Add shop clip toggle to surface deck

- SHOP button now swaps the looping surface clip for the world's `shop`
  video (e.g. terran-shop-01.mp4); pressing again swaps back. The slot
  relabels between "Shop" and "Leave Shop" via a new ActionBar.setLabel.
- data/landing.json gains a per-frame `shop` entry; worlds without one get
  a console note instead of a clip.
- SurfaceScene: toggleShop/leaveShop manage the shop video (pause/resume,
  visibility), error handling falls back to the surface loop, and cleanup
  on takeoff/shutdown disposes the new video and note glyphs.
- ActionBar: slots store their base fontSize so setLabel can shrink-to-fit
  long labels; paintSlot re-asserts hover state after relabeling.
This commit is contained in:
Brian Fertig 2026-09-05 15:50:09 -06:00
parent 70f4954c69
commit d7f8e2101f
4 changed files with 201 additions and 12 deletions

View File

@ -20,9 +20,9 @@
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" } { "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
], ],
"surface": { "surface": {
"_comment": "Deck layout for the surface (SurfaceScene): `research` is replaced by `shop` (no-op for now) and `takeoff` sits to the left of `menu`. SurfaceScene passes this list to ActionBar via its `buttons` option; everything else (height, colors, animation) is shared with the flight deck.", "_comment": "Deck layout for the surface (SurfaceScene): `research` is replaced by `shop` (toggles the world's shop clip — while the clip is up the slot relabels to `labelActive`, and pressing it swaps back) and `takeoff` sits to the left of `menu`. SurfaceScene passes this list to ActionBar via its `buttons` option; everything else (height, colors, animation) is shared with the flight deck.",
"buttons": [ "buttons": [
{ "id": "shop", "label": "Shop", "accent": "#00e5ff" }, { "id": "shop", "label": "Shop", "labelActive": "Leave Shop", "accent": "#00e5ff" },
{ "id": "build", "label": "Build", "accent": "#ffc94d" }, { "id": "build", "label": "Build", "accent": "#ffc94d" },
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" }, { "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
{ "id": null, "label": null }, { "id": null, "label": null },

View File

@ -6,6 +6,9 @@
"", "",
"`land` — the full-screen one-shot clip played when the ship lands.", "`land` — the full-screen one-shot clip played when the ship lands.",
"`surface` — the looping clip shown on the surface deck behind the action bar.", "`surface` — the looping clip shown on the surface deck behind the action bar.",
"`shop` — the looping clip the SHOP button swaps in over the surface",
" clip (press SHOP again to swap back). null/missing = no shop",
" on that world (a console note instead of a clip).",
"`takeoff` — the full-screen one-shot clip played on TAKE OFF, before the", "`takeoff` — the full-screen one-shot clip played on TAKE OFF, before the",
" flight world resumes. A null/missing entry takes off instantly.", " flight world resumes. A null/missing entry takes off instantly.",
"", "",
@ -37,11 +40,11 @@
"lineStrokeWidth": 2 "lineStrokeWidth": 2
}, },
"videos": [ "videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-02.mp4" }, { "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-02.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4" } { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }
] ]
} }

View File

@ -18,8 +18,9 @@ import { SaveManager } from '../save/SaveManager.js';
* plate. No UI during the flight down. * plate. No UI during the flight down.
* 2. SURFACE the clip is swapped for the looping world clip (`surface`), * 2. SURFACE the clip is swapped for the looping world clip (`surface`),
* and the command deck appears: the GameScene deck with Research * and the command deck appears: the GameScene deck with Research
* replaced by SHOP (a seam for now) and TAKE OFF left of MENU * replaced by SHOP (swaps the loop for the world's `shop` clip
* (data/actionbar.json surface.buttons). Menu keeps the GameScene's * e.g. terran-shop-01.mp4 press again to swap back) and TAKE OFF
* left of MENU (data/actionbar.json surface.buttons). Menu keeps the GameScene's
* save sub-bar Save/Load operate on the paused GameScene's state * save sub-bar Save/Load operate on the paused GameScene's state
* (SavePanel `stateScene`). While the clip loops, the planet's name * (SavePanel `stateScene`). While the clip loops, the planet's name
* decodes in at the upper-left (Ethnocentric, the menu title's shade, * decodes in at the upper-left (Ethnocentric, the menu title's shade,
@ -36,7 +37,9 @@ import { SaveManager } from '../save/SaveManager.js';
* giants, per data/planets.json frames), so a planet always lands with * giants, per data/planets.json frames), so a planet always lands with
* the clip matching the art it is drawn with. Missing clips fall back to * the clip matching the art it is drawn with. Missing clips fall back to
* the existing stubs in data/landing.json; a broken/missing file skips * the existing stubs in data/landing.json; a broken/missing file skips
* straight to the surface instead of stranding the player. * straight to the surface instead of stranding the player. SHOP swaps the
* surface loop for the frame's `shop` clip (null on worlds without one
* a console note instead).
*/ */
export class SurfaceScene extends Phaser.Scene { export class SurfaceScene extends Phaser.Scene {
constructor() { constructor() {
@ -67,6 +70,10 @@ export class SurfaceScene extends Phaser.Scene {
this.takeoffKey = null; this.takeoffKey = null;
this.takeoffVideo = null; this.takeoffVideo = null;
this.takeoffDone = false; // finishTakeoff idempotency ('complete' + a guard can both fire) this.takeoffDone = false; // finishTakeoff idempotency ('complete' + a guard can both fire)
this.shopKey = null;
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
this.noteG = null; // console-note glyphs (consoleNote)
} }
/** Pick this world's clips (data/landing.json) and queue them. */ /** Pick this world's clips (data/landing.json) and queue them. */
@ -75,12 +82,15 @@ export class SurfaceScene extends Phaser.Scene {
this.landKey = `__surf_land_${this.planetFrame}`; this.landKey = `__surf_land_${this.planetFrame}`;
this.surfaceKey = `__surf_loop_${this.planetFrame}`; this.surfaceKey = `__surf_loop_${this.planetFrame}`;
this.takeoffKey = `__surf_takeoff_${this.planetFrame}`; this.takeoffKey = `__surf_takeoff_${this.planetFrame}`;
this.shopKey = `__surf_shop_${this.planetFrame}`;
this.landUrl = this.resolveUrl(entry.land ?? null); this.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null); this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null); this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null);
this.shopUrl = this.resolveUrl(entry.shop ?? null);
if (this.landUrl) this.load.video(this.landKey, this.landUrl); if (this.landUrl) this.load.video(this.landKey, this.landUrl);
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl); if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl); if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
if (this.shopUrl) this.load.video(this.shopKey, this.shopUrl);
// The surface's music loop (data/music.json → frames[planets.png frame]) // The surface's music loop (data/music.json → frames[planets.png frame])
// — the same key as the videos, so a world hums the track matching the // — the same key as the videos, so a world hums the track matching the
@ -190,6 +200,141 @@ export class SurfaceScene extends Phaser.Scene {
this.buildDeck(); this.buildDeck();
} }
// ------------------------------------------------------------------
// Stage 2b — the shop clip (SHOP swaps the loop, and back)
// ------------------------------------------------------------------
/**
* SHOP swap the looping surface clip for this world's shop clip
* (data/landing.json videos[frame].shop, e.g. terran-shop-01.mp4);
* the next press swaps back. The surface clip is PAUSED, not
* destroyed, so it resumes exactly where it left off; the shop clip
* is created on the first press (the file was preloaded) and then
* just paused/resumed, and the deck's SHOP slot relabels to
* `labelActive` ("Leave Shop", data/actionbar.json surface.buttons)
* and back. The upper-left HUD stays up in both states the shop is
* on the same world and the surface hum keeps humming.
* A world whose frame has no shop clip (data/landing.json shop null)
* gets a console note instead.
*/
toggleShop() {
if (this.phase !== 'surface') return; // no deck, no shop
if (this.shopMode) {
this.leaveShop();
return;
}
if (!this.hasVideo(this.shopKey)) {
this.consoleNote('NO SHOP ON THIS WORLD');
return;
}
if (!this.shopVideo) {
// First press of this stay: bring the clip up (only the <video>
// element is new — the file was preloaded).
const s = this.add.video(0, 0, this.shopKey).setOrigin(0.5).setDepth(10);
this.attachClip(s);
s.setLoop(true);
this.shopVideo = s;
s.on('error', () => {
// Broken clip — drop back to the surface loop, don't strand the view.
this.shopMode = false;
this.destroyVideo(this.shopVideo);
this.shopVideo = null;
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
this.consoleNote('SHOP CLIP UNAVAILABLE');
});
}
this.shopVideo.setVisible(true); // may be hidden from a previous visit
this.surfaceVideo?.pause(); // the loop pauses where it is (resume() brings it back)
// resume(), not play(): the first start is a no-op-safe superset (it
// falls through to the first-play path), and on a RE-entry the clip
// has played before, where play() would silently no-op.
this.shopVideo.resume();
this.shopMode = true;
this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP
}
/**
* The second SHOP press back to the looping surface clip.
* v4 quirk: for a clip that has already played once, `play()` is a
* no-op (its createPlayPromise gate only fires on the first start)
* `resume()` (setPaused(false)) is what actually restarts it. And a
* paused <video> keeps showing its last frame, so the shop clip
* painted over the surface loop must be hidden, not just paused.
*/
leaveShop() {
this.shopMode = false;
this.shopVideo?.pause();
this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
}
/**
* The SHOP slot's label for the given state (data/actionbar.json
* surface.buttons: `label` out, `labelActive` in), with plain fallbacks
* if the config entry is missing.
*/
shopLabel(inShop) {
const buttons = config.get('actionbar.surface.buttons');
const b = Array.isArray(buttons) ? buttons.find((x) => x && x.id === 'shop') : null;
const active = b?.labelActive ?? 'Leave Shop';
const base = b?.label ?? 'Shop';
return inShop ? String(active) : String(base);
}
/**
* The console note this scene's small cousin of GameScene.consoleToast:
* an accent glyph + console-caps line, top-center, fading in, holding,
* fading out. One at a time (a new note replaces an old one). SHOP
* feedback lives here.
*/
consoleNote(label, { glyph = '\u25b8', glyphColor, durationMs = 2400 } = {}) {
if (Array.isArray(this.noteG)) {
for (const g of this.noteG) g.destroy();
this.noteG = null;
}
const fam = fontStack('body');
const g1 = this.add
.text(0, 0, glyph, {
fontFamily: fam,
fontSize: '13px',
color: glyphColor ?? toCss(themeColor('neon', 0x00e5ff)),
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen
const g2 = this.add
.text(0, 0, String(label ?? '').toUpperCase(), {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
const yy = this.scale.height / 2 - 75; // the flight world's console slot
g1.setPosition(x0, yy).setDepth(60).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(60).setAlpha(0);
this.noteG = [g1, g2];
this.tweens.add({ targets: this.noteG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.noteG)) return;
const [a, b] = this.noteG;
this.noteG = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
});
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// HUD (upper-left) — the planet's name + type + tether level // HUD (upper-left) — the planet's name + type + tether level
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -321,11 +466,16 @@ export class SurfaceScene extends Phaser.Scene {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/** /**
* The deck's button presses (ActionBar onAction). SHOP / BUILD / * The deck's button presses (ActionBar onAction). SHOP swaps the
* looping surface clip for the world's shop clip (and back); BUILD /
* SHIP are seams for the surface economy (data/builds.json plugs in * SHIP are seams for the surface economy (data/builds.json plugs in
* later); TAKE OFF goes back to the ship; MENU is the save door. * later); TAKE OFF goes back to the ship; MENU is the save door.
*/ */
deckAction(id) { deckAction(id) {
if (id === 'shop') {
this.toggleShop();
return;
}
if (id === 'menu') { if (id === 'menu') {
this.menuAction(); this.menuAction();
return; return;
@ -416,6 +566,11 @@ export class SurfaceScene extends Phaser.Scene {
this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder
this.surfaceVideo = null; this.surfaceVideo = null;
} }
if (this.shopVideo) {
this.destroyVideo(this.shopVideo); // same — the take-off clip owns the screen
this.shopVideo = null;
}
this.shopMode = false;
const v = this.add.video(0, 0, this.takeoffKey).setOrigin(0.5).setDepth(20); const v = this.add.video(0, 0, this.takeoffKey).setOrigin(0.5).setDepth(20);
this.attachClip(v); this.attachClip(v);
@ -569,9 +724,16 @@ export class SurfaceScene extends Phaser.Scene {
this.destroyVideo(this.landVideo); this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo); this.destroyVideo(this.surfaceVideo);
this.destroyVideo(this.takeoffVideo); this.destroyVideo(this.takeoffVideo);
this.destroyVideo(this.shopVideo);
this.landVideo = null; this.landVideo = null;
this.surfaceVideo = null; this.surfaceVideo = null;
this.takeoffVideo = null; this.takeoffVideo = null;
this.shopVideo = null;
this.shopMode = false;
if (Array.isArray(this.noteG)) {
for (const g of this.noteG) g.destroy();
this.noteG = null;
}
this.destroyHud(); this.destroyHud();
this.actionBar?.destroy(); this.actionBar?.destroy();
this.menuSubBar?.destroy(); this.menuSubBar?.destroy();

View File

@ -238,6 +238,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
id: live ? b.id : null, id: live ? b.id : null,
live, live,
slot, slot,
fontSize, // setLabel's shrink-to-fit needs the slot's base size
accent: toColor(b.accent ?? themeColor('neon', 0x00e5ff)), accent: toColor(b.accent ?? themeColor('neon', 0x00e5ff)),
hoverOn: false, hoverOn: false,
pressing: false, pressing: false,
@ -458,6 +459,29 @@ export class ActionBar extends Phaser.GameObjects.Container {
} }
} }
/**
* Relabel a live slot in place (main text + both RGB ghosts) e.g. the
* surface deck's SHOP slot reads LEAVE SHOP while the shop clip is up
* (`setLabel('shop', 'Leave Shop')`). The slot's id and therefore the
* onAction press is unchanged, so the scene's toggle keeps working.
* A label too long for the button face shrinks to fit. Returns the
* slot, or null if the id has no live slot.
*/
setLabel(id, text) {
const s = (this.slots ?? []).find((sl) => sl.id === id);
if (!s || !s.label) return null;
const t = String(text ?? '').toUpperCase();
for (const o of [s.label, s.ghostCyan, s.ghostMagenta]) o?.setText(t);
const max = this.style.bw - 18;
if (s.label.width > max) {
const k = Math.max(10, (s.fontSize * max) / s.label.width);
for (const o of [s.label, s.ghostCyan, s.ghostMagenta]) o?.setFontSize(`${k}px`);
}
// Re-assert the current visual state (hover keeps the white label).
this.paintSlot(s, s.hoverOn ? 'hover' : 'base');
return s;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Boot // Boot
// ------------------------------------------------------------------ // ------------------------------------------------------------------