Tighten combat AI and HUD state flow

This commit is contained in:
Admin 2026-09-19 01:24:49 +00:00
parent 79f98ce568
commit 9adf91162a
6 changed files with 333 additions and 364 deletions

View File

@ -1,6 +1,5 @@
export const PLAYER_MAX_HULL = 100; export const PLAYER_MAX_HULL = 100;
export const DEFAULT_PIRATE_SHOT_DAMAGE = 6; export const DEFAULT_PIRATE_SHOT_DAMAGE = 6;
export const PLAYER_HIT_COOLDOWN_MS = 300;
export const normalizePlayerDamage = ( export const normalizePlayerDamage = (
rawDamage, rawDamage,
@ -11,107 +10,6 @@ export const normalizePlayerDamage = (
return Math.max(1, Math.min(25, value)); return Math.max(1, Math.min(25, value));
}; };
export const reducePlayerHitQueue = ({
hull,
lastHitAt,
now,
damages,
cooldownMs = PLAYER_HIT_COOLDOWN_MS,
maxHull = PLAYER_MAX_HULL,
}) => {
let nextHull = Number.isFinite(hull) ? hull : maxHull;
let nextLastHitAt = Number.isFinite(lastHitAt) ? lastHitAt : -Infinity;
let appliedHits = 0;
let ignoredHits = 0;
let totalDamage = 0;
for (const rawDamage of damages) {
if (now - nextLastHitAt < cooldownMs) {
ignoredHits += 1;
continue;
}
const damage = normalizePlayerDamage(rawDamage);
nextLastHitAt = now;
nextHull = Math.max(0, nextHull - damage);
totalDamage += damage;
appliedHits += 1;
if (nextHull <= 0) break;
}
return {
hull: nextHull,
lastHitAt: nextLastHitAt,
appliedHits,
ignoredHits,
totalDamage,
gameOver: nextHull <= 0,
};
};
export const processQueuedPlayerHits = ({
hull,
lastHitAt,
now,
hits,
cooldownMs = PLAYER_HIT_COOLDOWN_MS,
maxHull = PLAYER_MAX_HULL,
}) => {
return reducePlayerHitQueue({
hull,
lastHitAt,
now,
damages: hits.map(({ damage }) => damage),
cooldownMs,
maxHull,
});
};
export const reducePlayerVolleyHits = ({
hull,
hits,
seenVolleyIds = [],
maxHull = PLAYER_MAX_HULL,
fallbackDamage = DEFAULT_PIRATE_SHOT_DAMAGE,
maxSeenVolleys = 128,
}) => {
const seen = new Set(seenVolleyIds);
const acceptedHits = [];
const ignoredHits = [];
let totalDamage = 0;
for (const hit of hits) {
const volleyId = hit?.volleyId ?? null;
if (volleyId !== null && seen.has(volleyId)) {
ignoredHits.push(hit);
continue;
}
if (volleyId !== null) seen.add(volleyId);
acceptedHits.push(hit);
totalDamage += normalizePlayerDamage(hit?.damage, fallbackDamage);
}
while (seen.size > maxSeenVolleys) {
const oldest = seen.values().next().value;
seen.delete(oldest);
}
const currentHull = Number.isFinite(hull) ? hull : maxHull;
const nextHull = Math.max(0, currentHull - totalDamage);
return {
hull: nextHull,
totalDamage,
acceptedHits,
ignoredHits,
seenVolleyIds: seen,
gameOver: nextHull <= 0,
};
};
export const reducePlayerProjectileHits = ({ export const reducePlayerProjectileHits = ({
hull, hull,
hits, hits,
@ -133,7 +31,6 @@ export const reducePlayerProjectileHits = ({
}; };
}; };
export const resolveTaggedProjectile = (first, second, owner) => { export const resolveTaggedProjectile = (first, second, owner) => {
for (const candidate of [first, second]) { for (const candidate of [first, second]) {
if (!candidate || typeof candidate.getData !== 'function') continue; if (!candidate || typeof candidate.getData !== 'function') continue;

View File

@ -3,8 +3,19 @@ import './style.css';
import airshipSheetUrl from './assets/airships.svg?url'; import airshipSheetUrl from './assets/airships.svg?url';
import explosionSheetUrl from './assets/explosions.svg?url'; import explosionSheetUrl from './assets/explosions.svg?url';
import { airshipLog, registerDebugSnapshot } from './diagnostics.js'; import { airshipLog, registerDebugSnapshot } from './diagnostics.js';
import { normalizePlayerDamage, reducePlayerProjectileHits, resolveTaggedProjectile } from './game-logic.js'; import {
import { pirateClassForWave, separationVector } from './pirate-ai.js'; DEFAULT_DEFAULT_PIRATE_SHOT_DAMAGE,
PLAYER_MAX_HULL,
normalizePlayerDamage,
reducePlayerProjectileHits,
resolveTaggedProjectile,
} from './game-logic.js';
import {
pirateDesiredHeading,
pirateRosterForWave,
pirateSpeedForRange,
separationVector,
} from './pirate-ai.js';
const WORLD_SIZE = 2400; const WORLD_SIZE = 2400;
const PLAYER_MAX_SPEED = 180; const PLAYER_MAX_SPEED = 180;
@ -18,10 +29,9 @@ const HELM_STEER_CURVE = 1.18;
const PLAYER_BROADSIDE_RELOAD_MS = 850; const PLAYER_BROADSIDE_RELOAD_MS = 850;
const SHOT_SPEED = 520; const SHOT_SPEED = 520;
const SHOT_LIFETIME = 900; const SHOT_LIFETIME = 900;
const PLAYER_MAX_HULL = 100;
const PIRATE_FALLBACK_MAX_HULL = 120; const PIRATE_FALLBACK_MAX_HULL = 120;
const PIRATE_SHOT_DAMAGE = 6;
const MAX_PIRATES = 6; const MAX_PIRATES = 6;
const HUD_PUBLISH_INTERVAL_MS = 50;
const AUTO_RESET_MS = 2200; const AUTO_RESET_MS = 2200;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
@ -51,6 +61,7 @@ class GameScene extends Phaser.Scene {
this.pendingPlayerHits = []; this.pendingPlayerHits = [];
this.playerHitSequence = 0; this.playerHitSequence = 0;
this.lastDiagnosticsAt = -Infinity; this.lastDiagnosticsAt = -Infinity;
this.lastHudPublishAt = -Infinity;
this.wave = 1; this.wave = 1;
this.defeated = 0; this.defeated = 0;
@ -267,8 +278,13 @@ class GameScene extends Phaser.Scene {
} }
spawnWave() { spawnWave() {
const count = Math.min(this.wave, MAX_PIRATES); const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
airshipLog('wave:spawn', { wave: this.wave, count }); const count = roster.length;
airshipLog('wave:spawn', {
wave: this.wave,
count,
classes: roster.map(({ key }) => key),
});
this.status.setText(`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}`); this.status.setText(`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}`);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@ -276,7 +292,7 @@ class GameScene extends Phaser.Scene {
const radius = Phaser.Math.Between(500, 760); const radius = Phaser.Math.Between(500, 760);
const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120); const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120);
const y = clamp(this.player.y + Math.sin(angle) * radius, 120, WORLD_SIZE - 120); const y = clamp(this.player.y + Math.sin(angle) * radius, 120, WORLD_SIZE - 120);
this.spawnPirate(x, y, angle, pirateClassForWave(this.wave, i, count)); this.spawnPirate(x, y, angle, roster[i]);
} }
this.time.delayedCall(900, () => { this.time.delayedCall(900, () => {
@ -297,7 +313,7 @@ class GameScene extends Phaser.Scene {
pirate.setData('speed', pirateClass.speed); pirate.setData('speed', pirateClass.speed);
pirate.setData('turnRate', pirateClass.turnRate); pirate.setData('turnRate', pirateClass.turnRate);
pirate.setData('reloadMs', pirateClass.reloadMs); pirate.setData('reloadMs', pirateClass.reloadMs);
pirate.setData('damage', pirateClass.damage); pirate.setData('projectileDamage', pirateClass.projectileDamage);
pirate.setData('preferredRange', pirateClass.preferredRange); pirate.setData('preferredRange', pirateClass.preferredRange);
pirate.setData('retreatRange', pirateClass.retreatRange); pirate.setData('retreatRange', pirateClass.retreatRange);
pirate.setData('broadsideRange', pirateClass.broadsideRange); pirate.setData('broadsideRange', pirateClass.broadsideRange);
@ -367,16 +383,18 @@ class GameScene extends Phaser.Scene {
if (this.gameState !== 'playing') { if (this.gameState !== 'playing') {
if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart(); if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart();
this.drawHud(); const hudState = this.buildHudState();
this.publishHudState(); if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, true);
return; return;
} }
this.processPlayerHits(); this.processPlayerHits();
this.ensureLivePlayerState(); this.validateLivePlayerState();
if (this.gameState !== 'playing') { if (this.gameState !== 'playing') {
this.drawHud(); const hudState = this.buildHudState();
this.publishHudState(); if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, true);
return; return;
} }
@ -398,9 +416,11 @@ class GameScene extends Phaser.Scene {
this.updatePirates(dt); this.updatePirates(dt);
this.updateProjectiles(); this.updateProjectiles();
this.drawWeaponArcs(); this.drawWeaponArcs();
this.drawHud();
this.checkGameState(); this.checkGameState();
this.publishHudState();
const hudState = this.buildHudState();
if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, hadQueuedPlayerHit);
if (hadQueuedPlayerHit) { if (hadQueuedPlayerHit) {
airshipLog('combat:post-hit-frame-complete', { airshipLog('combat:post-hit-frame-complete', {
@ -463,12 +483,13 @@ class GameScene extends Phaser.Scene {
const turnRate = pirate.getData('turnRate') || 52; const turnRate = pirate.getData('turnRate') || 52;
const baseSpeed = pirate.getData('speed') || 118; const baseSpeed = pirate.getData('speed') || 118;
let desiredHeading = bearing; let desiredHeading = pirateDesiredHeading({
if (distance < preferredRange + 40) { bearing,
const optionA = normalizeDegrees(bearing + 90); currentHeading: pirate.angle,
const optionB = normalizeDegrees(bearing - 90); distance,
desiredHeading = Math.abs(signedAngleDelta(optionA, pirate.angle)) < Math.abs(signedAngleDelta(optionB, pirate.angle)) ? optionA : optionB; preferredRange,
} retreatRange,
});
const separation = separationVector(pirate, pirates); const separation = separationVector(pirate, pirates);
if (separation.x || separation.y) { if (separation.x || separation.y) {
@ -481,11 +502,12 @@ class GameScene extends Phaser.Scene {
const delta = signedAngleDelta(desiredHeading, pirate.angle); const delta = signedAngleDelta(desiredHeading, pirate.angle);
pirate.angle += clamp(delta, -turnRate * dt, turnRate * dt); pirate.angle += clamp(delta, -turnRate * dt, turnRate * dt);
const speed = distance > preferredRange + 100 const speed = pirateSpeedForRange({
? baseSpeed distance,
: distance < retreatRange preferredRange,
? baseSpeed * 0.58 retreatRange,
: baseSpeed * 0.82; baseSpeed,
});
const h = headingRad(pirate); const h = headingRad(pirate);
pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed); pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed);
@ -495,11 +517,11 @@ class GameScene extends Phaser.Scene {
const broadsideRange = pirate.getData('broadsideRange') || 420; const broadsideRange = pirate.getData('broadsideRange') || 420;
const broadsideTolerance = pirate.getData('broadsideError') || 38; const broadsideTolerance = pirate.getData('broadsideError') || 38;
const reloadMs = pirate.getData('reloadMs') || 1650; const reloadMs = pirate.getData('reloadMs') || 1650;
const damage = pirate.getData('damage') || PIRATE_SHOT_DAMAGE; const projectileDamage = pirate.getData('projectileDamage') || DEFAULT_PIRATE_SHOT_DAMAGE;
if (distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) { if (distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) {
const side = relative >= 0 ? 1 : -1; const side = relative >= 0 ? 1 : -1;
this.fireBroadside(pirate, side, this.pirateShots, 'pirate-shot', damage, 0.9); this.fireBroadside(pirate, side, this.pirateShots, 'pirate-shot', projectileDamage, 0.9);
pirate.setData('lastShot', this.time.now); pirate.setData('lastShot', this.time.now);
} }
} }
@ -507,12 +529,12 @@ class GameScene extends Phaser.Scene {
playerBroadside(side) { playerBroadside(side) {
const broadside = side < 0 ? 'port' : 'starboard'; const broadside = side < 0 ? 'port' : 'starboard';
if (this.time.now - this.lastPlayerShot[broadside] < PLAYER_BROADSIDE_RELOAD_MS) return; if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return;
this.fireBroadside(this.player, side, this.playerShots, 'player-shot', 16, 1.0); this.fireBroadside(this.player, side, this.playerShots, 'player-shot', 16, 1.0);
this.lastPlayerShot[broadside] = this.time.now; this.lastPlayerShot[broadside] = this.time.now;
} }
fireBroadside(ship, side, group, texture, damage, velocityScale) { fireBroadside(ship, side, group, texture, projectileDamage, velocityScale) {
if (!ship?.body) return; if (!ship?.body) return;
const actor = group === this.pirateShots ? 'pirate' : 'player'; const actor = group === this.pirateShots ? 'pirate' : 'player';
const base = headingRad(ship) + side * Math.PI / 2; const base = headingRad(ship) + side * Math.PI / 2;
@ -520,7 +542,7 @@ class GameScene extends Phaser.Scene {
airshipLog('combat:broadside-fired', { airshipLog('combat:broadside-fired', {
actor, actor,
side: side < 0 ? 'port' : 'starboard', side: side < 0 ? 'port' : 'starboard',
damage, damage: projectileDamage,
x: Math.round(ship.x), x: Math.round(ship.x),
y: Math.round(ship.y), y: Math.round(ship.y),
angle: Math.round(ship.angle), angle: Math.round(ship.angle),
@ -535,7 +557,7 @@ class GameScene extends Phaser.Scene {
shot.setDepth(8); shot.setDepth(8);
shot.body.setAllowGravity(false); shot.body.setAllowGravity(false);
shot.setData('bornAt', this.time.now); shot.setData('bornAt', this.time.now);
shot.setData('damage', damage); shot.setData('damage', projectileDamage);
shot.setData('projectileOwner', actor); shot.setData('projectileOwner', actor);
if (actor === 'pirate') shot.setData('playerHitQueued', false); if (actor === 'pirate') shot.setData('playerHitQueued', false);
shot.setVelocity( shot.setVelocity(
@ -602,7 +624,7 @@ class GameScene extends Phaser.Scene {
if (shot.getData('playerHitQueued')) return; if (shot.getData('playerHitQueued')) return;
const damage = normalizePlayerDamage(shot.getData('damage'), PIRATE_SHOT_DAMAGE); const damage = normalizePlayerDamage(shot.getData('damage'), DEFAULT_PIRATE_SHOT_DAMAGE);
// The overlap callback only records a verified pirate projectile. // The overlap callback only records a verified pirate projectile.
// Hull mutation and projectile destruction happen on the next scene update, // Hull mutation and projectile destruction happen on the next scene update,
@ -643,7 +665,7 @@ class GameScene extends Phaser.Scene {
hull: this.playerHull, hull: this.playerHull,
hits, hits,
maxHull: PLAYER_MAX_HULL, maxHull: PLAYER_MAX_HULL,
fallbackDamage: PIRATE_SHOT_DAMAGE, fallbackDamage: DEFAULT_PIRATE_SHOT_DAMAGE,
}); });
this.playerHull = result.hull; this.playerHull = result.hull;
@ -714,7 +736,7 @@ class GameScene extends Phaser.Scene {
}); });
} }
playPlayerHitReaction(damage = PIRATE_SHOT_DAMAGE) { playPlayerHitReaction(damage = DEFAULT_PIRATE_SHOT_DAMAGE) {
if (!this.player?.active) return; if (!this.player?.active) return;
this.player.setTint(0xffd7d7); this.player.setTint(0xffd7d7);
@ -725,15 +747,25 @@ class GameScene extends Phaser.Scene {
this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012)); this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012));
} }
ensureLivePlayerState() { validateLivePlayerState() {
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return; if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
if (!this.player.active) this.player.setActive(true); const issues = [];
if (!this.player.visible) this.player.setVisible(true); if (!this.player.active) issues.push('inactive');
if (this.player.alpha !== 1) this.player.setAlpha(1); if (!this.player.visible) issues.push('hidden');
if (this.player.alpha !== 1) issues.push('alpha');
if (!this.player.body) issues.push('body-missing');
else if (!this.player.body.enable) issues.push('body-disabled');
if (this.player.body && !this.player.body.enable) { if (issues.length > 0) {
this.player.body.enable = true; airshipLog('player:live-state-invalid', {
issues,
hull: this.playerHull,
active: this.player.active,
visible: this.player.visible,
alpha: this.player.alpha,
bodyEnabled: this.player.body?.enable ?? null,
}, 'warn');
} }
} }
@ -745,7 +777,7 @@ class GameScene extends Phaser.Scene {
const { pirate: nearest, range } = this.nearestPirate(); const { pirate: nearest, range } = this.nearestPirate();
const now = this.time?.now ?? 0; const now = this.time?.now ?? 0;
const reloadReady = (side) => clamp( const reloadReady = (side) => clamp(
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / this.playerBroadsideReloadMs, (now - (this.lastPlayerShot?.[side] ?? -Infinity)) / Math.max(1, this.playerBroadsideReloadMs),
0, 0,
1, 1,
); );
@ -780,9 +812,13 @@ class GameScene extends Phaser.Scene {
}; };
} }
publishHudState() { publishHudState(state = this.buildHudState(), force = false) {
const state = this.buildHudState(); if (!this.mobileHudActive || !state) return;
if (!state) return;
const now = this.time?.now ?? 0;
if (!force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return;
this.lastHudPublishAt = now;
window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state })); window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state }));
} }
@ -825,28 +861,30 @@ class GameScene extends Phaser.Scene {
return { pirate: nearest, range: nearestRange }; return { pirate: nearest, range: nearestRange };
} }
drawHud() { drawHud(state = this.buildHudState()) {
if (!state) return;
const width = this.scale.width; const width = this.scale.width;
const compact = width < 700; const compact = width < 700;
const heading = normalizeDegrees(this.player.angle); const target = state.target;
const activePirates = this.pirates?.countActive(true) || 0; const playerHull = state.player?.hull ?? PLAYER_MAX_HULL;
const { pirate: nearest, range } = this.nearestPirate(); const playerMaxHull = state.player?.maxHull ?? PLAYER_MAX_HULL;
const nearestHull = nearest ? (nearest.getData('hull') || 0) : 0; const nearestHull = target?.hull ?? 0;
const nearestMaxHull = nearest ? (nearest.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL) : PIRATE_FALLBACK_MAX_HULL; const nearestMaxHull = target?.maxHull ?? PIRATE_FALLBACK_MAX_HULL;
const nearestClass = nearest ? (nearest.getData('classLabel') || 'PIRATE') : 'PIRATE'; const nearestClass = target?.name || 'PIRATE';
const rangeText = Number.isFinite(range) ? `${range.toFixed(0)}m` : '--'; const rangeText = target && Number.isFinite(target.range) ? `${target.range.toFixed(0)}m` : '--';
this.speedText.setText(`${Math.abs(this.playerSpeed).toFixed(0)} ${this.playerSpeed < 0 ? 'REV' : 'FWD'}`); this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`);
this.headingText.setText(`${heading.toFixed(0).padStart(3, '0')}°`); this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`);
this.waveText.setText(`WAVE ${this.wave} • ${activePirates} HOSTILE${activePirates === 1 ? '' : 'S'}`); this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`);
this.combatText.setText(compact this.combatText.setText(compact
? `K ${this.defeated}\n${rangeText}` ? `K ${state.kills}\n${rangeText}`
: `DEFEATED ${this.defeated}\nRANGE ${rangeText}`); : `DEFEATED ${state.kills}\nRANGE ${rangeText}`);
this.playerHullLabel.setText(`SPARROW ${this.playerHull}/${PLAYER_MAX_HULL}`); this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(nearest this.enemyHullLabel.setText(target
? `${nearestClass} ${nearestHull}/${nearestMaxHull}` ? `${nearestClass} ${nearestHull}/${nearestMaxHull}`
: `WAVE ${this.wave} CLEAR`); : `WAVE ${state.wave} CLEAR`);
const side = 14; const side = 14;
const gap = 14; const gap = 14;
@ -866,9 +904,9 @@ class GameScene extends Phaser.Scene {
this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth, barHeight, 4); this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth, barHeight, 4);
this.hudGraphics.fillStyle(0x61c98b, 1); this.hudGraphics.fillStyle(0x61c98b, 1);
this.hudGraphics.fillRoundedRect(side, barY, barWidth * (this.playerHull / PLAYER_MAX_HULL), barHeight, 4); this.hudGraphics.fillRoundedRect(side, barY, barWidth * (playerHull / playerMaxHull), barHeight, 4);
if (nearest) { if (target) {
this.hudGraphics.fillStyle(0xd65757, 1); this.hudGraphics.fillStyle(0xd65757, 1);
this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth * (nearestHull / nearestMaxHull), barHeight, 4); this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth * (nearestHull / nearestMaxHull), barHeight, 4);
} }

View File

@ -6,7 +6,7 @@ export const PIRATE_CLASSES = {
speed: 148, speed: 148,
turnRate: 70, turnRate: 70,
reloadMs: 1280, reloadMs: 1280,
damage: 4, projectileDamage: 4,
preferredRange: 300, preferredRange: 300,
retreatRange: 175, retreatRange: 175,
broadsideRange: 380, broadsideRange: 380,
@ -20,7 +20,7 @@ export const PIRATE_CLASSES = {
speed: 118, speed: 118,
turnRate: 52, turnRate: 52,
reloadMs: 1650, reloadMs: 1650,
damage: 6, projectileDamage: 6,
preferredRange: 335, preferredRange: 335,
retreatRange: 210, retreatRange: 210,
broadsideRange: 420, broadsideRange: 420,
@ -34,7 +34,7 @@ export const PIRATE_CLASSES = {
speed: 102, speed: 102,
turnRate: 44, turnRate: 44,
reloadMs: 1450, reloadMs: 1450,
damage: 8, projectileDamage: 8,
preferredRange: 365, preferredRange: 365,
retreatRange: 225, retreatRange: 225,
broadsideRange: 470, broadsideRange: 470,
@ -43,10 +43,93 @@ export const PIRATE_CLASSES = {
}, },
}; };
export const pirateClassForWave = (wave, index, count) => { export const PIRATE_CLASS_COSTS = {
if (wave >= 4 && wave % 2 === 0 && index === count - 1) return PIRATE_CLASSES.captain; raider: 1,
if (wave >= 2 && (wave + index) % 3 === 0) return PIRATE_CLASSES.raider; frigate: 2,
return PIRATE_CLASSES.frigate; captain: 3,
};
const normalizeDegrees = (deg) => ((deg % 360) + 360) % 360;
const signedAngleDelta = (target, current) => {
let delta = normalizeDegrees(target) - normalizeDegrees(current);
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
};
export const waveDifficultyBudget = (wave) => {
const safeWave = Math.max(1, Math.floor(Number(wave) || 1));
return safeWave + 1 + Math.floor((safeWave - 1) / 2);
};
export const pirateRosterForWave = (wave, maxPirates = 6) => {
const safeWave = Math.max(1, Math.floor(Number(wave) || 1));
const safeMaxPirates = Math.max(1, Math.floor(Number(maxPirates) || 1));
const count = Math.min(safeWave, safeMaxPirates);
let remainingBudget = Math.max(count, waveDifficultyBudget(safeWave));
let captainSlots = safeWave >= 4 ? Math.max(1, Math.floor(safeWave / 4)) : 0;
const roster = [];
for (let index = 0; index < count; index += 1) {
const remainingSlots = count - index - 1;
const minimumRemainingCost = remainingSlots * PIRATE_CLASS_COSTS.raider;
if (
captainSlots > 0
&& remainingBudget - PIRATE_CLASS_COSTS.captain >= minimumRemainingCost
) {
roster.push(PIRATE_CLASSES.captain);
remainingBudget -= PIRATE_CLASS_COSTS.captain;
captainSlots -= 1;
continue;
}
if (remainingBudget - PIRATE_CLASS_COSTS.frigate >= minimumRemainingCost) {
roster.push(PIRATE_CLASSES.frigate);
remainingBudget -= PIRATE_CLASS_COSTS.frigate;
continue;
}
roster.push(PIRATE_CLASSES.raider);
remainingBudget -= PIRATE_CLASS_COSTS.raider;
}
return roster;
};
export const pirateDesiredHeading = ({
bearing,
currentHeading,
distance,
preferredRange,
retreatRange,
}) => {
if (distance < retreatRange) {
return normalizeDegrees(bearing + 180);
}
if (distance < preferredRange + 40) {
const optionA = normalizeDegrees(bearing + 90);
const optionB = normalizeDegrees(bearing - 90);
return Math.abs(signedAngleDelta(optionA, currentHeading))
< Math.abs(signedAngleDelta(optionB, currentHeading))
? optionA
: optionB;
}
return normalizeDegrees(bearing);
};
export const pirateSpeedForRange = ({
distance,
preferredRange,
retreatRange,
baseSpeed,
}) => {
if (distance < retreatRange) return baseSpeed * 0.9;
if (distance > preferredRange + 100) return baseSpeed;
return baseSpeed * 0.82;
}; };
export const separationVector = (ship, ships, radius = 125) => { export const separationVector = (ship, ships, radius = 125) => {

View File

@ -3,104 +3,24 @@ import assert from 'node:assert/strict';
import { import {
DEFAULT_PIRATE_SHOT_DAMAGE, DEFAULT_PIRATE_SHOT_DAMAGE,
PLAYER_HIT_COOLDOWN_MS,
PLAYER_MAX_HULL, PLAYER_MAX_HULL,
normalizePlayerDamage, normalizePlayerDamage,
processQueuedPlayerHits,
reducePlayerHitQueue,
reducePlayerProjectileHits, reducePlayerProjectileHits,
reducePlayerVolleyHits,
resolveTaggedProjectile, resolveTaggedProjectile,
} from '../src/game-logic.js'; } from '../src/game-logic.js';
test('first pirate hit removes exactly the default damage and does not end the game', () => { test('first pirate projectile removes exactly the default damage', () => {
const result = reducePlayerHitQueue({ const result = reducePlayerProjectileHits({
hull: PLAYER_MAX_HULL, hull: PLAYER_MAX_HULL,
lastHitAt: -Infinity, hits: [{ damage: DEFAULT_PIRATE_SHOT_DAMAGE }],
now: 1000,
damages: [DEFAULT_PIRATE_SHOT_DAMAGE],
}); });
assert.equal(result.hull, 94); assert.equal(result.hull, 94);
assert.equal(result.appliedHits, 1); assert.equal(result.appliedHits, 1);
assert.equal(result.ignoredHits, 0);
assert.equal(result.totalDamage, 6); assert.equal(result.totalDamage, 6);
assert.equal(result.gameOver, false); assert.equal(result.gameOver, false);
}); });
test('actual queued first player hit applies 6 damage without mutating projectile lifecycle', () => {
const shot = {
active: true,
destroyed: false,
destroy() {
this.destroyed = true;
this.active = false;
},
};
const firstFrame = processQueuedPlayerHits({
hull: PLAYER_MAX_HULL,
lastHitAt: -Infinity,
now: 1000,
hits: [{ shot, damage: DEFAULT_PIRATE_SHOT_DAMAGE }],
});
assert.equal(shot.destroyed, false);
assert.equal(shot.active, true);
assert.equal(firstFrame.hull, 94);
assert.equal(firstFrame.appliedHits, 1);
assert.equal(firstFrame.ignoredHits, 0);
assert.equal(firstFrame.gameOver, false);
const followingFrame = processQueuedPlayerHits({
hull: firstFrame.hull,
lastHitAt: firstFrame.lastHitAt,
now: 1016,
hits: [],
});
assert.equal(followingFrame.hull, 94);
assert.equal(followingFrame.gameOver, false);
assert.equal(followingFrame.appliedHits, 0);
});
test('three cannonballs arriving in one broadside only apply one hull hit', () => {
const result = reducePlayerHitQueue({
hull: 100,
lastHitAt: -Infinity,
now: 2000,
damages: [6, 6, 6],
});
assert.equal(result.hull, 94);
assert.equal(result.appliedHits, 1);
assert.equal(result.ignoredHits, 2);
});
test('hit inside cooldown is ignored and exact cooldown boundary is accepted', () => {
const blocked = reducePlayerHitQueue({
hull: 94,
lastHitAt: 1000,
now: 1000 + PLAYER_HIT_COOLDOWN_MS - 1,
damages: [6],
});
assert.equal(blocked.hull, 94);
assert.equal(blocked.appliedHits, 0);
assert.equal(blocked.ignoredHits, 1);
const accepted = reducePlayerHitQueue({
hull: 94,
lastHitAt: 1000,
now: 1000 + PLAYER_HIT_COOLDOWN_MS,
damages: [6],
});
assert.equal(accepted.hull, 88);
assert.equal(accepted.appliedHits, 1);
});
test('damage normalization handles invalid and extreme values safely', () => { test('damage normalization handles invalid and extreme values safely', () => {
assert.equal(normalizePlayerDamage(undefined), 6); assert.equal(normalizePlayerDamage(undefined), 6);
assert.equal(normalizePlayerDamage('not-a-number'), 6); assert.equal(normalizePlayerDamage('not-a-number'), 6);
@ -109,24 +29,20 @@ test('damage normalization handles invalid and extreme values safely', () => {
assert.equal(normalizePlayerDamage('8'), 8); assert.equal(normalizePlayerDamage('8'), 8);
}); });
test('invalid hull state recovers from max hull before applying damage', () => { test('invalid hull state recovers from max hull before applying projectile damage', () => {
const result = reducePlayerHitQueue({ const result = reducePlayerProjectileHits({
hull: Number.NaN, hull: Number.NaN,
lastHitAt: -Infinity, hits: [{ damage: 6 }],
now: 3000,
damages: [6],
}); });
assert.equal(result.hull, 94); assert.equal(result.hull, 94);
assert.equal(result.gameOver, false); assert.equal(result.gameOver, false);
}); });
test('lethal hit clamps hull at zero and reports game over', () => { test('lethal projectile damage clamps hull at zero and reports game over', () => {
const result = reducePlayerHitQueue({ const result = reducePlayerProjectileHits({
hull: 5, hull: 5,
lastHitAt: -Infinity, hits: [{ damage: 6 }],
now: 4000,
damages: [6],
}); });
assert.equal(result.hull, 0); assert.equal(result.hull, 0);
@ -134,97 +50,30 @@ test('lethal hit clamps hull at zero and reports game over', () => {
assert.equal(result.gameOver, true); assert.equal(result.gameOver, true);
}); });
test('empty hit queue is a no-op', () => { test('empty projectile queue is a no-op', () => {
const result = reducePlayerHitQueue({ const result = reducePlayerProjectileHits({
hull: 72, hull: 72,
lastHitAt: 1234, hits: [],
now: 5000,
damages: [],
}); });
assert.equal(result.hull, 72); assert.equal(result.hull, 72);
assert.equal(result.lastHitAt, 1234); assert.equal(result.totalDamage, 0);
assert.equal(result.appliedHits, 0); assert.equal(result.appliedHits, 0);
assert.equal(result.ignoredHits, 0);
assert.equal(result.gameOver, false); assert.equal(result.gameOver, false);
}); });
test('one pirate broadside damages once, and the next broadside damages again', () => { test('every distinct pirate projectile can damage once with no cross-shot suppression', () => {
const firstVolley = reducePlayerVolleyHits({
hull: 100,
hits: [
{ volleyId: 101, damage: 6 },
{ volleyId: 101, damage: 6 },
{ volleyId: 101, damage: 6 },
],
});
assert.equal(firstVolley.hull, 94);
assert.equal(firstVolley.totalDamage, 6);
assert.equal(firstVolley.acceptedHits.length, 1);
assert.equal(firstVolley.ignoredHits.length, 2);
const secondVolley = reducePlayerVolleyHits({
hull: firstVolley.hull,
seenVolleyIds: firstVolley.seenVolleyIds,
hits: [
{ volleyId: 102, damage: 6 },
{ volleyId: 102, damage: 6 },
],
});
assert.equal(secondVolley.hull, 88);
assert.equal(secondVolley.totalDamage, 6);
assert.equal(secondVolley.acceptedHits.length, 1);
assert.equal(secondVolley.ignoredHits.length, 1);
const repeatedSecondVolley = reducePlayerVolleyHits({
hull: secondVolley.hull,
seenVolleyIds: secondVolley.seenVolleyIds,
hits: [{ volleyId: 102, damage: 6 }],
});
assert.equal(repeatedSecondVolley.hull, 88);
assert.equal(repeatedSecondVolley.totalDamage, 0);
assert.equal(repeatedSecondVolley.acceptedHits.length, 0);
assert.equal(repeatedSecondVolley.ignoredHits.length, 1);
});
test('different pirate volleys can both damage even when processed together', () => {
const result = reducePlayerVolleyHits({
hull: 100,
hits: [
{ volleyId: 201, damage: 6 },
{ volleyId: 202, damage: 6 },
],
});
assert.equal(result.hull, 88);
assert.equal(result.totalDamage, 12);
assert.equal(result.acceptedHits.length, 2);
assert.equal(result.ignoredHits.length, 0);
});
test('every new pirate projectile can damage once with no cross-shot suppression', () => {
const first = reducePlayerProjectileHits({ const first = reducePlayerProjectileHits({
hull: 100, hull: 100,
hits: [{ damage: 6 }], hits: [{ damage: 6 }],
}); });
assert.equal(first.hull, 94);
assert.equal(first.totalDamage, 6);
assert.equal(first.appliedHits, 1);
assert.equal(first.gameOver, false);
const second = reducePlayerProjectileHits({ const second = reducePlayerProjectileHits({
hull: first.hull, hull: first.hull,
hits: [{ damage: 6 }], hits: [{ damage: 6 }],
}); });
assert.equal(first.hull, 94);
assert.equal(second.hull, 88); assert.equal(second.hull, 88);
assert.equal(second.totalDamage, 6);
assert.equal(second.appliedHits, 1);
assert.equal(second.gameOver, false);
}); });
test('multiple distinct pirate projectiles in the same frame all apply damage', () => { test('multiple distinct pirate projectiles in the same frame all apply damage', () => {
@ -242,7 +91,6 @@ test('multiple distinct pirate projectiles in the same frame all apply damage',
assert.equal(result.appliedHits, 3); assert.equal(result.appliedHits, 3);
}); });
test('projectile resolver is independent of Phaser overlap argument order', () => { test('projectile resolver is independent of Phaser overlap argument order', () => {
const player = { const player = {
getData() { return undefined; }, getData() { return undefined; },

View File

@ -1,19 +1,93 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PIRATE_CLASSES, pirateClassForWave, separationVector } from '../src/pirate-ai.js'; import {
PIRATE_CLASSES,
PIRATE_CLASS_COSTS,
pirateDesiredHeading,
pirateRosterForWave,
pirateSpeedForRange,
separationVector,
waveDifficultyBudget,
} from '../src/pirate-ai.js';
test('pirate classes expose distinct combat roles', () => { const rosterCost = (roster) => roster.reduce(
(sum, pirateClass) => sum + PIRATE_CLASS_COSTS[pirateClass.key],
0,
);
test('pirate classes expose distinct combat roles and projectile damage', () => {
assert.ok(PIRATE_CLASSES.raider.speed > PIRATE_CLASSES.frigate.speed); assert.ok(PIRATE_CLASSES.raider.speed > PIRATE_CLASSES.frigate.speed);
assert.ok(PIRATE_CLASSES.raider.hull < PIRATE_CLASSES.frigate.hull); assert.ok(PIRATE_CLASSES.raider.hull < PIRATE_CLASSES.frigate.hull);
assert.ok(PIRATE_CLASSES.captain.hull > PIRATE_CLASSES.frigate.hull); assert.ok(PIRATE_CLASSES.captain.hull > PIRATE_CLASSES.frigate.hull);
assert.ok(PIRATE_CLASSES.captain.damage > PIRATE_CLASSES.frigate.damage); assert.ok(PIRATE_CLASSES.captain.projectileDamage > PIRATE_CLASSES.frigate.projectileDamage);
}); });
test('waves introduce raiders and captains without exceeding the wave composition', () => { test('wave roster uses a monotonic difficulty budget with a six-ship cap', () => {
assert.equal(pirateClassForWave(1, 0, 1).key, 'frigate'); assert.deepEqual(
assert.equal(pirateClassForWave(3, 0, 3).key, 'raider'); pirateRosterForWave(1).map(({ key }) => key),
assert.equal(pirateClassForWave(4, 3, 4).key, 'captain'); ['frigate'],
);
assert.deepEqual(
pirateRosterForWave(2).map(({ key }) => key),
['frigate', 'raider'],
);
assert.deepEqual(
pirateRosterForWave(4).map(({ key }) => key),
['captain', 'raider', 'raider', 'raider'],
);
let previousCost = 0;
for (let wave = 1; wave <= 12; wave += 1) {
const roster = pirateRosterForWave(wave);
const cost = rosterCost(roster);
assert.ok(roster.length <= 6);
assert.ok(cost <= waveDifficultyBudget(wave));
assert.ok(cost >= previousCost, `wave ${wave} should not get cheaper`);
previousCost = cost;
}
});
test('pirates retreat away from the player when inside retreat range', () => {
const heading = pirateDesiredHeading({
bearing: 20,
currentHeading: 90,
distance: 150,
preferredRange: 335,
retreatRange: 210,
});
assert.equal(heading, 200);
});
test('pirates choose the closest broadside heading inside preferred range', () => {
const heading = pirateDesiredHeading({
bearing: 0,
currentHeading: 80,
distance: 300,
preferredRange: 335,
retreatRange: 210,
});
assert.equal(heading, 90);
});
test('retreat speed keeps pirates moving out of close range', () => {
const retreat = pirateSpeedForRange({
distance: 150,
preferredRange: 335,
retreatRange: 210,
baseSpeed: 118,
});
const broadside = pirateSpeedForRange({
distance: 300,
preferredRange: 335,
retreatRange: 210,
baseSpeed: 118,
});
assert.ok(retreat > broadside);
}); });
test('separation steering pushes nearby ships apart and ignores distant ships', () => { test('separation steering pushes nearby ships apart and ignores distant ships', () => {

View File

@ -105,15 +105,18 @@ test('player hit FX runs after queued hit processing, outside the overlap callba
}); });
test('player damage is per projectile with no global cooldown or volley suppression', async () => { test('player damage is per projectile with obsolete cooldown and volley models removed', async () => {
const source = await read('src/game-v4.js'); const [source, logic] = await Promise.all([
read('src/game-v4.js'),
read('src/game-logic.js'),
]);
assert.match(source, /if \(shot\.getData\('playerHitQueued'\)\) return/); assert.match(source, /if \(shot\.getData\('playerHitQueued'\)\) return/);
assert.match(source, /reducePlayerProjectileHits\(\{/); assert.match(source, /reducePlayerProjectileHits\(\{/);
assert.doesNotMatch(source, /PLAYER_HIT_COOLDOWN_MS/); assert.doesNotMatch(logic, /PLAYER_HIT_COOLDOWN_MS/);
assert.doesNotMatch(source, /damagedPlayerVolleys/); assert.doesNotMatch(logic, /reducePlayerHitQueue/);
assert.doesNotMatch(source, /nextPirateVolleyId/); assert.doesNotMatch(logic, /reducePlayerVolleyHits/);
assert.doesNotMatch(source, /volleyId/); assert.doesNotMatch(logic, /processQueuedPlayerHits/);
}); });
test('portrait health sprites sit tightly beneath the top HUD', async () => { test('portrait health sprites sit tightly beneath the top HUD', async () => {
@ -171,15 +174,36 @@ test('game consumes analog helm vector and keeps broadside cooldowns independent
assert.match(source, /this\.lastPlayerShot\[broadside\]/); assert.match(source, /this\.lastPlayerShot\[broadside\]/);
}); });
test('player firing and reload HUD share one reload duration source', async () => {
test('pirate combat uses class stats and separation steering', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');
assert.match(source, /pirateClassForWave\(this\.wave, i, count\)/); assert.match(source, /this\.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS/);
assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < this\.playerBroadsideReloadMs/);
assert.match(source, /Math\.max\(1, this\.playerBroadsideReloadMs\)/);
});
test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /const HUD_PUBLISH_INTERVAL_MS = 50/);
assert.match(source, /if \(!this\.mobileHudActive\) this\.drawHud\(hudState\)/);
assert.match(source, /publishHudState\(state = this\.buildHudState\(\), force = false\)/);
assert.match(source, /if \(!this\.mobileHudActive \|\| !state\) return/);
assert.match(source, /now - this\.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS/);
assert.match(source, /drawHud\(state = this\.buildHudState\(\)\)/);
});
test('pirate combat uses budgeted class stats, retreat steering, and separation', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /pirateRosterForWave\(this\.wave, MAX_PIRATES\)/);
assert.match(source, /pirateDesiredHeading\(\{/);
assert.match(source, /pirateSpeedForRange\(\{/);
assert.match(source, /separationVector\(pirate, pirates\)/); assert.match(source, /separationVector\(pirate, pirates\)/);
assert.match(source, /pirate\.getData\('reloadMs'\)/); assert.match(source, /pirate\.getData\('reloadMs'\)/);
assert.match(source, /pirate\.getData\('projectileDamage'\)/);
assert.match(source, /pirate\.getData\('maxHull'\)/); assert.match(source, /pirate\.getData\('maxHull'\)/);
assert.match(source, /nearestHull \/ nearestMaxHull/);
}); });
test('mobile cannon HUD shows independent reload progress', async () => { test('mobile cannon HUD shows independent reload progress', async () => {
@ -234,15 +258,20 @@ test('processed pirate shots are consumed and later projectiles start clean', as
}); });
test('non-lethal damage preserves the live player sprite and body', async () => { test('live player validation reports invalid state without repairing gameplay state', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');
assert.match(source, /this\.processPlayerHits\(\);\s*this\.ensureLivePlayerState\(\)/); assert.match(source, /this\.processPlayerHits\(\);\s*this\.validateLivePlayerState\(\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| this\.playerHull <= 0 \|\| !this\.player\) return/); const match = /validateLivePlayerState\(\) \{([\s\S]*?)\n buildHudState\(/.exec(source);
assert.match(source, /if \(!this\.player\.active\) this\.player\.setActive\(true\)/); assert.ok(match, 'validateLivePlayerState block should be found');
assert.match(source, /if \(!this\.player\.visible\) this\.player\.setVisible\(true\)/);
assert.match(source, /if \(this\.player\.alpha !== 1\) this\.player\.setAlpha\(1\)/); const block = match[1];
assert.match(source, /if \(this\.player\.body && !this\.player\.body\.enable\)/); assert.match(block, /player:live-state-invalid/);
assert.match(block, /issues\.push\('body-disabled'\)/);
assert.doesNotMatch(block, /setActive\(/);
assert.doesNotMatch(block, /setVisible\(/);
assert.doesNotMatch(block, /setAlpha\(/);
assert.doesNotMatch(block, /body\.enable\s*=/);
}); });