Tighten combat AI and HUD state flow
This commit is contained in:
parent
79f98ce568
commit
9adf91162a
@ -1,6 +1,5 @@
|
||||
export const PLAYER_MAX_HULL = 100;
|
||||
export const DEFAULT_PIRATE_SHOT_DAMAGE = 6;
|
||||
export const PLAYER_HIT_COOLDOWN_MS = 300;
|
||||
|
||||
export const normalizePlayerDamage = (
|
||||
rawDamage,
|
||||
@ -11,107 +10,6 @@ export const normalizePlayerDamage = (
|
||||
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 = ({
|
||||
hull,
|
||||
hits,
|
||||
@ -133,7 +31,6 @@ export const reducePlayerProjectileHits = ({
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const resolveTaggedProjectile = (first, second, owner) => {
|
||||
for (const candidate of [first, second]) {
|
||||
if (!candidate || typeof candidate.getData !== 'function') continue;
|
||||
|
||||
164
src/game-v4.js
164
src/game-v4.js
@ -3,8 +3,19 @@ import './style.css';
|
||||
import airshipSheetUrl from './assets/airships.svg?url';
|
||||
import explosionSheetUrl from './assets/explosions.svg?url';
|
||||
import { airshipLog, registerDebugSnapshot } from './diagnostics.js';
|
||||
import { normalizePlayerDamage, reducePlayerProjectileHits, resolveTaggedProjectile } from './game-logic.js';
|
||||
import { pirateClassForWave, separationVector } from './pirate-ai.js';
|
||||
import {
|
||||
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 PLAYER_MAX_SPEED = 180;
|
||||
@ -18,10 +29,9 @@ const HELM_STEER_CURVE = 1.18;
|
||||
const PLAYER_BROADSIDE_RELOAD_MS = 850;
|
||||
const SHOT_SPEED = 520;
|
||||
const SHOT_LIFETIME = 900;
|
||||
const PLAYER_MAX_HULL = 100;
|
||||
const PIRATE_FALLBACK_MAX_HULL = 120;
|
||||
const PIRATE_SHOT_DAMAGE = 6;
|
||||
const MAX_PIRATES = 6;
|
||||
const HUD_PUBLISH_INTERVAL_MS = 50;
|
||||
const AUTO_RESET_MS = 2200;
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
@ -51,6 +61,7 @@ class GameScene extends Phaser.Scene {
|
||||
this.pendingPlayerHits = [];
|
||||
this.playerHitSequence = 0;
|
||||
this.lastDiagnosticsAt = -Infinity;
|
||||
this.lastHudPublishAt = -Infinity;
|
||||
this.wave = 1;
|
||||
this.defeated = 0;
|
||||
|
||||
@ -267,8 +278,13 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
spawnWave() {
|
||||
const count = Math.min(this.wave, MAX_PIRATES);
|
||||
airshipLog('wave:spawn', { wave: this.wave, count });
|
||||
const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
|
||||
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'}`);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
@ -276,7 +292,7 @@ class GameScene extends Phaser.Scene {
|
||||
const radius = Phaser.Math.Between(500, 760);
|
||||
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);
|
||||
this.spawnPirate(x, y, angle, pirateClassForWave(this.wave, i, count));
|
||||
this.spawnPirate(x, y, angle, roster[i]);
|
||||
}
|
||||
|
||||
this.time.delayedCall(900, () => {
|
||||
@ -297,7 +313,7 @@ class GameScene extends Phaser.Scene {
|
||||
pirate.setData('speed', pirateClass.speed);
|
||||
pirate.setData('turnRate', pirateClass.turnRate);
|
||||
pirate.setData('reloadMs', pirateClass.reloadMs);
|
||||
pirate.setData('damage', pirateClass.damage);
|
||||
pirate.setData('projectileDamage', pirateClass.projectileDamage);
|
||||
pirate.setData('preferredRange', pirateClass.preferredRange);
|
||||
pirate.setData('retreatRange', pirateClass.retreatRange);
|
||||
pirate.setData('broadsideRange', pirateClass.broadsideRange);
|
||||
@ -367,16 +383,18 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
if (this.gameState !== 'playing') {
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart();
|
||||
this.drawHud();
|
||||
this.publishHudState();
|
||||
const hudState = this.buildHudState();
|
||||
if (!this.mobileHudActive) this.drawHud(hudState);
|
||||
this.publishHudState(hudState, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processPlayerHits();
|
||||
this.ensureLivePlayerState();
|
||||
this.validateLivePlayerState();
|
||||
if (this.gameState !== 'playing') {
|
||||
this.drawHud();
|
||||
this.publishHudState();
|
||||
const hudState = this.buildHudState();
|
||||
if (!this.mobileHudActive) this.drawHud(hudState);
|
||||
this.publishHudState(hudState, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -398,9 +416,11 @@ class GameScene extends Phaser.Scene {
|
||||
this.updatePirates(dt);
|
||||
this.updateProjectiles();
|
||||
this.drawWeaponArcs();
|
||||
this.drawHud();
|
||||
this.checkGameState();
|
||||
this.publishHudState();
|
||||
|
||||
const hudState = this.buildHudState();
|
||||
if (!this.mobileHudActive) this.drawHud(hudState);
|
||||
this.publishHudState(hudState, hadQueuedPlayerHit);
|
||||
|
||||
if (hadQueuedPlayerHit) {
|
||||
airshipLog('combat:post-hit-frame-complete', {
|
||||
@ -463,12 +483,13 @@ class GameScene extends Phaser.Scene {
|
||||
const turnRate = pirate.getData('turnRate') || 52;
|
||||
const baseSpeed = pirate.getData('speed') || 118;
|
||||
|
||||
let desiredHeading = bearing;
|
||||
if (distance < preferredRange + 40) {
|
||||
const optionA = normalizeDegrees(bearing + 90);
|
||||
const optionB = normalizeDegrees(bearing - 90);
|
||||
desiredHeading = Math.abs(signedAngleDelta(optionA, pirate.angle)) < Math.abs(signedAngleDelta(optionB, pirate.angle)) ? optionA : optionB;
|
||||
}
|
||||
let desiredHeading = pirateDesiredHeading({
|
||||
bearing,
|
||||
currentHeading: pirate.angle,
|
||||
distance,
|
||||
preferredRange,
|
||||
retreatRange,
|
||||
});
|
||||
|
||||
const separation = separationVector(pirate, pirates);
|
||||
if (separation.x || separation.y) {
|
||||
@ -481,11 +502,12 @@ class GameScene extends Phaser.Scene {
|
||||
const delta = signedAngleDelta(desiredHeading, pirate.angle);
|
||||
pirate.angle += clamp(delta, -turnRate * dt, turnRate * dt);
|
||||
|
||||
const speed = distance > preferredRange + 100
|
||||
? baseSpeed
|
||||
: distance < retreatRange
|
||||
? baseSpeed * 0.58
|
||||
: baseSpeed * 0.82;
|
||||
const speed = pirateSpeedForRange({
|
||||
distance,
|
||||
preferredRange,
|
||||
retreatRange,
|
||||
baseSpeed,
|
||||
});
|
||||
const h = headingRad(pirate);
|
||||
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 broadsideTolerance = pirate.getData('broadsideError') || 38;
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -507,12 +529,12 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
playerBroadside(side) {
|
||||
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.lastPlayerShot[broadside] = this.time.now;
|
||||
}
|
||||
|
||||
fireBroadside(ship, side, group, texture, damage, velocityScale) {
|
||||
fireBroadside(ship, side, group, texture, projectileDamage, velocityScale) {
|
||||
if (!ship?.body) return;
|
||||
const actor = group === this.pirateShots ? 'pirate' : 'player';
|
||||
const base = headingRad(ship) + side * Math.PI / 2;
|
||||
@ -520,7 +542,7 @@ class GameScene extends Phaser.Scene {
|
||||
airshipLog('combat:broadside-fired', {
|
||||
actor,
|
||||
side: side < 0 ? 'port' : 'starboard',
|
||||
damage,
|
||||
damage: projectileDamage,
|
||||
x: Math.round(ship.x),
|
||||
y: Math.round(ship.y),
|
||||
angle: Math.round(ship.angle),
|
||||
@ -535,7 +557,7 @@ class GameScene extends Phaser.Scene {
|
||||
shot.setDepth(8);
|
||||
shot.body.setAllowGravity(false);
|
||||
shot.setData('bornAt', this.time.now);
|
||||
shot.setData('damage', damage);
|
||||
shot.setData('damage', projectileDamage);
|
||||
shot.setData('projectileOwner', actor);
|
||||
if (actor === 'pirate') shot.setData('playerHitQueued', false);
|
||||
shot.setVelocity(
|
||||
@ -602,7 +624,7 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
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.
|
||||
// Hull mutation and projectile destruction happen on the next scene update,
|
||||
@ -643,7 +665,7 @@ class GameScene extends Phaser.Scene {
|
||||
hull: this.playerHull,
|
||||
hits,
|
||||
maxHull: PLAYER_MAX_HULL,
|
||||
fallbackDamage: PIRATE_SHOT_DAMAGE,
|
||||
fallbackDamage: DEFAULT_PIRATE_SHOT_DAMAGE,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
ensureLivePlayerState() {
|
||||
validateLivePlayerState() {
|
||||
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
|
||||
|
||||
if (!this.player.active) this.player.setActive(true);
|
||||
if (!this.player.visible) this.player.setVisible(true);
|
||||
if (this.player.alpha !== 1) this.player.setAlpha(1);
|
||||
const issues = [];
|
||||
if (!this.player.active) issues.push('inactive');
|
||||
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) {
|
||||
this.player.body.enable = true;
|
||||
if (issues.length > 0) {
|
||||
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 now = this.time?.now ?? 0;
|
||||
const reloadReady = (side) => clamp(
|
||||
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / this.playerBroadsideReloadMs,
|
||||
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / Math.max(1, this.playerBroadsideReloadMs),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
@ -780,9 +812,13 @@ class GameScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
publishHudState() {
|
||||
const state = this.buildHudState();
|
||||
if (!state) return;
|
||||
publishHudState(state = this.buildHudState(), force = false) {
|
||||
if (!this.mobileHudActive || !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 }));
|
||||
}
|
||||
|
||||
@ -825,28 +861,30 @@ class GameScene extends Phaser.Scene {
|
||||
return { pirate: nearest, range: nearestRange };
|
||||
}
|
||||
|
||||
drawHud() {
|
||||
drawHud(state = this.buildHudState()) {
|
||||
if (!state) return;
|
||||
|
||||
const width = this.scale.width;
|
||||
const compact = width < 700;
|
||||
const heading = normalizeDegrees(this.player.angle);
|
||||
const activePirates = this.pirates?.countActive(true) || 0;
|
||||
const { pirate: nearest, range } = this.nearestPirate();
|
||||
const nearestHull = nearest ? (nearest.getData('hull') || 0) : 0;
|
||||
const nearestMaxHull = nearest ? (nearest.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL) : PIRATE_FALLBACK_MAX_HULL;
|
||||
const nearestClass = nearest ? (nearest.getData('classLabel') || 'PIRATE') : 'PIRATE';
|
||||
const rangeText = Number.isFinite(range) ? `${range.toFixed(0)}m` : '--';
|
||||
const target = state.target;
|
||||
const playerHull = state.player?.hull ?? PLAYER_MAX_HULL;
|
||||
const playerMaxHull = state.player?.maxHull ?? PLAYER_MAX_HULL;
|
||||
const nearestHull = target?.hull ?? 0;
|
||||
const nearestMaxHull = target?.maxHull ?? PIRATE_FALLBACK_MAX_HULL;
|
||||
const nearestClass = target?.name || 'PIRATE';
|
||||
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.headingText.setText(`${heading.toFixed(0).padStart(3, '0')}°`);
|
||||
this.waveText.setText(`WAVE ${this.wave} • ${activePirates} HOSTILE${activePirates === 1 ? '' : 'S'}`);
|
||||
this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`);
|
||||
this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`);
|
||||
this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`);
|
||||
this.combatText.setText(compact
|
||||
? `K ${this.defeated}\n${rangeText}`
|
||||
: `DEFEATED ${this.defeated}\nRANGE ${rangeText}`);
|
||||
? `K ${state.kills}\n${rangeText}`
|
||||
: `DEFEATED ${state.kills}\nRANGE ${rangeText}`);
|
||||
|
||||
this.playerHullLabel.setText(`SPARROW ${this.playerHull}/${PLAYER_MAX_HULL}`);
|
||||
this.enemyHullLabel.setText(nearest
|
||||
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
|
||||
this.enemyHullLabel.setText(target
|
||||
? `${nearestClass} ${nearestHull}/${nearestMaxHull}`
|
||||
: `WAVE ${this.wave} CLEAR`);
|
||||
: `WAVE ${state.wave} CLEAR`);
|
||||
|
||||
const side = 14;
|
||||
const gap = 14;
|
||||
@ -866,9 +904,9 @@ class GameScene extends Phaser.Scene {
|
||||
this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth, barHeight, 4);
|
||||
|
||||
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.fillRoundedRect(enemyX, barY, barWidth * (nearestHull / nearestMaxHull), barHeight, 4);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ export const PIRATE_CLASSES = {
|
||||
speed: 148,
|
||||
turnRate: 70,
|
||||
reloadMs: 1280,
|
||||
damage: 4,
|
||||
projectileDamage: 4,
|
||||
preferredRange: 300,
|
||||
retreatRange: 175,
|
||||
broadsideRange: 380,
|
||||
@ -20,7 +20,7 @@ export const PIRATE_CLASSES = {
|
||||
speed: 118,
|
||||
turnRate: 52,
|
||||
reloadMs: 1650,
|
||||
damage: 6,
|
||||
projectileDamage: 6,
|
||||
preferredRange: 335,
|
||||
retreatRange: 210,
|
||||
broadsideRange: 420,
|
||||
@ -34,7 +34,7 @@ export const PIRATE_CLASSES = {
|
||||
speed: 102,
|
||||
turnRate: 44,
|
||||
reloadMs: 1450,
|
||||
damage: 8,
|
||||
projectileDamage: 8,
|
||||
preferredRange: 365,
|
||||
retreatRange: 225,
|
||||
broadsideRange: 470,
|
||||
@ -43,10 +43,93 @@ export const PIRATE_CLASSES = {
|
||||
},
|
||||
};
|
||||
|
||||
export const pirateClassForWave = (wave, index, count) => {
|
||||
if (wave >= 4 && wave % 2 === 0 && index === count - 1) return PIRATE_CLASSES.captain;
|
||||
if (wave >= 2 && (wave + index) % 3 === 0) return PIRATE_CLASSES.raider;
|
||||
return PIRATE_CLASSES.frigate;
|
||||
export const PIRATE_CLASS_COSTS = {
|
||||
raider: 1,
|
||||
frigate: 2,
|
||||
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) => {
|
||||
|
||||
@ -3,104 +3,24 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
DEFAULT_PIRATE_SHOT_DAMAGE,
|
||||
PLAYER_HIT_COOLDOWN_MS,
|
||||
PLAYER_MAX_HULL,
|
||||
normalizePlayerDamage,
|
||||
processQueuedPlayerHits,
|
||||
reducePlayerHitQueue,
|
||||
reducePlayerProjectileHits,
|
||||
reducePlayerVolleyHits,
|
||||
resolveTaggedProjectile,
|
||||
} from '../src/game-logic.js';
|
||||
|
||||
test('first pirate hit removes exactly the default damage and does not end the game', () => {
|
||||
const result = reducePlayerHitQueue({
|
||||
test('first pirate projectile removes exactly the default damage', () => {
|
||||
const result = reducePlayerProjectileHits({
|
||||
hull: PLAYER_MAX_HULL,
|
||||
lastHitAt: -Infinity,
|
||||
now: 1000,
|
||||
damages: [DEFAULT_PIRATE_SHOT_DAMAGE],
|
||||
hits: [{ damage: DEFAULT_PIRATE_SHOT_DAMAGE }],
|
||||
});
|
||||
|
||||
assert.equal(result.hull, 94);
|
||||
assert.equal(result.appliedHits, 1);
|
||||
assert.equal(result.ignoredHits, 0);
|
||||
assert.equal(result.totalDamage, 6);
|
||||
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', () => {
|
||||
assert.equal(normalizePlayerDamage(undefined), 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);
|
||||
});
|
||||
|
||||
test('invalid hull state recovers from max hull before applying damage', () => {
|
||||
const result = reducePlayerHitQueue({
|
||||
test('invalid hull state recovers from max hull before applying projectile damage', () => {
|
||||
const result = reducePlayerProjectileHits({
|
||||
hull: Number.NaN,
|
||||
lastHitAt: -Infinity,
|
||||
now: 3000,
|
||||
damages: [6],
|
||||
hits: [{ damage: 6 }],
|
||||
});
|
||||
|
||||
assert.equal(result.hull, 94);
|
||||
assert.equal(result.gameOver, false);
|
||||
});
|
||||
|
||||
test('lethal hit clamps hull at zero and reports game over', () => {
|
||||
const result = reducePlayerHitQueue({
|
||||
test('lethal projectile damage clamps hull at zero and reports game over', () => {
|
||||
const result = reducePlayerProjectileHits({
|
||||
hull: 5,
|
||||
lastHitAt: -Infinity,
|
||||
now: 4000,
|
||||
damages: [6],
|
||||
hits: [{ damage: 6 }],
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('empty hit queue is a no-op', () => {
|
||||
const result = reducePlayerHitQueue({
|
||||
test('empty projectile queue is a no-op', () => {
|
||||
const result = reducePlayerProjectileHits({
|
||||
hull: 72,
|
||||
lastHitAt: 1234,
|
||||
now: 5000,
|
||||
damages: [],
|
||||
hits: [],
|
||||
});
|
||||
|
||||
assert.equal(result.hull, 72);
|
||||
assert.equal(result.lastHitAt, 1234);
|
||||
assert.equal(result.totalDamage, 0);
|
||||
assert.equal(result.appliedHits, 0);
|
||||
assert.equal(result.ignoredHits, 0);
|
||||
assert.equal(result.gameOver, false);
|
||||
});
|
||||
|
||||
test('one pirate broadside damages once, and the next broadside damages again', () => {
|
||||
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', () => {
|
||||
test('every distinct pirate projectile can damage once with no cross-shot suppression', () => {
|
||||
const first = reducePlayerProjectileHits({
|
||||
hull: 100,
|
||||
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({
|
||||
hull: first.hull,
|
||||
hits: [{ damage: 6 }],
|
||||
});
|
||||
|
||||
assert.equal(first.hull, 94);
|
||||
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', () => {
|
||||
@ -242,7 +91,6 @@ test('multiple distinct pirate projectiles in the same frame all apply damage',
|
||||
assert.equal(result.appliedHits, 3);
|
||||
});
|
||||
|
||||
|
||||
test('projectile resolver is independent of Phaser overlap argument order', () => {
|
||||
const player = {
|
||||
getData() { return undefined; },
|
||||
|
||||
@ -1,19 +1,93 @@
|
||||
import test from 'node:test';
|
||||
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.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', () => {
|
||||
assert.equal(pirateClassForWave(1, 0, 1).key, 'frigate');
|
||||
assert.equal(pirateClassForWave(3, 0, 3).key, 'raider');
|
||||
assert.equal(pirateClassForWave(4, 3, 4).key, 'captain');
|
||||
test('wave roster uses a monotonic difficulty budget with a six-ship cap', () => {
|
||||
assert.deepEqual(
|
||||
pirateRosterForWave(1).map(({ key }) => key),
|
||||
['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', () => {
|
||||
|
||||
@ -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 () => {
|
||||
const source = await read('src/game-v4.js');
|
||||
test('player damage is per projectile with obsolete cooldown and volley models removed', async () => {
|
||||
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, /reducePlayerProjectileHits\(\{/);
|
||||
assert.doesNotMatch(source, /PLAYER_HIT_COOLDOWN_MS/);
|
||||
assert.doesNotMatch(source, /damagedPlayerVolleys/);
|
||||
assert.doesNotMatch(source, /nextPirateVolleyId/);
|
||||
assert.doesNotMatch(source, /volleyId/);
|
||||
assert.doesNotMatch(logic, /PLAYER_HIT_COOLDOWN_MS/);
|
||||
assert.doesNotMatch(logic, /reducePlayerHitQueue/);
|
||||
assert.doesNotMatch(logic, /reducePlayerVolleyHits/);
|
||||
assert.doesNotMatch(logic, /processQueuedPlayerHits/);
|
||||
});
|
||||
|
||||
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\]/);
|
||||
});
|
||||
|
||||
|
||||
test('pirate combat uses class stats and separation steering', async () => {
|
||||
test('player firing and reload HUD share one reload duration source', async () => {
|
||||
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, /pirate\.getData\('reloadMs'\)/);
|
||||
assert.match(source, /pirate\.getData\('projectileDamage'\)/);
|
||||
assert.match(source, /pirate\.getData\('maxHull'\)/);
|
||||
assert.match(source, /nearestHull \/ nearestMaxHull/);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
assert.match(source, /this\.processPlayerHits\(\);\s*this\.ensureLivePlayerState\(\)/);
|
||||
assert.match(source, /if \(this\.gameState !== 'playing' \|\| this\.playerHull <= 0 \|\| !this\.player\) return/);
|
||||
assert.match(source, /if \(!this\.player\.active\) this\.player\.setActive\(true\)/);
|
||||
assert.match(source, /if \(!this\.player\.visible\) this\.player\.setVisible\(true\)/);
|
||||
assert.match(source, /if \(this\.player\.alpha !== 1\) this\.player\.setAlpha\(1\)/);
|
||||
assert.match(source, /if \(this\.player\.body && !this\.player\.body\.enable\)/);
|
||||
assert.match(source, /this\.processPlayerHits\(\);\s*this\.validateLivePlayerState\(\)/);
|
||||
const match = /validateLivePlayerState\(\) \{([\s\S]*?)\n buildHudState\(/.exec(source);
|
||||
assert.ok(match, 'validateLivePlayerState block should be found');
|
||||
|
||||
const block = match[1];
|
||||
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*=/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user