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\)/); +});