Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 5efc5dfe61 Updated tests 2026-09-08 10:36:48 -06:00
Brian Fertig ec68fa142c Fix frozen video feed on window reopen and add construct SFX
- Use `video.resume()` instead of `video.play()` in Build, Map, Quest,
  and Research windows: Phaser v4's `play()` is a no-op after `pause()`
  because the `_playCalled` flag stays true, leaving the feed frozen
  after close → reopen. `resume()` handles both first open and re-open.
- Play the 'construct' SFX when the planet HUD lines start their
  scramble-decode animation in SurfaceScene.
2026-09-08 10:32:46 -06:00
10 changed files with 335 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

17
dev/video-reopen.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — video reopen regression (dev)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/video-reopen.mjs"></script>
</body>
</html>

106
dev/video-reopen.mjs Normal file
View File

@ -0,0 +1,106 @@
/**
* Dev-only: the EXACT player-reported flow against the real Research
* window open the console, close it, open it again: the feed must be
* PLAYING every time it opens (it used to stay paused on reopen, because
* Phaser 4's video play() is a no-op after pause(); the window now
* resumes via video.resume()).
*
* node dev/server.mjs 8123
* node dev/shot-firefox.mjs \
* "http://127.0.0.1:8123/dev/video-reopen.html" \
* "window.__VIDEO_REOPEN ? '1' : null" VIDEO_REOPEN.png 45000
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText =
'position:fixed;left:10px;top:10px;z-index:9999;margin:0;padding:10px 14px;font:13px/1.55 monospace;color:#eaf6ff;background:rgba(6,20,16,0.94);border:1px solid #1b3a5a;white-space:pre-wrap;max-width:60%;';
document.body.appendChild(report);
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
setReport('booting…');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const done = (lines) => {
setReport(lines);
window.__VIDEO_REOPEN = { ready: true, lines, errors };
};
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // main.js does the same
const game = new Phaser.Game(gameConfig);
window.game = game;
async function waitScene() {
for (let i = 0; i < 150; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.researchWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
const results = [];
const line = (ok, tag, detail) => results.push(`${ok ? 'PASS' : 'FAIL'} ${tag}${detail}`);
const vstate = (w) => {
const el = w.video && w.video.video;
return el ? `state=${w.openState} video:${el.paused ? 'paused' : 'playing'} t=${(el.currentTime || 0).toFixed(2)}s` : 'NO SIGNAL';
};
try {
const s = await waitScene();
const w = s.researchWindow;
await sleep(800); // boot reveal settles
// ── open #1 (deck RESEARCH button) ─────────────────────────────────
s.deckAction('research');
await sleep(1500);
const el = w.video.video;
line(w.isOpen && el && !el.paused, 'open #1', vstate(w));
// ── close (the ✕ / deck toggle) ────────────────────────────────────
w.close();
await sleep(700);
line(!w.isOpen && el.paused, 'close', vstate(w));
// ── open #2 — the reported bug: the feed must play again ───────────
s.deckAction('research');
await sleep(1500);
const t2 = el.currentTime;
line(w.isOpen && !el.paused, 'open #2 (reopen — was stuck paused before the fix)', vstate(w));
await sleep(900);
line(el.currentTime > t2, 'open #2 time advancing', `t=${t2.toFixed(2)}s → ${el.currentTime.toFixed(2)}s`);
// ── close → open #3: repeated toggles keep working ─────────────────
w.close();
await sleep(700);
line(el.paused, 'close #2', vstate(w));
s.deckAction('research');
await sleep(1500);
line(w.isOpen && !el.paused, 'open #3', vstate(w));
const allPass = results.every((r) => r.startsWith('PASS'));
const lines = [
allPass ? 'VIDEO REOPEN (RESEARCH WINDOW) — ALL PASS' : 'VIDEO REOPEN (RESEARCH WINDOW) — FAILURES',
'─'.repeat(64),
...results,
errors.length ? `errors:\n${errors.slice(0, 5).join('\n')}` : 'no console errors',
];
done(lines);
} catch (err) {
errors.push(`FATAL: ${err.message}`);
done(['VIDEO REOPEN — ERROR', String(err.message), ...errors.slice(0, 5)]);
}

20
dev/video-replay.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — video replay regression (dev)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { position: absolute; right: 10px; bottom: 10px; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/video-replay.mjs"></script>
<!-- Holds the `load` event (when --screenshot fires) until the test
sequences have all run and the report has painted. -->
<script defer src="/sleep?ms=11000"></script>
</body>
</html>

167
dev/video-replay.mjs Normal file
View File

@ -0,0 +1,167 @@
/**
* Dev-only regression test: feed-window video play/pause/replay.
*
* Symptom under test (Build/Research/Map/Quests windows): open the
* window, close it, open it again the feed stays PAUSED.
*
* Root cause lives in the vendored Phaser 4 Video object: play() only
* issues the native <video>.play() while its internal _playCalled flag
* is false; pause() leaves the flag true, so a later play() call is a
* silent no-op. resume() is the code path that re-issues native
* playback for a previously-played, now-paused clip.
*
* This page exercises exactly those call sequences on real Phaser
* Video objects (assets/videos/build.mp4) and reports:
* A play pause play (old open() path EXPECTED to stay paused)
* B play pause resume (new open() path EXPECTED to play)
* C resume (first open) (new open() path on a fresh clip)
*
* node dev/server.mjs 8123 # any static server that also answers /sleep?ms=N
* firefox --headless --screenshot VIDEO_REPLAY.png \
* --window-size=960,520 "http://127.0.0.1:8123/dev/video-replay.html"
*/
import Phaser from '../js/vendor/phaser.js';
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText =
'position:fixed;left:10px;top:10px;z-index:9999;margin:0;padding:10px 14px;font:13px/1.55 monospace;color:#eaf6ff;background:rgba(6,20,16,0.94);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
setReport('booting…');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class VideoTestScene extends Phaser.Scene {
constructor() {
super({ key: 'VideoTestScene' }); // v4: scenes carry their key explicitly
}
preload() {
this.load.video('feedA', 'assets/videos/build.mp4');
this.load.video('feedB', 'assets/videos/build.mp4');
this.load.video('feedC', 'assets/videos/build.mp4');
}
create() {
this.__ready = true; // create() runs after the loader completes
}
}
const game = new Phaser.Game({
type: Phaser.AUTO,
width: 300,
height: 200,
parent: 'game',
backgroundColor: '#04060d',
scene: VideoTestScene,
});
window.game = game;
const results = [];
const line = (ok, tag, detail) => results.push(`${ok ? 'PASS' : 'FAIL'} ${tag}${detail}`);
function snapshot(v, label) {
const el = v && v.video;
const s = el
? `paused=${el.paused} t=${(el.currentTime || 0).toFixed(2)}s ended=${el.ended}`
: 'no <video> element';
return `${label}: ${s}`;
}
async function run() {
// the scene manager resolves by name only after boot — poll (same as
// the other dev harnesses, e.g. research-shot.mjs)
let scene = null;
for (let i = 0; i < 200; i++) {
scene = game.scene.getScene('VideoTestScene');
if (scene) break;
await sleep(100);
}
if (!scene) {
const dump = JSON.stringify({
booted: game.isBooted,
renderer: game.renderer && game.renderer.type,
scenes: (game.scene && game.scene.scenes || []).map((sc) => (sc.sys && (sc.sys.settings && sc.sys.settings.key || sc.sys.key)) || sc.key),
}, null, 0);
throw new Error('scene never registered — ' + dump);
}
// wait for the scene's create() (loader done)
for (let i = 0; i < 200 && !scene.__ready; i++) await sleep(100);
if (!scene.__ready) throw new Error('scene never reached create()');
await sleep(300);
const mk = (key) => {
const v = scene.add.video(0, 0, key);
v.setLoop(true);
if (v.video) v.video.muted = true;
return v;
};
// ── A: the OLD open() path — play() after pause() ─────────────────
const a = mk('feedA');
a.play(); // open #1
await sleep(1200);
const a1 = a.video;
line(!a1.paused, 'A-open#1 play()', snapshot(a, 'after play()'));
a.pause(); // close
await sleep(400);
line(a1.paused, 'A-close pause()', snapshot(a, 'after pause()'));
a.play(); // open #2 — the old code path
await sleep(1200);
line(
a1.paused,
'A-open#2 play() (old path — reproduces the bug: stays paused)',
snapshot(a, 'after 2nd play()')
);
// ── B: the NEW open() path — resume() after pause() ────────────────
const b = mk('feedB');
b.resume(); // open #1 (resume() delegates to play() on a fresh clip)
await sleep(1200);
const b1 = b.video;
line(!b1.paused, 'B-open#1 resume()', snapshot(b, 'after first resume()'));
b.pause(); // close
await sleep(400);
const tBefore = b1.currentTime;
b.resume(); // open #2 — the new code path
await sleep(1200);
line(
!b1.paused && b1.currentTime > tBefore,
'B-open#2 resume() (new path — fixed)',
snapshot(b, `after resume() (was t=${tBefore.toFixed(2)}s)`)
);
// ── C: first-ever open uses resume() (never played before) ────────
const c = mk('feedC');
c.resume(); // open — never played
await sleep(1200);
const c1 = c.video;
line(!c1.paused && c1.currentTime > 0, 'C-open#1 resume() (fresh clip)', snapshot(c, 'after resume()'));
// ── verdict ───────────────────────────────────────────────────────
const allPass = results.every((r) => r.startsWith('PASS'));
const lines = [
`Phaser ${Phaser.VERSION}`,
allPass ? 'VIDEO REPLAY TEST — ALL PASS' : 'VIDEO REPLAY TEST — FAILURES',
'─'.repeat(64),
...results,
'',
'A-open#2 staying paused is EXPECTED: it is the Phaser 4 play()-after-',
'pause() no-op that the window fix (resume()) works around.',
errors.length ? `errors:\n${errors.slice(0, 5).join('\n')}` : 'no console errors',
];
setReport(lines);
window.__done = true; // dev/shot-firefox.mjs polls this
a.pause(); // stop the repro clip; B/C keep looping (ambience, muted)
setReport(lines);
}
run().catch((e) => {
window.__done = true;
setReport(['VIDEO REPLAY TEST — ERROR', String((e && e.message) || e), ...errors.slice(0, 5)]);
});

View File

@ -446,6 +446,7 @@ export class SurfaceScene extends Phaser.Scene {
if (!this.hudLines) return;
if (this.hudT0 === null) {
this.hudT0 = time;
this.playSfx('construct'); // the planet info starts typing in (data/sfx.json → construct)
for (const ln of this.hudLines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
}
let done = true;

View File

@ -1233,7 +1233,12 @@ export class BuildWindow extends Phaser.GameObjects.Container {
this._paintAll(this.activeCat);
this._paintStatusStrip();
this.video?.play?.();
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the feed would stay frozen after close → reopen.
// resume() handles both a first open (delegates to play()) and a
// re-open of a paused clip.
this.video?.resume?.();
this.sfx('ui_window');
this._startReveal();

View File

@ -2171,7 +2171,12 @@ export class MapWindow extends Phaser.GameObjects.Container {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
this.video?.play?.();
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the feed would stay frozen after close → reopen.
// resume() handles both a first open (delegates to play()) and a
// re-open of a paused clip.
this.video?.resume?.();
this.sfx('ui_window');
const now = this.scene.time.now;
this._startReveal(now);

View File

@ -1065,7 +1065,12 @@ export class QuestWindow extends Phaser.GameObjects.Container {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
this.video?.play?.();
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the feed would stay frozen after close → reopen.
// resume() handles both a first open (delegates to play()) and a
// re-open of a paused clip.
this.video?.resume?.();
this.sfx('ui_window');
this._startReveal(this.scene.time.now);
}

View File

@ -1269,7 +1269,12 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this._paintAll(this.activeCat);
this._paintStatusStrip();
this.video?.play?.();
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the feed would stay frozen after close → reopen.
// resume() handles both a first open (delegates to play()) and a
// re-open of a paused clip.
this.video?.resume?.();
this.sfx('ui_window');
this._startReveal();
}