255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
|
|
|
test('mobile HUD uses split helm frame and knob assets', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /import helmFrameSrc from '.\/assets\/controller\/helm-frame\.js'/);
|
|
assert.match(source, /import helmKnobSrc from '.\/assets\/controller\/helm-knob\.js'/);
|
|
assert.doesNotMatch(source, /helmSpriteSheetSrc/);
|
|
assert.doesNotMatch(source, /helm-sheet\.webp/);
|
|
assert.doesNotMatch(source, /backgroundPosition = .*column/);
|
|
});
|
|
|
|
test('drag control emits and HUD consumes one shared helm vector event', async () => {
|
|
const [index, hud] = await Promise.all([
|
|
read('index.html'),
|
|
read('src/mobile-hud.js'),
|
|
]);
|
|
|
|
assert.match(index, /CustomEvent\('airship:helm-vector'/);
|
|
assert.match(index, /emitHelmVector\(dx \/ radius, dy \/ radius\)/);
|
|
assert.match(index, /emitHelmVector\(0, 0\)/);
|
|
assert.doesNotMatch(index, /setHeld\('Arrow/);
|
|
assert.doesNotMatch(index, /keyMap/);
|
|
|
|
assert.match(hud, /addEventListener\('airship:helm-vector'/);
|
|
assert.match(hud, /setHelmKnob\(event\.detail\?\.x \?\? 0, event\.detail\?\.y \?\? 0\)/);
|
|
});
|
|
|
|
test('top HUD and cannon controls use direct image modules', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /import topHudSrc from '.\/assets\/controller\/top-hud\.js'/);
|
|
assert.match(source, /import cannonRoundelSrc from '.\/assets\/controller\/cannon-roundel\.js'/);
|
|
assert.match(source, /addPiece\('steam-top', topHudSrc/);
|
|
assert.match(source, /addPiece\('steam-cannon-port', cannonRoundelSrc/);
|
|
assert.match(source, /addPiece\('steam-cannon-starboard', cannonRoundelSrc/);
|
|
});
|
|
|
|
test('player collision callback queues hits and processing occurs in scene update', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /this\.pendingPlayerHits\.push\(\{[\s\S]*?shot,[\s\S]*?damage,[\s\S]*?x: shot\.x,[\s\S]*?y: shot\.y/);
|
|
assert.match(source, /this\.processPlayerHits\(\)/);
|
|
assert.match(source, /reducePlayerProjectileHits\(\{/);
|
|
assert.match(source, /combat:player-hit-queued/);
|
|
assert.match(source, /combat:player-hit-processed/);
|
|
});
|
|
|
|
test('wide-screen HUD keeps visual controls aligned with touch hit-zones', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /@media \(min-width: 700px\)/);
|
|
assert.match(source, /--steam-wide-helm-size:/);
|
|
assert.match(source, /--steam-wide-cannon-size:/);
|
|
assert.match(source, /\.steam-radar-wrap,\s*#mobile-controls\.controller-active #drag-stick/);
|
|
assert.match(source, /\.steam-cannon-port,\s*\.steam-cannon-starboard,\s*#mobile-controls\.controller-active \.mobile-control\.port,\s*#mobile-controls\.controller-active \.mobile-control\.starboard/);
|
|
assert.match(source, /right: calc\(var\(--steam-wide-edge-right\) \+ var\(--steam-wide-cannon-size\) \+ 14px\)/);
|
|
});
|
|
|
|
test('wide-screen HUD caps portrait-scaled artwork instead of stretching with viewport width', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /\.steam-top \{[\s\S]*?width: min\(64vw, 560px\)/);
|
|
assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-player-card \{[\s\S]*?width: min\(18vw, 180px\)/);
|
|
assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-enemy-card \{[\s\S]*?width: min\(18vw, 180px\)/);
|
|
});
|
|
|
|
test('non-lethal player hit processing does not mutate the player object or game state', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
const match = /processPlayerHits\(\) \{([\s\S]*?)\n spawnExplosion\(/.exec(source);
|
|
assert.ok(match, 'processPlayerHits block should be found');
|
|
|
|
const block = match[1];
|
|
assert.match(block, /this\.playerHull = result\.hull/);
|
|
assert.match(block, /if \(gameOver\) this\.finishGame\(\)/);
|
|
|
|
assert.doesNotMatch(block, /this\.player\.setVelocity\(/);
|
|
assert.doesNotMatch(block, /this\.player\.setVisible\(/);
|
|
assert.doesNotMatch(block, /this\.player\.setActive\(/);
|
|
assert.doesNotMatch(block, /this\.player\.destroy\(/);
|
|
assert.doesNotMatch(block, /this\.player\.body\.enable\s*=/);
|
|
assert.doesNotMatch(block, /this\.gameState\s*=/);
|
|
});
|
|
|
|
test('a hit frame emits a completion marker after the rest of scene update runs', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /const hadQueuedPlayerHit = this\.pendingPlayerHits\.length > 0/);
|
|
assert.match(source, /this\.checkGameState\(\);[\s\S]*?combat:post-hit-frame-complete/);
|
|
});
|
|
|
|
test('player hit FX runs after queued hit processing, outside the overlap callback', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
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\.playPlayerHitReaction\(totalDamage\)/);
|
|
assert.match(source, /this\.cameras\.main\.shake\(120/);
|
|
assert.doesNotMatch(source, /targets: this\.player/);
|
|
assert.match(source, /combat:player-hit-fx-error/);
|
|
});
|
|
|
|
|
|
test('player damage is per projectile with no global cooldown or volley suppression', async () => {
|
|
const source = await read('src/game-v4.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/);
|
|
});
|
|
|
|
test('portrait health sprites sit tightly beneath the top HUD', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /\.steam-player-card,[\s\S]*?\.steam-enemy-card \{[\s\S]*?top: clamp\(80px, 10\.3vh, 90px\)/);
|
|
assert.match(source, /background-size: 400% 300%/);
|
|
assert.match(source, /background-color: transparent/);
|
|
assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-player-card,[\s\S]*?\.steam-enemy-card \{[\s\S]*?top: clamp\(70px, 20vh, 104px\)/);
|
|
});
|
|
|
|
test('mobile HUD uses the player sprite sheet for health states', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /import playerHealthSrc from '.\/assets\/controller\/player-health\.js'/);
|
|
assert.match(source, /import enemyHealthSrc from '.\/assets\/controller\/enemy-health\.js'/);
|
|
assert.match(source, /addHealthPiece\('steam-player-card', playerHealthSrc/);
|
|
assert.match(source, /\.steam-player-card \{[\s\S]*?background-size: 400% 300%/);
|
|
assert.match(source, /setHealthFrame\(\s*playerHealthPiece,\s*healthFrame\(playerHull, playerMaxHull\)/);
|
|
assert.doesNotMatch(source, /player-health-hq-frame\.js/);
|
|
assert.doesNotMatch(source, /steam-player-health-fill/);
|
|
});
|
|
|
|
test('uploaded top HUD uses its new aspect ratio', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /\.steam-top \{[\s\S]*?aspect-ratio: 960 \/ 246/);
|
|
assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-top \{[\s\S]*?aspect-ratio: 960 \/ 246/);
|
|
});
|
|
|
|
|
|
test('game consumes analog helm vector and keeps broadside cooldowns independent', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /addEventListener\('airship:helm-vector', this\.handleHelmVector\)/);
|
|
assert.match(source, /const rawHelmX = Math\.abs\(this\.helmVector\.x\) > HELM_DEAD_ZONE/);
|
|
assert.match(source, /Math\.pow\(Math\.abs\(rawHelmX\), HELM_STEER_CURVE\)/);
|
|
assert.match(source, /const throttleForward = this\.cursors\.up\.isDown \? 1 : Math\.max\(0, -helmY\)/);
|
|
assert.match(source, /const turnInput = keyboardTurn \|\| helmX/);
|
|
assert.match(source, /const broadside = side < 0 \? 'port' : 'starboard'/);
|
|
assert.match(source, /this\.lastPlayerShot\[broadside\]/);
|
|
});
|
|
|
|
|
|
test('pirate combat uses class stats and separation steering', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /pirateClassForWave\(this\.wave, i, count\)/);
|
|
assert.match(source, /separationVector\(pirate, pirates\)/);
|
|
assert.match(source, /pirate\.getData\('reloadMs'\)/);
|
|
assert.match(source, /pirate\.getData\('maxHull'\)/);
|
|
assert.match(source, /nearestHull \/ nearestMaxHull/);
|
|
});
|
|
|
|
test('mobile cannon HUD shows independent reload progress', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /data-steam="port-reload"/);
|
|
assert.match(source, /data-steam="starboard-reload"/);
|
|
assert.match(source, /state\.reload\?\.port/);
|
|
assert.match(source, /state\.reload\?\.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, /this\.player\.setTint\(0xffd7d7\)/);
|
|
assert.match(game, /this\.cameras\.main\.shake\(120/);
|
|
assert.doesNotMatch(game, /targets: this\.player/);
|
|
assert.doesNotMatch(game, /scaleX: this\.playerBaseScaleX/);
|
|
assert.match(hud, /steam-player-card\.steam-hit/);
|
|
assert.match(hud, /pulsePlayerHit\(state\.player\.hitSequence \?\? 0\)/);
|
|
});
|
|
|
|
|
|
test('processed pirate shots are consumed and later projectiles start clean', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /shot\.setData\('projectileOwner', actor\)/);
|
|
assert.match(source, /if \(actor === 'pirate'\) shot\.setData\('playerHitQueued', false\)/);
|
|
assert.match(source, /shot !== this\.player[\s\S]*?projectileOwner'\) === 'pirate'[\s\S]*?shot\.destroy\(\)/);
|
|
assert.match(source, /if \(shot\.getData\('playerHitQueued'\)\) return/);
|
|
});
|
|
|
|
|
|
test('non-lethal damage preserves the live player sprite and body', 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\)/);
|
|
});
|
|
|
|
|
|
test('player overlap resolves an explicitly tagged pirate projectile before queueing or destroying it', async () => {
|
|
const source = await read('src/game-v4.js');
|
|
|
|
assert.match(source, /shot\.setData\('projectileOwner', actor\)/);
|
|
assert.match(source, /hitPlayer\(first, second\)/);
|
|
assert.match(source, /resolveTaggedProjectile\(first, second, 'pirate'\)/);
|
|
assert.match(source, /if \(!shot\?\.active \|\| shot === this\.player\)/);
|
|
assert.match(source, /shot !== this\.player[\s\S]*?projectileOwner'\) === 'pirate'[\s\S]*?shot\.destroy\(\)/);
|
|
});
|
|
|
|
|
|
test('player health uses the same 4x3 sprite frame addressing as enemy health', async () => {
|
|
const source = await read('src/mobile-hud.js');
|
|
|
|
assert.match(source, /const healthFrame = \(current, max\) =>/);
|
|
assert.match(source, /const setHealthFrame = \(piece, frame\) =>/);
|
|
assert.match(source, /setHealthFrame\(\s*playerHealthPiece,/);
|
|
assert.match(source, /background-size: 400% 300%/);
|
|
assert.doesNotMatch(source, /setPlayerHealthLevel/);
|
|
});
|