Map HUD to game state and animate player hits

This commit is contained in:
Admin 2026-09-18 06:25:21 +00:00
parent 6b7cf5cdaf
commit e0508c0d43
3 changed files with 183 additions and 119 deletions

View File

@ -49,6 +49,7 @@ class GameScene extends Phaser.Scene {
this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS; this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS;
this.helmVector = { x: 0, y: 0 }; this.helmVector = { x: 0, y: 0 };
this.pendingPlayerHits = []; this.pendingPlayerHits = [];
this.playerHitSequence = 0;
this.lastDiagnosticsAt = -Infinity; this.lastDiagnosticsAt = -Infinity;
this.wave = 1; this.wave = 1;
this.defeated = 0; this.defeated = 0;
@ -78,6 +79,8 @@ class GameScene extends Phaser.Scene {
this.player = this.physics.add.sprite(1200, 1550, 'airships', 0); this.player = this.physics.add.sprite(1200, 1550, 'airships', 0);
this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(74, 74); this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(74, 74);
this.playerBaseScaleX = this.player.scaleX;
this.playerBaseScaleY = this.player.scaleY;
this.player.body.setAllowGravity(false); this.player.body.setAllowGravity(false);
this.player.body.setSize(42, 56, true); this.player.body.setSize(42, 56, true);
this.player.play('player-idle'); this.player.play('player-idle');
@ -367,12 +370,14 @@ 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(); this.drawHud();
this.publishHudState();
return; return;
} }
this.processPlayerHits(); this.processPlayerHits();
if (this.gameState !== 'playing') { if (this.gameState !== 'playing') {
this.drawHud(); this.drawHud();
this.publishHudState();
return; return;
} }
@ -396,6 +401,7 @@ class GameScene extends Phaser.Scene {
this.drawWeaponArcs(); this.drawWeaponArcs();
this.drawHud(); this.drawHud();
this.checkGameState(); this.checkGameState();
this.publishHudState();
if (hadQueuedPlayerHit) { if (hadQueuedPlayerHit) {
airshipLog('combat:post-hit-frame-complete', { airshipLog('combat:post-hit-frame-complete', {
@ -630,7 +636,8 @@ class GameScene extends Phaser.Scene {
for (const impact of hits) { for (const impact of hits) {
this.spawnExplosion(impact.x, impact.y, 0.32); this.spawnExplosion(impact.x, impact.y, 0.32);
} }
this.flashShip(this.player, 0xffcccc); this.playerHitSequence += 1;
this.playPlayerHitReaction(totalDamage);
airshipLog('combat:player-hit-fx', { airshipLog('combat:player-hit-fx', {
impacts: hits.length, impacts: hits.length,
scale: 0.32, scale: 0.32,
@ -678,6 +685,81 @@ class GameScene extends Phaser.Scene {
}); });
} }
playPlayerHitReaction(damage = PIRATE_SHOT_DAMAGE) {
if (!this.player?.active) return;
this.tweens.killTweensOf(this.player);
this.player.setScale(this.playerBaseScaleX, this.playerBaseScaleY);
this.player.setTint(0xffd7d7);
this.tweens.add({
targets: this.player,
scaleX: this.playerBaseScaleX * 1.12,
scaleY: this.playerBaseScaleY * 1.12,
alpha: 0.62,
duration: 70,
yoyo: true,
ease: 'Quad.easeOut',
onComplete: () => {
if (!this.player?.active) return;
this.player.setScale(this.playerBaseScaleX, this.playerBaseScaleY);
this.player.setAlpha(1);
this.player.clearTint();
},
});
this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012));
}
buildHudState() {
const player = this.player;
if (!player) return null;
const activePirates = (this.pirates?.getChildren?.() || []).filter((pirate) => pirate?.active);
const { pirate: nearest, range } = this.nearestPirate();
const now = this.time?.now ?? 0;
const reloadReady = (side) => clamp(
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / this.playerBroadsideReloadMs,
0,
1,
);
return {
gameState: this.gameState,
speed: this.playerSpeed,
heading: normalizeDegrees(player.angle),
wave: this.wave,
hostiles: activePirates.length,
kills: this.defeated,
player: {
hull: this.playerHull,
maxHull: PLAYER_MAX_HULL,
hitSequence: this.playerHitSequence,
},
target: nearest ? {
hull: nearest.getData('hull') || 0,
maxHull: nearest.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL,
name: nearest.getData('classLabel') || 'PIRATE',
range,
} : null,
reload: {
port: reloadReady('port'),
starboard: reloadReady('starboard'),
},
radar: activePirates.slice(0, 10).map((pirate) => ({
dx: (pirate.x - player.x) / 900,
dy: (pirate.y - player.y) / 900,
})),
status: this.status?.text || '',
};
}
publishHudState() {
const state = this.buildHudState();
if (!state) return;
window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state }));
}
updateProjectiles() { updateProjectiles() {
for (const group of [this.playerShots, this.pirateShots]) { for (const group of [this.playerShots, this.pirateShots]) {
for (const shot of group.getChildren()) { for (const shot of group.getChildren()) {

View File

@ -1,4 +1,3 @@
import Phaser from 'phaser';
import topHudSrc from './assets/controller/top-hud.js'; import topHudSrc from './assets/controller/top-hud.js';
import playerHealthSrc from './assets/controller/player-health.js'; import playerHealthSrc from './assets/controller/player-health.js';
import enemyHealthSrc from './assets/controller/enemy-health.js'; import enemyHealthSrc from './assets/controller/enemy-health.js';
@ -88,7 +87,18 @@ if (!isDesktopPointer.matches) {
.steam-player-card { .steam-player-card {
left: -1%; left: -1%;
width: 53%; width: 53%;
background-size: 100% 100%; background-size: 400% 300%;
}
.steam-player-card.steam-hit {
animation: steam-player-hit 220ms ease-out;
}
@keyframes steam-player-hit {
0% { filter: brightness(1); transform: translateX(0); }
25% { filter: brightness(1.9); transform: translateX(-4px); }
55% { filter: brightness(1.35); transform: translateX(4px); }
100% { filter: brightness(1); transform: translateX(0); }
} }
.steam-enemy-card { .steam-enemy-card {
@ -97,33 +107,6 @@ if (!isDesktopPointer.matches) {
background-size: 400% 300%; background-size: 400% 300%;
} }
.steam-player-health-tint,
.steam-player-health-cover {
position: absolute;
pointer-events: none;
border-radius: 999px;
}
.steam-player-health-tint {
left: 39.5%;
top: 56.5%;
width: 49%;
height: 19%;
transform-origin: left center;
background: transparent;
mix-blend-mode: color;
z-index: 1;
}
.steam-player-health-cover {
right: 11.5%;
top: 56.5%;
width: 0;
height: 19%;
background: #111416;
z-index: 2;
}
.steam-card-name, .steam-card-name,
.steam-card-value { .steam-card-value {
position: absolute; position: absolute;
@ -582,8 +565,6 @@ if (!isDesktopPointer.matches) {
}; };
const playerHealthPiece = addHealthPiece('steam-player-card', playerHealthSrc, ` const playerHealthPiece = addHealthPiece('steam-player-card', playerHealthSrc, `
<span class="steam-player-health-tint" data-steam="player-health-tint"></span>
<span class="steam-player-health-cover" data-steam="player-health-cover"></span>
<span class="steam-card-name">SPARROW</span> <span class="steam-card-name">SPARROW</span>
<span class="steam-card-value" data-steam="player-hull">100/100</span> <span class="steam-card-value" data-steam="player-hull">100/100</span>
`); `);
@ -663,31 +644,6 @@ if (!isDesktopPointer.matches) {
return dot; return dot;
}); });
const oldHudKeys = [
'hudGraphics',
'brandText',
'speedText',
'headingText',
'waveText',
'combatText',
'playerHullLabel',
'enemyHullLabel',
'controlsHint',
'status',
];
const getScene = () => {
for (const game of Phaser.GAMES || []) {
const scene = game?.scene?.getScene?.('game');
if (scene?.sys?.isActive?.()) return scene;
}
return null;
};
const hideLegacyHud = (scene) => {
for (const key of oldHudKeys) scene[key]?.setVisible?.(false);
};
const clamp01 = (value) => Math.max(0, Math.min(1, value)); const clamp01 = (value) => Math.max(0, Math.min(1, value));
const healthFrame = (current, max) => { const healthFrame = (current, max) => {
@ -701,18 +657,16 @@ if (!isDesktopPointer.matches) {
piece.style.backgroundPosition = `${(col / 3) * 100}% ${(row / 2) * 100}%`; piece.style.backgroundPosition = `${(col / 3) * 100}% ${(row / 2) * 100}%`;
}; };
const updateRadar = (scene) => { const updateRadar = (contacts = []) => {
const pirates = scene.pirates?.getChildren?.().filter((pirate) => pirate?.active) || [];
radarDots.forEach((dot, index) => { radarDots.forEach((dot, index) => {
const pirate = pirates[index]; const contact = contacts[index];
if (!pirate) { if (!contact) {
dot.hidden = true; dot.hidden = true;
return; return;
} }
let dx = (pirate.x - scene.player.x) / 900; let dx = Number(contact.dx) || 0;
let dy = (pirate.y - scene.player.y) / 900; let dy = Number(contact.dy) || 0;
const distance = Math.hypot(dx, dy); const distance = Math.hypot(dx, dy);
if (distance > 1) { if (distance > 1) {
dx /= distance; dx /= distance;
@ -725,63 +679,58 @@ if (!isDesktopPointer.matches) {
}); });
}; };
const update = () => { let lastPlayerHitSequence = 0;
const scene = getScene();
if (scene?.player) { const pulsePlayerHit = (sequence) => {
hideLegacyHud(scene); if (!Number.isFinite(sequence) || sequence <= lastPlayerHitSequence) return;
lastPlayerHitSequence = sequence;
playerHealthPiece.classList.remove('steam-hit');
void playerHealthPiece.offsetWidth;
playerHealthPiece.classList.add('steam-hit');
};
const heading = ((scene.player.angle % 360) + 360) % 360; const renderHudState = (state) => {
const activePirates = scene.pirates?.countActive?.(true) || 0; if (!state?.player) return;
const nearest = scene.nearestPirate?.() || { pirate: null, range: Infinity };
const pirate = nearest.pirate;
const range = nearest.range;
const playerHull = Math.max(0, scene.playerHull ?? 100);
const enemyHull = pirate ? Math.max(0, pirate.getData?.('hull') ?? 0) : 0;
const enemyMaxHull = pirate ? Math.max(1, pirate.getData?.('maxHull') ?? 120) : 120;
const enemyName = pirate ? (pirate.getData?.('classLabel') || 'PIRATE') : 'PIRATE';
fields.speed.textContent = `${Math.abs(scene.playerSpeed || 0).toFixed(0)}${scene.playerSpeed < 0 ? 'R' : ''}`; const playerHull = Math.max(0, state.player.hull ?? 100);
fields.heading.textContent = `${heading.toFixed(0).padStart(3, '0')}°`; const playerMaxHull = Math.max(1, state.player.maxHull ?? 100);
fields.wave.textContent = String(scene.wave || 1); const target = state.target || null;
fields.hostiles.textContent = String(activePirates);
fields.kills.textContent = String(scene.defeated || 0);
fields.range.textContent = Number.isFinite(range) ? `${Math.round(range)}m` : '--';
fields['player-hull'].textContent = `${playerHull}/100`;
fields['enemy-name'].textContent = enemyName;
fields['enemy-hull'].textContent = pirate ? `${enemyHull}/${enemyMaxHull}` : 'CLEAR';
const playerPct = clamp01(playerHull / 100);
fields['player-health-cover'].style.width = `${(1 - playerPct) * 49}%`;
fields['player-health-tint'].style.transform = `scaleX(${playerPct})`;
fields['player-health-tint'].style.background = playerPct > 0.66
? 'transparent'
: playerPct > 0.33
? 'rgba(255, 210, 0, .58)'
: 'rgba(255, 45, 18, .72)';
setHealthFrame(enemyHealthPiece, pirate ? healthFrame(enemyHull, enemyMaxHull) : 11);
const reloadMs = Math.max(1, scene.playerBroadsideReloadMs || 850); fields.speed.textContent = `${Math.abs(state.speed || 0).toFixed(0)}${state.speed < 0 ? 'R' : ''}`;
const now = scene.time?.now ?? 0; fields.heading.textContent = `${(state.heading || 0).toFixed(0).padStart(3, '0')}°`;
const portReady = clamp01((now - (scene.lastPlayerShot?.port ?? -Infinity)) / reloadMs); fields.wave.textContent = String(state.wave || 1);
const starboardReady = clamp01((now - (scene.lastPlayerShot?.starboard ?? -Infinity)) / reloadMs); fields.hostiles.textContent = String(state.hostiles || 0);
fields['port-reload'].style.transform = `scaleX(${portReady})`; fields.kills.textContent = String(state.kills || 0);
fields['starboard-reload'].style.transform = `scaleX(${starboardReady})`; fields.range.textContent = target && Number.isFinite(target.range) ? `${Math.round(target.range)}m` : '--';
fields['player-hull'].textContent = `${playerHull}/${playerMaxHull}`;
fields['enemy-name'].textContent = target?.name || 'PIRATE';
fields['enemy-hull'].textContent = target ? `${Math.max(0, target.hull || 0)}/${Math.max(1, target.maxHull || 120)}` : 'CLEAR';
const statusText = scene.status?.text?.trim?.() || ''; setHealthFrame(playerHealthPiece, healthFrame(playerHull, playerMaxHull));
setHealthFrame(
enemyHealthPiece,
target ? healthFrame(target.hull || 0, target.maxHull || 120) : 11,
);
fields['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`;
fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 1)})`;
const statusText = String(state.status || '').trim();
fields.status.textContent = statusText; fields.status.textContent = statusText;
fields.status.classList.toggle('visible', Boolean(statusText)); fields.status.classList.toggle('visible', Boolean(statusText));
updateRadar(scene); pulsePlayerHit(state.player.hitSequence ?? 0);
} updateRadar(state.radar || []);
requestAnimationFrame(update);
}; };
window.addEventListener('airship:hud-state', (event) => {
renderHudState(event.detail);
});
airshipLog('hud:mounted', { airshipLog('hud:mounted', {
mobile: true, mobile: true,
pieces: hud.querySelectorAll('.steam-piece').length, pieces: hud.querySelectorAll('.steam-piece').length,
}); });
requestAnimationFrame(update);
} }
} }

View File

@ -98,7 +98,9 @@ test('player hit FX runs after queued hit processing, outside the overlap callba
assert.match(source, /this\.pendingPlayerHits\.push\(\{[\s\S]*?x: shot\.x,[\s\S]*?y: shot\.y/); assert.match(source, /this\.pendingPlayerHits\.push\(\{[\s\S]*?x: shot\.x,[\s\S]*?y: shot\.y/);
assert.match(source, /if \(appliedHits > 0\) \{[\s\S]*?for \(const impact of hits\) \{[\s\S]*?this\.spawnExplosion\(impact\.x, impact\.y, 0\.32\)/); assert.match(source, /if \(appliedHits > 0\) \{[\s\S]*?for \(const impact of hits\) \{[\s\S]*?this\.spawnExplosion\(impact\.x, impact\.y, 0\.32\)/);
assert.match(source, /this\.flashShip\(this\.player, 0xffcccc\)/); assert.match(source, /this\.playPlayerHitReaction\(totalDamage\)/);
assert.match(source, /this\.cameras\.main\.shake\(120/);
assert.match(source, /this\.tweens\.add\(\{/);
assert.match(source, /combat:player-hit-fx-error/); assert.match(source, /combat:player-hit-fx-error/);
}); });
@ -134,10 +136,11 @@ test('mobile HUD uses uploaded player and enemy health sprite sheets', async ()
assert.match(source, /addHealthPiece\('steam-enemy-card', enemyHealthSrc/); assert.match(source, /addHealthPiece\('steam-enemy-card', enemyHealthSrc/);
assert.match(source, /const healthFrame = \(current, max\) =>/); assert.match(source, /const healthFrame = \(current, max\) =>/);
assert.match(source, /const setHealthFrame = \(piece, frame\) =>/); assert.match(source, /const setHealthFrame = \(piece, frame\) =>/);
assert.match(source, /player-health-cover/); assert.match(source, /\.steam-player-card \{[\s\S]*?background-size: 400% 300%/);
assert.match(source, /player-health-tint/); assert.match(source, /setHealthFrame\(playerHealthPiece, healthFrame\(playerHull, playerMaxHull\)\)/);
assert.match(source, /const enemyMaxHull = pirate \? Math\.max\(1, pirate\.getData\?\.\('maxHull'\) \?\? 120\) : 120/); assert.match(source, /setHealthFrame\([\s\S]*?enemyHealthPiece,[\s\S]*?target \? healthFrame\(target\.hull \|\| 0, target\.maxHull \|\| 120\) : 11/);
assert.match(source, /setHealthFrame\(enemyHealthPiece, pirate \? healthFrame\(enemyHull, enemyMaxHull\) : 11\)/); assert.doesNotMatch(source, /player-health-cover/);
assert.doesNotMatch(source, /player-health-tint/);
assert.doesNotMatch(source, /steam-bar-fill/); assert.doesNotMatch(source, /steam-bar-fill/);
}); });
@ -181,3 +184,33 @@ test('mobile cannon HUD shows independent reload progress', async () => {
assert.match(source, /scene\.lastPlayerShot\?\.port/); assert.match(source, /scene\.lastPlayerShot\?\.port/);
assert.match(source, /scene\.lastPlayerShot\?\.starboard/); assert.match(source, /scene\.lastPlayerShot\?\.starboard/);
}); });
test('mobile HUD is driven by authoritative game state events', async () => {
const [game, hud] = await Promise.all([
read('src/game-v4.js'),
read('src/mobile-hud.js'),
]);
assert.match(game, /CustomEvent\('airship:hud-state', \{ detail: state \}\)/);
assert.match(game, /hostiles: activePirates\.length/);
assert.match(game, /hitSequence: this\.playerHitSequence/);
assert.match(hud, /addEventListener\('airship:hud-state'/);
assert.match(hud, /renderHudState\(event\.detail\)/);
assert.doesNotMatch(hud, /Phaser\.GAMES/);
assert.doesNotMatch(hud, /getScene\(\)/);
});
test('processed player hits drive sprite and HUD hit reactions', async () => {
const [game, hud] = await Promise.all([
read('src/game-v4.js'),
read('src/mobile-hud.js'),
]);
assert.match(game, /this\.playerHitSequence \+= 1/);
assert.match(game, /playPlayerHitReaction\(damage = PIRATE_SHOT_DAMAGE\)/);
assert.match(game, /scaleX: this\.playerBaseScaleX \* 1\.12/);
assert.match(game, /this\.cameras\.main\.shake\(120/);
assert.match(hud, /steam-player-card\.steam-hit/);
assert.match(hud, /pulsePlayerHit\(state\.player\.hitSequence \?\? 0\)/);
});