diff --git a/src/game-v4.js b/src/game-v4.js
index 31ad32d..c3732ce 100644
--- a/src/game-v4.js
+++ b/src/game-v4.js
@@ -49,6 +49,7 @@ class GameScene extends Phaser.Scene {
this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS;
this.helmVector = { x: 0, y: 0 };
this.pendingPlayerHits = [];
+ this.playerHitSequence = 0;
this.lastDiagnosticsAt = -Infinity;
this.wave = 1;
this.defeated = 0;
@@ -78,6 +79,8 @@ class GameScene extends Phaser.Scene {
this.player = this.physics.add.sprite(1200, 1550, 'airships', 0);
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.setSize(42, 56, true);
this.player.play('player-idle');
@@ -367,12 +370,14 @@ class GameScene extends Phaser.Scene {
if (this.gameState !== 'playing') {
if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart();
this.drawHud();
+ this.publishHudState();
return;
}
this.processPlayerHits();
if (this.gameState !== 'playing') {
this.drawHud();
+ this.publishHudState();
return;
}
@@ -396,6 +401,7 @@ class GameScene extends Phaser.Scene {
this.drawWeaponArcs();
this.drawHud();
this.checkGameState();
+ this.publishHudState();
if (hadQueuedPlayerHit) {
airshipLog('combat:post-hit-frame-complete', {
@@ -630,7 +636,8 @@ class GameScene extends Phaser.Scene {
for (const impact of hits) {
this.spawnExplosion(impact.x, impact.y, 0.32);
}
- this.flashShip(this.player, 0xffcccc);
+ this.playerHitSequence += 1;
+ this.playPlayerHitReaction(totalDamage);
airshipLog('combat:player-hit-fx', {
impacts: hits.length,
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() {
for (const group of [this.playerShots, this.pirateShots]) {
for (const shot of group.getChildren()) {
diff --git a/src/mobile-hud.js b/src/mobile-hud.js
index 777da56..f7804e2 100644
--- a/src/mobile-hud.js
+++ b/src/mobile-hud.js
@@ -1,4 +1,3 @@
-import Phaser from 'phaser';
import topHudSrc from './assets/controller/top-hud.js';
import playerHealthSrc from './assets/controller/player-health.js';
import enemyHealthSrc from './assets/controller/enemy-health.js';
@@ -88,7 +87,18 @@ if (!isDesktopPointer.matches) {
.steam-player-card {
left: -1%;
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 {
@@ -97,33 +107,6 @@ if (!isDesktopPointer.matches) {
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-value {
position: absolute;
@@ -582,8 +565,6 @@ if (!isDesktopPointer.matches) {
};
const playerHealthPiece = addHealthPiece('steam-player-card', playerHealthSrc, `
-
-
SPARROW
100/100
`);
@@ -663,31 +644,6 @@ if (!isDesktopPointer.matches) {
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 healthFrame = (current, max) => {
@@ -701,18 +657,16 @@ if (!isDesktopPointer.matches) {
piece.style.backgroundPosition = `${(col / 3) * 100}% ${(row / 2) * 100}%`;
};
- const updateRadar = (scene) => {
- const pirates = scene.pirates?.getChildren?.().filter((pirate) => pirate?.active) || [];
-
+ const updateRadar = (contacts = []) => {
radarDots.forEach((dot, index) => {
- const pirate = pirates[index];
- if (!pirate) {
+ const contact = contacts[index];
+ if (!contact) {
dot.hidden = true;
return;
}
- let dx = (pirate.x - scene.player.x) / 900;
- let dy = (pirate.y - scene.player.y) / 900;
+ let dx = Number(contact.dx) || 0;
+ let dy = Number(contact.dy) || 0;
const distance = Math.hypot(dx, dy);
if (distance > 1) {
dx /= distance;
@@ -725,63 +679,58 @@ if (!isDesktopPointer.matches) {
});
};
- const update = () => {
- const scene = getScene();
+ let lastPlayerHitSequence = 0;
- if (scene?.player) {
- hideLegacyHud(scene);
-
- const heading = ((scene.player.angle % 360) + 360) % 360;
- const activePirates = scene.pirates?.countActive?.(true) || 0;
- 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' : ''}`;
- fields.heading.textContent = `${heading.toFixed(0).padStart(3, '0')}°`;
- fields.wave.textContent = String(scene.wave || 1);
- 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);
- const now = scene.time?.now ?? 0;
- const portReady = clamp01((now - (scene.lastPlayerShot?.port ?? -Infinity)) / reloadMs);
- const starboardReady = clamp01((now - (scene.lastPlayerShot?.starboard ?? -Infinity)) / reloadMs);
- fields['port-reload'].style.transform = `scaleX(${portReady})`;
- fields['starboard-reload'].style.transform = `scaleX(${starboardReady})`;
-
- const statusText = scene.status?.text?.trim?.() || '';
- fields.status.textContent = statusText;
- fields.status.classList.toggle('visible', Boolean(statusText));
-
- updateRadar(scene);
- }
-
- requestAnimationFrame(update);
+ const pulsePlayerHit = (sequence) => {
+ if (!Number.isFinite(sequence) || sequence <= lastPlayerHitSequence) return;
+ lastPlayerHitSequence = sequence;
+ playerHealthPiece.classList.remove('steam-hit');
+ void playerHealthPiece.offsetWidth;
+ playerHealthPiece.classList.add('steam-hit');
};
+ const renderHudState = (state) => {
+ if (!state?.player) return;
+
+ const playerHull = Math.max(0, state.player.hull ?? 100);
+ const playerMaxHull = Math.max(1, state.player.maxHull ?? 100);
+ const target = state.target || null;
+
+ fields.speed.textContent = `${Math.abs(state.speed || 0).toFixed(0)}${state.speed < 0 ? 'R' : ''}`;
+ fields.heading.textContent = `${(state.heading || 0).toFixed(0).padStart(3, '0')}°`;
+ fields.wave.textContent = String(state.wave || 1);
+ fields.hostiles.textContent = String(state.hostiles || 0);
+ fields.kills.textContent = String(state.kills || 0);
+ 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';
+
+ 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.classList.toggle('visible', Boolean(statusText));
+
+ pulsePlayerHit(state.player.hitSequence ?? 0);
+ updateRadar(state.radar || []);
+ };
+
+ window.addEventListener('airship:hud-state', (event) => {
+ renderHudState(event.detail);
+ });
+
airshipLog('hud:mounted', {
mobile: true,
pieces: hud.querySelectorAll('.steam-piece').length,
});
- requestAnimationFrame(update);
}
}
diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs
index 1b7a231..46168ab 100644
--- a/tests/wiring.test.mjs
+++ b/tests/wiring.test.mjs
@@ -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, /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/);
});
@@ -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, /const healthFrame = \(current, max\) =>/);
assert.match(source, /const setHealthFrame = \(piece, frame\) =>/);
- assert.match(source, /player-health-cover/);
- assert.match(source, /player-health-tint/);
- assert.match(source, /const enemyMaxHull = pirate \? Math\.max\(1, pirate\.getData\?\.\('maxHull'\) \?\? 120\) : 120/);
- assert.match(source, /setHealthFrame\(enemyHealthPiece, pirate \? healthFrame\(enemyHull, enemyMaxHull\) : 11\)/);
+ assert.match(source, /\.steam-player-card \{[\s\S]*?background-size: 400% 300%/);
+ assert.match(source, /setHealthFrame\(playerHealthPiece, healthFrame\(playerHull, playerMaxHull\)\)/);
+ assert.match(source, /setHealthFrame\([\s\S]*?enemyHealthPiece,[\s\S]*?target \? healthFrame\(target\.hull \|\| 0, target\.maxHull \|\| 120\) : 11/);
+ assert.doesNotMatch(source, /player-health-cover/);
+ assert.doesNotMatch(source, /player-health-tint/);
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\?\.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\)/);
+});