Map HUD to game state and animate player hits
This commit is contained in:
parent
6b7cf5cdaf
commit
e0508c0d43
@ -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()) {
|
||||
|
||||
@ -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, `
|
||||
<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-value" data-steam="player-hull">100/100</span>
|
||||
`);
|
||||
@ -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 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 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';
|
||||
const renderHudState = (state) => {
|
||||
if (!state?.player) return;
|
||||
|
||||
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 playerHull = Math.max(0, state.player.hull ?? 100);
|
||||
const playerMaxHull = Math.max(1, state.player.maxHull ?? 100);
|
||||
const target = state.target || null;
|
||||
|
||||
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})`;
|
||||
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';
|
||||
|
||||
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.classList.toggle('visible', Boolean(statusText));
|
||||
|
||||
updateRadar(scene);
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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\)/);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user