Add Job Board console UI and surface scene integration
- Introduce js/ui/JobBoardWindow.js: nearly full-screen (depth 80) build-console-style window with a muted looping 2:3 feed on the left, and AVAILABLE JOBS / JOB DETAILS placeholder sections on the right; open/close state machine, boot reveal, ambient glitch/sweep/cursor effects, ✕ close door
- Wire the Job Board slot through SurfaceScene: toggleJobBoard/leaveJobBoard, setServiceLocked (generalized from setBarLocked) so deck + drawer stand down while the board is up with the jobBoard slot kept live as a second door; pause/resume the surface clip under the console
- Preload assets/videos/jobboard.mp4 in GameScene via data/actionbar.json → surface.explore.video (2:3 aspect), adding a NO SIGNAL fallback when missing
- Update data/actionbar.json explore drawer with the jobBoard item (isHomeSystem:false, no labelActive) and the shared video block
- Add headless test dev/jobboard.test.mjs covering config, build, geometry, open/close, update ticks, and destroy; extend phaser-stub with setVisible/setDepth
- Add dev screenshot harness dev/jobboard-shot.{html,mjs} and reference shots for drawer/board/left stages
This commit is contained in:
parent
919bcc4c4e
commit
1b9b269b80
|
|
@ -30,7 +30,8 @@
|
||||||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||||
],
|
],
|
||||||
"explore": {
|
"explore": {
|
||||||
"_comment": "The EXPLORE drawer — the full-width secondary bar (the deck's own left→right span, resting on its top rail — the shop tab bar's recipe) that folds up from the surface deck's Explore slot. SurfaceScene renders it with js/ui/MenuSubBar.js in span mode; the item LOGIC is js/ui/ExploreBar.js (worldContext / matchesWhere / resolveItems). `items` is the ordered button list: `id` (fired via onAction — the behavior lands later), `label`, `labelActive` (the label while the service is up — BAR relabels to LEAVE BAR while its clip loops, and is the only live button on the surface until pressed), `accent`, and an optional `where` clause — EVERY key it names must match the world the player is standing on for the button to appear. Keys: `world` ('planet' | 'station'); `planetClass` (the planet's class — 'terran' | 'rocky' | 'gas' | 'ice' | 'lava' | 'arid' | 'capitalNeutral' | 'capitalApex' | 'capitalMindweave' | 'capitalZenith', per data/planets.json → frames; 'none' on stations); `faction` (the faction controlling this system — 'apex' | 'mindweave' | 'zenith', or 'neutral' = wild space; per data/factions.json); `isHomeSystem` (boolean — true in the player's home / starting system; `{ isHomeSystem: false }` = every system EXCEPT the home). A value may be a single entry or a list (any-match). No `where` = every planet and station; the Job Board carries `{ isHomeSystem: false }` — it's on every planet and station EXCEPT the player's home system. DESIGN NOTE: the home system is safe space — enemies should NEVER be placed there, so there is no work to post on its board. The bar's clip is data/landing.json → videos[frame].bar (the neutral capital's capital-bar-neutral.mp4).",
|
"_comment": "The EXPLORE drawer — the full-width secondary bar (the deck's own left→right span, resting on its top rail — the shop tab bar's recipe) that folds up from the surface deck's Explore slot. SurfaceScene renders it with js/ui/MenuSubBar.js in span mode; the item LOGIC is js/ui/ExploreBar.js (worldContext / matchesWhere / resolveItems). `items` is the ordered button list: `id` (fired via onAction — the behavior lands later), `label`, `labelActive` (the label while the service is up — BAR relabels to LEAVE BAR while its clip loops, and is the only live button on the surface until pressed), `accent`, and an optional `where` clause — EVERY key it names must match the world the player is standing on for the button to appear. Keys: `world` ('planet' | 'station'); `planetClass` (the planet's class — 'terran' | 'rocky' | 'gas' | 'ice' | 'lava' | 'arid' | 'capitalNeutral' | 'capitalApex' | 'capitalMindweave' | 'capitalZenith', per data/planets.json → frames; 'none' on stations); `faction` (the faction controlling this system — 'apex' | 'mindweave' | 'zenith', or 'neutral' = wild space; per data/factions.json); `isHomeSystem` (boolean — true in the player's home / starting system; `{ isHomeSystem: false }` = every system EXCEPT the home). A value may be a single entry or a list (any-match). No `where` = every planet and station; the Job Board carries `{ isHomeSystem: false }` — it's on every planet and station EXCEPT the player's home system. DESIGN NOTE: the home system is safe space — enemies should NEVER be placed there, so there is no work to post on its board. JOB BOARD is a nearly full-screen CONSOLE (js/ui/JobBoardWindow.js — the build screen's structure, depth 80 over the deck/drawer/upper-left HUD): pressing it opens the board (left: the feed — `video` below, a muted 2:3 loop of assets/videos/jobboard.mp4; right: the JOB BOARD title, an AVAILABLE JOBS section — the board's postings — and a JOB DETAILS section — the selected job's contract — both placeholders until the postings land); the console's ✕ is the way-out, and the JOB BOARD slot's own press stays live as the second door (the deck + drawer stand down meanwhile — SurfaceScene.setServiceLocked). The bar's clip is data/landing.json → videos[frame].bar (the neutral capital's capital-bar-neutral.mp4).",
|
||||||
|
"video": { "file": "assets/videos/jobboard.mp4", "aspect": [2, 3] },
|
||||||
"items": [
|
"items": [
|
||||||
{ "id": "jobBoard", "label": "Job Board", "accent": "#ffc94d", "where": { "isHomeSystem": false } },
|
{ "id": "jobBoard", "label": "Job Board", "accent": "#ffc94d", "where": { "isHomeSystem": false } },
|
||||||
{ "id": "bar", "label": "Bar", "labelActive": "Leave Bar", "accent": "#00e5ff", "where": { "planetClass": "capitalNeutral" } }
|
{ "id": "bar", "label": "Bar", "labelActive": "Leave Bar", "accent": "#00e5ff", "where": { "planetClass": "capitalNeutral" } }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Orbit — dev job board screenshot page</title>
|
||||||
|
<base href="../" />
|
||||||
|
<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/jobboard-shot.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* Dev-only visual probe for the JOB BOARD console (js/ui/JobBoardWindow.js):
|
||||||
|
* boots the real flow (menu → new game → land on the home world → surface),
|
||||||
|
* then parks at the stage named in the URL hash, with a report in a DOM
|
||||||
|
* <pre> the screenshot can always read:
|
||||||
|
*
|
||||||
|
* #drawer (default) — the HOME world's EXPLORE: no drawer at all (the
|
||||||
|
* Job Board slot is absent — data/actionbar.json →
|
||||||
|
* `isHomeSystem: false`, safe space), the slot's press is a no-op
|
||||||
|
* with the console's ack ("NOTHING TO EXPLORE HERE").
|
||||||
|
* #board — the board console OPEN over the surface (nearly
|
||||||
|
* full-screen, depth 80 — over the deck + drawer + the upper-left
|
||||||
|
* HUD), driven through the scene's own seams (the slot's press →
|
||||||
|
* the toggle): the left feed (assets/videos/jobboard.mp4, muted
|
||||||
|
* loop), the right JOB BOARD title + the AVAILABLE JOBS / JOB
|
||||||
|
* DETAILS placeholder sections, the deck stood down (the slot's
|
||||||
|
* press stays live as the second door; the ✕ is the way-out).
|
||||||
|
* PROBE STAGING: the home world's isHomeSystem flag is flipped
|
||||||
|
* for this shot so the slot exists here (on a real non-home world
|
||||||
|
* it exists naturally — the drawer's item list is the
|
||||||
|
* where-clause's, covered by dev/explore.test.mjs).
|
||||||
|
* #left — …and back: the slot's press again (the second
|
||||||
|
* door) — the console down, the deck live, the slot still JOB
|
||||||
|
* BOARD (no relabel either way).
|
||||||
|
*
|
||||||
|
* node dev/server.mjs 8080
|
||||||
|
* brave --headless --disable-gpu --remote-debugging-port=9333 --window-size=1280,720 about:blank
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/jobboard-shot.html#drawer" jb-drawer.png "window.__JB && window.__JB.stage && !/FAILED/.test(window.__JB.stage)" 45000
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/jobboard-shot.html#board" jb-board.png "window.__JB && window.__JB.stage && !/FAILED/.test(window.__JB.stage)" 45000
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/jobboard-shot.html#left" jb-left.png "window.__JB && window.__JB.stage && !/FAILED/.test(window.__JB.stage)" 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 { MenuScene } from '../js/scenes/MenuScene.js';
|
||||||
|
import { GameScene } from '../js/scenes/GameScene.js';
|
||||||
|
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
|
||||||
|
|
||||||
|
const data = await ConfigLoader.load();
|
||||||
|
config.init(data);
|
||||||
|
|
||||||
|
// Capture console errors + uncaught exceptions for the report.
|
||||||
|
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 gameConfig = createGameConfig();
|
||||||
|
gameConfig.scene = [MenuScene, GameScene, SurfaceScene];
|
||||||
|
const game = new Phaser.Game(gameConfig);
|
||||||
|
window.game = game;
|
||||||
|
|
||||||
|
const report = document.createElement('pre');
|
||||||
|
report.id = 'report';
|
||||||
|
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:72%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);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… (menu → new game → home world → surface)');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
async function waitFor(fn, tries = 200, everyMs = 100) {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
const v = fn();
|
||||||
|
if (v) return v;
|
||||||
|
await sleep(everyMs);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE = (location.hash || '#drawer').slice(1) || 'drawer';
|
||||||
|
const deckReport = (ss) =>
|
||||||
|
(ss.actionBar?.slots ?? []).filter((s) => s.live).map((s) => `${s.id}:${s.disabled ? 'off' : 'on'}`);
|
||||||
|
const slotLabel = (ss) => {
|
||||||
|
const b = (ss.exploreSubBar?.buttons ?? []).find((x) => x.id === 'jobBoard');
|
||||||
|
return b ? b.btn.labelText.text : '(absent — no drawer on the home world)';
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) The real menu flow: NEW GAME.
|
||||||
|
const menu = await waitFor(() => { const m = game.scene.getScene('MenuScene'); return m?.newGameBtn ? m : null; });
|
||||||
|
if (!menu) throw new Error('menu never booted');
|
||||||
|
menu.startNewGame();
|
||||||
|
const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship ? g : null; }, 200);
|
||||||
|
if (!gs) throw new Error('GameScene never booted');
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
// 2) Land on the home world the game's own way (the target is built
|
||||||
|
// WHILE the home flag still reads true — the HUD's "Home World"
|
||||||
|
// label comes from it).
|
||||||
|
const target = gs.commsTargetFor(gs.planet);
|
||||||
|
if (STAGE !== 'drawer') gs.isHomeSystem = false; // PROBE STAGING — the slot's visual on this world
|
||||||
|
gs.startLanding(target);
|
||||||
|
const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.jobBoardWindow ? c : null; }, 200);
|
||||||
|
if (!ss) throw new Error('surface never booted');
|
||||||
|
await sleep(700);
|
||||||
|
ss.startSurface(); // force the loop stage (don't wait the landing clip out)
|
||||||
|
const forcePlay = (v) => { if (v?.video) { v.video.muted = true; v.video.play().catch(() => {}); } };
|
||||||
|
forcePlay(ss.surfaceVideo);
|
||||||
|
await sleep(900);
|
||||||
|
|
||||||
|
const win = ss.jobBoardWindow;
|
||||||
|
|
||||||
|
if (STAGE === 'drawer') {
|
||||||
|
// The HOME world's EXPLORE — the slot is a no-op (no drawer: the
|
||||||
|
// Job Board's `isHomeSystem: false` clause leaves the item list
|
||||||
|
// empty, and a world with no items gets no drawer).
|
||||||
|
ss.toggleExplore();
|
||||||
|
await sleep(400); // the console note in + hold
|
||||||
|
window.__JB = {
|
||||||
|
stage: 'drawer',
|
||||||
|
home: ss.planetName,
|
||||||
|
isHomeSystem: gs.isHomeSystem === true,
|
||||||
|
drawerPresent: !!ss.exploreSubBar,
|
||||||
|
drawerItems: (ss.exploreSubBar?.buttons ?? []).map((b) => b.id),
|
||||||
|
noteG: ss.noteG ? ss.noteG.map((g) => g?.text) : null,
|
||||||
|
deck: deckReport(ss),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
setReport(JSON.stringify(window.__JB, null, 1));
|
||||||
|
} else {
|
||||||
|
// The slot's player path: the drawer's JOB BOARD press → the toggle
|
||||||
|
// (the drawer's onAction seam — the same call the button's press
|
||||||
|
// makes on every non-home world).
|
||||||
|
ss.toggleExplore();
|
||||||
|
await sleep(1100); // the drawer's unfold
|
||||||
|
ss.exploreAction('jobBoard');
|
||||||
|
await sleep(1900); // the board’s reveal + the title’s decode
|
||||||
|
// Headless autoplay policy: force the feed visible (muted).
|
||||||
|
if (win.video?.video) { win.video.video.muted = true; win.video.video.play().catch(() => {}); }
|
||||||
|
await sleep(700); // let a frame land
|
||||||
|
window.__JB = {
|
||||||
|
stage: 'board',
|
||||||
|
world: ss.planetName,
|
||||||
|
staged: 'isHomeSystem flipped for the slot’s visual (a real non-home world has it naturally)',
|
||||||
|
jobBoardMode: ss.jobBoardMode,
|
||||||
|
openState: win?.openState,
|
||||||
|
title: win?.rightTitle?.text,
|
||||||
|
titlePos: win?.rightTitle ? [Math.round(win.rightTitle.x), Math.round(win.rightTitle.y)] : null,
|
||||||
|
jobs: win?.jobsSection ? { a: +win.jobsSection.bg.alpha.toFixed(2), y: Math.round(win.jobsSection.bg.y), head: win.jobsSection.headTxt.text, ha: +win.jobsSection.headTxt.alpha.toFixed(2) } : null,
|
||||||
|
detail: win?.detailSection ? { a: +win.detailSection.bg.alpha.toFixed(2), y: Math.round(win.detailSection.bg.y), head: win.detailSection.headTxt.text, ha: +win.detailSection.headTxt.alpha.toFixed(2) } : null,
|
||||||
|
geo: win?.geo ? { bodyY: win.geo.bodyY, bodyBottom: win.geo.bodyBottom, rightX: win.geo.rightX, rightW: win.geo.rightW, availTop: win.geo.availTop, availH: win.geo.availH, detailPanelY: win.geo.detailPanelY, detailH: win.geo.detailH } : null,
|
||||||
|
video: win?.video ? { muted: win.video.video.muted, loop: win.video.loop, volume: win.video.volume } : null,
|
||||||
|
slotLabel: slotLabel(ss),
|
||||||
|
drawerItems: (ss.exploreSubBar?.buttons ?? []).map((b) => b.id),
|
||||||
|
deck: deckReport(ss),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
setReport(JSON.stringify(window.__JB, null, 1));
|
||||||
|
|
||||||
|
if (STAGE === 'left') {
|
||||||
|
// LEAVE — the slot's press again (the player's exact way out).
|
||||||
|
ss.exploreAction('jobBoard');
|
||||||
|
await sleep(900); // the close + the relabel back
|
||||||
|
window.__JB = {
|
||||||
|
stage: 'left',
|
||||||
|
world: ss.planetName,
|
||||||
|
jobBoardMode: ss.jobBoardMode,
|
||||||
|
openState: win?.openState,
|
||||||
|
slotLabel: slotLabel(ss),
|
||||||
|
deck: deckReport(ss),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
setReport(JSON.stringify(window.__JB, null, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.__JB = { stage: 'FAILED', error: String(err?.message ?? err), errors };
|
||||||
|
setReport(JSON.stringify(window.__JB, null, 1));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
// Headless test for the JOB BOARD console (js/ui/JobBoardWindow.js) — the
|
||||||
|
// build screen's structure (the Explore drawer's Job Board slot), driven
|
||||||
|
// against a stub scene, like dev/explore.test.mjs's MenuSubBar section:
|
||||||
|
//
|
||||||
|
// - CONFIG — data/actionbar.json → surface.explore: the jobBoard item
|
||||||
|
// (label / labelActive / the isHomeSystem:false clause) and the
|
||||||
|
// `video` block (assets/videos/jobboard.mp4, 2:3).
|
||||||
|
// - BUILD — the window constructs headless: the top bar (meta + ✕ +
|
||||||
|
// rail), the left feed (video from the scene cache, scanlines, sweep
|
||||||
|
// band, status strip), the right column (the JOB BOARD title + the
|
||||||
|
// AVAILABLE JOBS / JOB DETAILS placeholder sections).
|
||||||
|
// - GEOMETRY — the right column sits right of the feed; the body runs
|
||||||
|
// to the window's bottom padding (nearly full-screen, like the build
|
||||||
|
// screen — the window is depth 80, above the HUD (60) + deck (50) +
|
||||||
|
// drawer (60)).
|
||||||
|
// - OPEN/CLOSE — openState transitions (closed → open → closing →
|
||||||
|
// closed), the title decodes in (ScrambleDecode settles on 'JOB
|
||||||
|
// BOARD'), the close door's ✕ fires the onClose seam (the scene's
|
||||||
|
// full leave), update() ticks (cursor, sweep, glitch, REC pulse)
|
||||||
|
// without throwing, and destroy() tears the feed down.
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'dev';
|
||||||
|
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// --- Load the real config (data/*.json) into the config singleton ------
|
||||||
|
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const dataDir = join(__dirname, '../data');
|
||||||
|
const configData = {};
|
||||||
|
for (const f of fs.readdirSync(dataDir)) {
|
||||||
|
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||||
|
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||||
|
}
|
||||||
|
config.init(configData);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
console.log('== CONFIG — the drawer item + the feed block ==');
|
||||||
|
{
|
||||||
|
const items = config.get('actionbar.surface.explore.items');
|
||||||
|
const job = items.find((x) => x.id === 'jobBoard');
|
||||||
|
assert.ok(job, 'the explore drawer has a jobBoard item');
|
||||||
|
assert.equal(job.label, 'Job Board', 'the slot reads JOB BOARD (static — it does not relabel)');
|
||||||
|
assert.equal(job.labelActive, undefined, 'no labelActive — the console’s ✕ is the way-out (the slot’s own press is the second door)');
|
||||||
|
assert.equal(job.where.isHomeSystem, false, 'every system EXCEPT the home (safe space)');
|
||||||
|
const video = config.get('actionbar.surface.explore.video');
|
||||||
|
assert.equal(video.file, 'assets/videos/jobboard.mp4', 'the feed file');
|
||||||
|
assert.deepEqual(video.aspect, [2, 3], 'a 2:3 portrait (the build feed’s shape)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { JobBoardWindow } = await import(
|
||||||
|
pathToFileURL(join(__dirname, '../js/ui/JobBoardWindow.js')).href
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- The headless scene (dev/explore.test.mjs's stubObject recipe, plus
|
||||||
|
// --- the video + the few extra object methods the window reaches for).
|
||||||
|
function stubObject(extra = {}) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 60,
|
||||||
|
height: 18,
|
||||||
|
alpha: 1,
|
||||||
|
visible: true,
|
||||||
|
setOrigin() { return this; },
|
||||||
|
setVisible(v) { this.visible = v; return this; },
|
||||||
|
setAlpha(a) { this.alpha = a; return this; },
|
||||||
|
setScale() { return this; },
|
||||||
|
setTint() { return this; },
|
||||||
|
setBlendMode() { return this; },
|
||||||
|
setScrollFactor() { return this; },
|
||||||
|
setDepth() { return this; },
|
||||||
|
setPosition(x, y) { this.x = x; this.y = y; return this; },
|
||||||
|
setX(x) { this.x = x; return this; },
|
||||||
|
setY(y) { this.y = y; return this; },
|
||||||
|
setStrokeStyle() { return this; },
|
||||||
|
setSize(w, h) { this.width = w; this.height = h; return this; },
|
||||||
|
setDisplaySize(w, h) { this.displayWidth = w; this.displayHeight = h; return this; },
|
||||||
|
setInteractive() { return this; },
|
||||||
|
disableInteractive() { return this; },
|
||||||
|
on(ev, fn) { (this.__on ??= {})[ev] = fn; return this; },
|
||||||
|
once() { return this; },
|
||||||
|
off() { return this; },
|
||||||
|
// setText keeps .width fresh, like Phaser (the cursor tracks it).
|
||||||
|
setText(s) { this.text = String(s); this.width = Math.max(20, String(s).length * 7); return this; },
|
||||||
|
setColor(c) { this.color = c; return this; },
|
||||||
|
setFontSize() { return this; },
|
||||||
|
setFillStyle() { return this; },
|
||||||
|
fillStyle() { return this; },
|
||||||
|
lineStyle() { return this; },
|
||||||
|
fillPoints() { return this; },
|
||||||
|
strokePoints() { return this; },
|
||||||
|
fillRect() { return this; },
|
||||||
|
strokeRect() { return this; },
|
||||||
|
lineBetween() { return this; },
|
||||||
|
clear() { return this; },
|
||||||
|
destroy() { this.destroyed = true; },
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScene() {
|
||||||
|
const video = stubObject({ key: JobBoardWindow.VIDEO_KEY, volume: 1, loop: false });
|
||||||
|
video.setVolume = (v) => { video.volume = v; return video; };
|
||||||
|
video.setLoop = (b) => { video.loop = b; return video; };
|
||||||
|
video.video = { muted: false, readyState: 0, videoWidth: 544, videoHeight: 800, width: 544, height: 800 };
|
||||||
|
video.stop = () => {};
|
||||||
|
return {
|
||||||
|
scale: { width: 1280, height: 720 },
|
||||||
|
add: {
|
||||||
|
existing: (o) => o,
|
||||||
|
text: (x, y, str) => stubObject({ x, y, text: String(str), width: Math.max(20, String(str).length * 7) }),
|
||||||
|
image: (x, y, key) => stubObject({ x, y, key }),
|
||||||
|
graphics: () => stubObject({}),
|
||||||
|
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||||
|
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||||
|
circle: (x, y, r) => stubObject({ x, y, radius: r }),
|
||||||
|
video: (x, y, key) => { video.x = x; video.y = y; video.key = key; return video; },
|
||||||
|
},
|
||||||
|
cache: { video: { has: (key) => key === JobBoardWindow.VIDEO_KEY } },
|
||||||
|
tweens: {
|
||||||
|
add: (t) => {
|
||||||
|
if (typeof t?.onComplete === 'function') t.onComplete();
|
||||||
|
return { destroy() {}, stop() {} };
|
||||||
|
},
|
||||||
|
killTweensOf() {},
|
||||||
|
},
|
||||||
|
time: { now: 1000, delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; } },
|
||||||
|
textures: { exists: () => true, addCanvas() {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
console.log('== BUILD — the console constructs (top bar, feed, right column) ==');
|
||||||
|
{
|
||||||
|
const scene = makeScene();
|
||||||
|
const closed = [];
|
||||||
|
const win = new JobBoardWindow(scene, { planetName: 'Klaxoria', onClose: () => closed.push(1) });
|
||||||
|
|
||||||
|
assert.equal(win.openState, 'closed', 'starts closed');
|
||||||
|
assert.equal(win.isOpen, false, '…and not open');
|
||||||
|
assert.equal(win.depth, 80, 'depth 80 — the build screen’s: above the HUD (60) + deck (50) + drawer (60)');
|
||||||
|
|
||||||
|
// top bar: the meta line (the world’s name) + the ✕ plate + the rail.
|
||||||
|
assert.match(win.topMeta.text, /KLAXORIA/);
|
||||||
|
assert.match(win.topMeta.text, /SURFACE BOARD/);
|
||||||
|
assert.equal(win.closeTxt.text, '✕');
|
||||||
|
|
||||||
|
// the left feed: the video (muted, looping), the scanlines + sweep band,
|
||||||
|
// the status strip.
|
||||||
|
assert.ok(win.video, 'the feed video is created from the scene cache');
|
||||||
|
assert.equal(win.video.volume, 0, 'muted (the console’s requirement)');
|
||||||
|
assert.equal(win.video.loop, true, 'looping');
|
||||||
|
assert.equal(win.video.video.muted, true, '…and the native element is muted');
|
||||||
|
assert.ok(win.scan, 'scanlines over the feed');
|
||||||
|
assert.ok(win.sweepBand, 'the sweep band');
|
||||||
|
assert.match(win.statusTxt.text, /NO CONTRACTS ON FILE/);
|
||||||
|
|
||||||
|
// the right column: the title (decoded on open) + the two placeholder
|
||||||
|
// sections (the postings + the contract).
|
||||||
|
assert.equal(win.rightTitle.text, '', 'the title decodes in (empty until open)');
|
||||||
|
assert.equal(win.jobsSection.headTxt.text, 'AVAILABLE JOBS');
|
||||||
|
assert.match(win.jobsSection.mainTxt.text, /NO CONTRACTS ON FILE/);
|
||||||
|
assert.equal(win.detailSection.headTxt.text, 'JOB DETAILS');
|
||||||
|
assert.match(win.detailSection.mainTxt.text, /SELECT A JOB/);
|
||||||
|
|
||||||
|
// the section objects are CHILDREN of the window (which carries the
|
||||||
|
// depth-10 layer) — a scene-level object at depth 0 would hide behind
|
||||||
|
// the window's own opaque backdrop (caught in the headless render).
|
||||||
|
for (const k of ['headTxt', 'bg', 'mainTxt', 'subTxt']) {
|
||||||
|
assert.ok(win.children.includes(win.jobsSection[k]), `jobsSection.${k} is a window child (depth 10, not scene-level depth 0)`);
|
||||||
|
assert.ok(win.children.includes(win.detailSection[k]), `detailSection.${k} is a window child (depth 10, not scene-level depth 0)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GEOMETRY: the right column is right of the feed, and the body stays
|
||||||
|
// above the deck + drawer band (the way-out lives there).
|
||||||
|
const g = win.geo;
|
||||||
|
assert.ok(g.rightX > g.leftX + g.leftW, 'the right column sits right of the feed');
|
||||||
|
assert.equal(g.bodyBottom, 720 - 10 - 14, 'the body runs to the window’s bottom padding — nearly full-screen, like the build screen');
|
||||||
|
assert.ok(g.detailPanelY + g.detailH <= g.bodyBottom + 1, 'the details panel fits the body');
|
||||||
|
assert.ok(g.availH >= 80, 'the postings section has room to grow into');
|
||||||
|
|
||||||
|
// OPEN: closed → open; the title decodes in and settles on 'JOB BOARD'.
|
||||||
|
win.open();
|
||||||
|
assert.ok(win.isOpen, 'open() counts as open (the guards hold)');
|
||||||
|
assert.equal(win.video.volume, 0, 'still muted after open');
|
||||||
|
for (let t = 1000; t <= 4000; t += 100) win.update(t);
|
||||||
|
assert.equal(win.rightTitle.text, 'JOB BOARD', 'the title has settled on JOB BOARD');
|
||||||
|
|
||||||
|
// the ambient life ticks without throwing (cursor, sweep, REC pulse).
|
||||||
|
for (let t = 4000; t <= 20000; t += 500) win.update(t);
|
||||||
|
|
||||||
|
// CLOSE: the ✕ door fires the scene seam (the full leave), then the
|
||||||
|
// close lands (the stub tween completes inline).
|
||||||
|
win.closeG.__on.pointerdown();
|
||||||
|
assert.equal(closed.length, 1, 'the ✕ fired the onClose seam (the scene’s full leave)');
|
||||||
|
win.close();
|
||||||
|
assert.equal(win.openState, 'closed', 'the close animation lands on closed');
|
||||||
|
assert.equal(win.isOpen, false, '…and it no longer counts as open');
|
||||||
|
|
||||||
|
// refresh() is the repaint seam — a no-op for now, no throw.
|
||||||
|
win.refresh();
|
||||||
|
|
||||||
|
// destroy() tears the feed down (no crash; the video is gone).
|
||||||
|
win.destroy();
|
||||||
|
assert.ok(!win.video, 'the feed video is released');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll Job Board console checks passed ✔');
|
||||||
|
|
@ -14,8 +14,9 @@ class GameObject {
|
||||||
add(obj) { this.children.push(obj); return this; }
|
add(obj) { this.children.push(obj); return this; }
|
||||||
remove(obj) { this.children = this.children.filter((c) => c !== obj); return this; }
|
remove(obj) { this.children = this.children.filter((c) => c !== obj); return this; }
|
||||||
setOrigin() { return this; }
|
setOrigin() { return this; }
|
||||||
|
setVisible(v) { this.visible = v; return this; }
|
||||||
setScrollFactor() { return this; }
|
setScrollFactor() { return this; }
|
||||||
setDepth() { return this; }
|
setDepth(d) { this.depth = d; return this; }
|
||||||
setAlpha(a) { this.alpha = a; return this; }
|
setAlpha(a) { this.alpha = a; return this; }
|
||||||
setScale(v) { this.scale = v; return this; }
|
setScale(v) { this.scale = v; return this; }
|
||||||
setTint(t) { this.tint = t; return this; }
|
setTint(t) { this.tint = t; return this; }
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 408 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
|
|
@ -77,6 +77,7 @@ import {
|
||||||
applyActivation,
|
applyActivation,
|
||||||
} from '../research/SystemCategory.js';
|
} from '../research/SystemCategory.js';
|
||||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||||
|
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
|
||||||
import { BuildState } from '../build/BuildState.js';
|
import { BuildState } from '../build/BuildState.js';
|
||||||
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
|
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
|
||||||
|
|
||||||
|
|
@ -283,6 +284,21 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The job board's feed (data/actionbar.json → surface.explore.video):
|
||||||
|
// a muted 2:3 loop behind the JOB BOARD console on a surface
|
||||||
|
// (SurfaceScene's JobBoardWindow reads the shared cache). A missing
|
||||||
|
// file just leaves the window's NO SIGNAL plate up — the board still
|
||||||
|
// works.
|
||||||
|
{
|
||||||
|
const jobVideo = String(config.get('actionbar.surface.explore.video.file') ?? '');
|
||||||
|
if (jobVideo) {
|
||||||
|
const url = /^(https?:)?\/\//.test(jobVideo) || jobVideo.startsWith('assets/')
|
||||||
|
? jobVideo
|
||||||
|
: `assets/videos/${jobVideo}`;
|
||||||
|
this.load.video(JobBoardWindow.VIDEO_KEY, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The jump clip (data/gates.json → jump.video): a full-screen one-shot
|
// The jump clip (data/gates.json → jump.video): a full-screen one-shot
|
||||||
// played between the two systems on a gate jump (jumpThroughGate →
|
// played between the two systems on a gate jump (jumpThroughGate →
|
||||||
// _playJumpClip). A missing/empty file just leaves the short-cut
|
// _playJumpClip). A missing/empty file just leaves the short-cut
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { resolveItems, worldContext } from '../ui/ExploreBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.js';
|
import { SavePanel } from '../ui/SavePanel.js';
|
||||||
import { SaveManager } from '../save/SaveManager.js';
|
import { SaveManager } from '../save/SaveManager.js';
|
||||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||||
|
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
|
||||||
import { QuestWindow } from '../ui/QuestWindow.js';
|
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||||
import { ConfirmOverlay } from '../ui/ConfirmOverlay.js';
|
import { ConfirmOverlay } from '../ui/ConfirmOverlay.js';
|
||||||
import { planRoute } from '../galaxy/Route.js';
|
import { planRoute } from '../galaxy/Route.js';
|
||||||
|
|
@ -123,6 +124,8 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair
|
this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair
|
||||||
this.noteG = null; // console-note glyphs (consoleNote)
|
this.noteG = null; // console-note glyphs (consoleNote)
|
||||||
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
|
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
|
||||||
|
this.jobBoardWindow = null; // the JOB BOARD console (Explore drawer's Job Board slot)
|
||||||
|
this.jobBoardMode = false; // true while the job board console is up (the JOB BOARD press or the ✕ toggles it)
|
||||||
this._setDestDialog = null; // the shop's "SET DESTINATION" confirm (ConfirmOverlay, container-hosted)
|
this._setDestDialog = null; // the shop's "SET DESTINATION" confirm (ConfirmOverlay, container-hosted)
|
||||||
this._setDestHost = null; // the centered container hosting that dialog (SavePanel pattern)
|
this._setDestHost = null; // the centered container hosting that dialog (SavePanel pattern)
|
||||||
this._setDest = null; // the pending { exit, hops } behind that dialog
|
this._setDest = null; // the pending { exit, hops } behind that dialog
|
||||||
|
|
@ -510,17 +513,33 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
* untouched in both directions (it's the toggle that owns the lock). Idempotent — safe on re-entry.
|
* untouched in both directions (it's the toggle that owns the lock). Idempotent — safe on re-entry.
|
||||||
*/
|
*/
|
||||||
setBarLocked(locked) {
|
setBarLocked(locked) {
|
||||||
|
this.setServiceLocked(locked, 'bar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SURFACE LOCK — while a service point owns the surface (the bar clip,
|
||||||
|
* the job board console), EVERY button on the primary deck (all six
|
||||||
|
* live slots — the way-out is not on the deck) and every item on the
|
||||||
|
* secondary drawer EXCEPT the service's slot go standby (dim, inert —
|
||||||
|
* no hover, no press — the deck's per-slot language,
|
||||||
|
* ActionBar.setSlotEnabled; the drawer's, MenuSubBar.setDisabled).
|
||||||
|
* `escapeId` names the way-out slot (the bar's 'bar', the board's
|
||||||
|
* 'jobBoard') — it stays live. The shop slot re-arms with the rest (its
|
||||||
|
* TRADE gate — GameScene.isTradeUnlocked — lives on the shop's Buy/Sell
|
||||||
|
* tab, not on this slot). Idempotent — safe on re-entry.
|
||||||
|
*/
|
||||||
|
setServiceLocked(locked, escapeId) {
|
||||||
const deck = this.actionBar;
|
const deck = this.actionBar;
|
||||||
if (deck && Array.isArray(deck.slots)) {
|
if (deck && Array.isArray(deck.slots)) {
|
||||||
for (const s of deck.slots) {
|
for (const s of deck.slots) {
|
||||||
if (!s.live) continue;
|
if (!s.live) continue;
|
||||||
deck.setSlotEnabled(s.id, !locked); // the shop slot re-arms with the rest — its trade gate lives on the shop's Buy/Sell tab, not here
|
deck.setSlotEnabled(s.id, !locked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const drawer = this.exploreSubBar;
|
const drawer = this.exploreSubBar;
|
||||||
if (drawer && Array.isArray(drawer.buttons)) {
|
if (drawer && Array.isArray(drawer.buttons)) {
|
||||||
for (const b of drawer.buttons) {
|
for (const b of drawer.buttons) {
|
||||||
if (b.id === 'bar') continue; // BAR/LEAVE BAR is the way out
|
if (b.id === escapeId) continue; // the way-out slot stays live
|
||||||
drawer.setDisabled(b.id, locked);
|
drawer.setDisabled(b.id, locked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -539,6 +558,56 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
return inBar ? String(active) : String(base);
|
return inBar ? String(active) : String(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Stage 2c — the job board (EXPLORE → Job Board opens the console, and back)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JOB BOARD — the drawer's Job Board slot (on every world EXCEPT the
|
||||||
|
* player's home system — safe space): open the board's console (the
|
||||||
|
* build screen's structure — js/ui/JobBoardWindow.js, nearly
|
||||||
|
* full-screen, over the deck + drawer + the upper-left HUD); the next
|
||||||
|
* press — or the console's ✕ — closes it. While it's up the deck +
|
||||||
|
* drawer stand down (the slot's own press stays live as the second
|
||||||
|
* door — setServiceLocked). The surface clip pauses under the console
|
||||||
|
* and resumes where it left off (v4 quirk — resume(), not play();
|
||||||
|
* see leaveJobBoard).
|
||||||
|
*/
|
||||||
|
toggleJobBoard() {
|
||||||
|
if (this.phase !== 'surface') return; // no deck, no drawer, no board
|
||||||
|
if (this.jobBoardMode) {
|
||||||
|
this.leaveJobBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.vignetteHub?.busy) {
|
||||||
|
// A scene is pending or open (the shop's) — the board's entry waits
|
||||||
|
// for its DONE (one location at a time).
|
||||||
|
this.consoleNote('ONE THING AT A TIME');
|
||||||
|
this.playSfx('ui_close');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.jobBoardWindow?.isOpen) {
|
||||||
|
this.leaveJobBoard(); // out of sync (e.g. a close in flight) — land it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.jobBoardMode = true;
|
||||||
|
this.surfaceVideo?.pause(); // the console covers the loop (resume() brings it back)
|
||||||
|
this.jobBoardWindow?.open();
|
||||||
|
this.setServiceLocked(true, 'jobBoard'); // the board is up — every OTHER button stands down
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The second press (JOB BOARD) — or the console's ✕ — back to the looping surface clip. */
|
||||||
|
leaveJobBoard() {
|
||||||
|
if (!this.jobBoardMode) return;
|
||||||
|
this.jobBoardMode = false;
|
||||||
|
this.jobBoardWindow?.close();
|
||||||
|
this.setServiceLocked(false, 'jobBoard'); // the board is down — the deck + drawer come back
|
||||||
|
// 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 surface clip would stay frozen after board → back.
|
||||||
|
this.surfaceVideo?.resume();
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Stage 2d — the planet's VIGNETTES (data/vignettes.json — the
|
// Stage 2d — the planet's VIGNETTES (data/vignettes.json — the
|
||||||
// dialogue scenes that play on the surface: the window over the
|
// dialogue scenes that play on the surface: the window over the
|
||||||
|
|
@ -1163,6 +1232,21 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// The JOB BOARD console (the Explore drawer's Job Board slot — on
|
||||||
|
// every world EXCEPT the player's home system — safe space): the
|
||||||
|
// build screen's structure (js/ui/JobBoardWindow.js) on the surface
|
||||||
|
// clip's depth layer (10 — the bar clip's), so the deck (50) + drawer
|
||||||
|
// (60) stay live on top — the drawer's slot is the way-out (JOB BOARD
|
||||||
|
// ↔ LEAVE; toggleJobBoard owns the relabel + lock). The left feed is
|
||||||
|
// assets/videos/jobboard.mp4 (GameScene's preload, shared cache, muted
|
||||||
|
// loop). The AVAILABLE JOBS + JOB DETAILS sections stand as
|
||||||
|
// placeholders for now — the postings + the selection land later
|
||||||
|
// (JobBoardWindow.refresh is the repaint seam).
|
||||||
|
this.jobBoardWindow = new JobBoardWindow(this, {
|
||||||
|
planetName: this.planetName,
|
||||||
|
onClose: () => this.leaveJobBoard(),
|
||||||
|
});
|
||||||
|
|
||||||
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
||||||
// SAME window as the flight deck's (js/ui/QuestWindow.js), opened
|
// SAME window as the flight deck's (js/ui/QuestWindow.js), opened
|
||||||
// over the SLEEPING GameScene's dossier — the same split as the build
|
// over the SLEEPING GameScene's dossier — the same split as the build
|
||||||
|
|
@ -1306,6 +1390,17 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
this.toggleBar();
|
this.toggleBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.jobBoardMode && id !== 'jobBoard') {
|
||||||
|
// Belt-and-braces — the board's lock already stands the other drawer
|
||||||
|
// items down (their clicks never reach here).
|
||||||
|
this.consoleNote('LEAVE THE JOB BOARD FIRST');
|
||||||
|
this.playSfx('ui_close');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id === 'jobBoard') {
|
||||||
|
this.toggleJobBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.info(`[orbit] surface explore: ${id} — ${this.planetName}`);
|
console.info(`[orbit] surface explore: ${id} — ${this.planetName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1579,6 +1674,7 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
if (this.exploreSubBar?.isOpen) {
|
if (this.exploreSubBar?.isOpen) {
|
||||||
if (this.exploreSubBar.contains(pointer.x, pointer.y)) return;
|
if (this.exploreSubBar.contains(pointer.x, pointer.y)) return;
|
||||||
if (this.barMode) return; // the bar holds the drawer up — LEAVE BAR is the way out
|
if (this.barMode) return; // the bar holds the drawer up — LEAVE BAR is the way out
|
||||||
|
if (this.jobBoardMode) return; // the board holds the drawer up — the ✕ (or the JOB BOARD press) is the way out
|
||||||
this.exploreSubBar.close();
|
this.exploreSubBar.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1715,6 +1811,7 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.buildWindow?.update(time);
|
this.buildWindow?.update(time);
|
||||||
|
this.jobBoardWindow?.update(time);
|
||||||
this.questWindow?.update(time);
|
this.questWindow?.update(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1745,6 +1842,9 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
this._setDest = null;
|
this._setDest = null;
|
||||||
this.buildWindow?.destroy();
|
this.buildWindow?.destroy();
|
||||||
this.buildWindow = null;
|
this.buildWindow = null;
|
||||||
|
this.jobBoardWindow?.destroy();
|
||||||
|
this.jobBoardWindow = null;
|
||||||
|
this.jobBoardMode = false;
|
||||||
this.vignetteHub?.destroy(); // the scene + its pending beat die with the surface
|
this.vignetteHub?.destroy(); // the scene + its pending beat die with the surface
|
||||||
this.vignetteHub = null;
|
this.vignetteHub = null;
|
||||||
this.vignetteLocation = null;
|
this.vignetteLocation = null;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,788 @@
|
||||||
|
/**
|
||||||
|
* JobBoardWindow — the full-screen JOB BOARD console (the Explore
|
||||||
|
* drawer's Job Board slot, on every world EXCEPT the player's home
|
||||||
|
* system — safe space). Structurally the build console's (the deck's
|
||||||
|
* BUILD button — js/ui/BuildWindow.js): same cut-corner frame + title
|
||||||
|
* rail + ✕, the same boot reveal / decode / glitch language, and the
|
||||||
|
* same left feed + right content split.
|
||||||
|
*
|
||||||
|
* ┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ J O B B O A R D <WORLD> · BOARD [✕] │
|
||||||
|
* ├───────────────────────┬──────────────────────────────────────────────┤
|
||||||
|
* │ ● BOARD FEED // LIVE │ AVAILABLE JOBS │
|
||||||
|
* │ ┌─────────────────┐ │ ┌────────────────────────────────────────┐ │
|
||||||
|
* │ │ looping video │ │ │ NO CONTRACTS ON FILE — the postings │ │
|
||||||
|
* │ │ (muted, 2:3) │ │ │ list lands here (later) │ │
|
||||||
|
* │ │ + scanlines │ │ └────────────────────────────────────────┘ │
|
||||||
|
* │ │ + sweep band │ │ JOB DETAILS │
|
||||||
|
* │ └─────────────────┘ │ ┌────────────────────────────────────────┐ │
|
||||||
|
* │ BOARD STANDBY — … │ │ SELECT A JOB — the contract readout │ │
|
||||||
|
* │ │ │ lands here (later) │ │
|
||||||
|
* │ │ └────────────────────────────────────────┘ │
|
||||||
|
* └───────────────────────┴──────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Title: the JOB BOARD name in the top bar's upper left — the build
|
||||||
|
* console's titleTxt spot (the meta line + ✕ sit at the right). Left:
|
||||||
|
* the board's feed — assets/videos/jobboard.mp4, a 2:3 portrait that
|
||||||
|
* LOOPS MUTED while the console is up (the build feed's twin;
|
||||||
|
* data/actionbar.json → surface.explore.video). Right: the AVAILABLE
|
||||||
|
* JOBS section (the board's postings — flexing from the body's top
|
||||||
|
* edge) and the JOB DETAILS section (the selected job's contract) —
|
||||||
|
* both PLACEHOLDERS for now; the postings + the selection land later
|
||||||
|
* (refresh() is the repaint seam).
|
||||||
|
*
|
||||||
|
* Depth 80 — the build screen's: nearly full-screen, ABOVE the deck
|
||||||
|
* (50) + drawer (60) + the upper-left surface HUD (60), so it covers
|
||||||
|
* the planet text as well (the build console's shape). The top bar's
|
||||||
|
* text sits at the right (the build's titleMeta corner).
|
||||||
|
*
|
||||||
|
* The scene owns the toggle: it calls open()/close() and stands the
|
||||||
|
* deck + drawer down (SurfaceScene.setServiceLocked — the slot's own
|
||||||
|
* press stays live as the second door); the ✕ calls back through the
|
||||||
|
* onClose seam so the unlock can't be skipped.
|
||||||
|
*/
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toCss } from '../utils/Color.js';
|
||||||
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
|
import { ScrambleDecode } from '../utils/Decode.js';
|
||||||
|
import { playSfxOn } from '../utils/Sfx.js';
|
||||||
|
import { CyberShape } from './CyberShape.js';
|
||||||
|
|
||||||
|
const HEADER = fontStack('header');
|
||||||
|
const BODY = fontStack('body');
|
||||||
|
const rand = (a, b) => a + Math.random() * (b - a);
|
||||||
|
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
||||||
|
const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2);
|
||||||
|
|
||||||
|
/** Theme palette (data/theme.json), resolved once at build time. */
|
||||||
|
const C = {
|
||||||
|
ink: themeColor('ink', 0xeaf6ff),
|
||||||
|
dim: themeColor('dim', 0x7d92c4),
|
||||||
|
faint: themeColor('faint', 0x3d4c74),
|
||||||
|
neon: themeColor('neon', 0x00e5ff),
|
||||||
|
neon2: themeColor('neon2', 0xff2d6f),
|
||||||
|
amber: themeColor('amber', 0xffc94d),
|
||||||
|
panel: themeColor('panel', 0x0a1120),
|
||||||
|
bg: themeColor('bg', 0x04060d),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a cut-corner plate with its top-left at (x, y) into an existing
|
||||||
|
* Graphics (CyberShape.points is centred on (0,0) — translate by the
|
||||||
|
* plate centre). Mirrors BuildWindow.panel() (the build console's
|
||||||
|
* recipe, including the wide, low-alpha `glow` stroke pass).
|
||||||
|
*/
|
||||||
|
function panel(g, x, y, w, h, o = {}) {
|
||||||
|
const cx = x + w / 2;
|
||||||
|
const cy = y + h / 2;
|
||||||
|
const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map(
|
||||||
|
(p) => ({ x: p.x + cx, y: p.y + cy })
|
||||||
|
);
|
||||||
|
if (o.fill !== undefined) {
|
||||||
|
g.fillStyle(o.fill, o.fillAlpha ?? 1);
|
||||||
|
g.fillPoints(pts, true);
|
||||||
|
}
|
||||||
|
if (o.stroke !== undefined) {
|
||||||
|
g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
|
||||||
|
g.strokePoints(pts, true);
|
||||||
|
}
|
||||||
|
if (o.glow !== undefined) {
|
||||||
|
g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
|
||||||
|
g.strokePoints(pts, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corner brackets framing a rect (x, y = top-left). */
|
||||||
|
function brackets(g, x, y, w, h, o = {}) {
|
||||||
|
const color = o.color ?? C.neon;
|
||||||
|
const alpha = o.alpha ?? 0.7;
|
||||||
|
const len = o.length ?? 14;
|
||||||
|
g.lineStyle(o.lineWidth ?? 2, color, alpha);
|
||||||
|
const x2 = x + w;
|
||||||
|
const y2 = y + h;
|
||||||
|
g.lineBetween(x, y, x + len, y);
|
||||||
|
g.lineBetween(x, y, x, y + len);
|
||||||
|
g.lineBetween(x2, y, x2 - len, y);
|
||||||
|
g.lineBetween(x2, y, x2, y + len);
|
||||||
|
g.lineBetween(x, y2, x + len, y2);
|
||||||
|
g.lineBetween(x, y2, x, y2 - len);
|
||||||
|
g.lineBetween(x2, y2, x2 - len, y2);
|
||||||
|
g.lineBetween(x2, y2, x2, y2 - len);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobBoardWindow extends Phaser.GameObjects.Container {
|
||||||
|
static VIDEO_KEY = 'jobboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Phaser.Scene} scene the owning scene (SurfaceScene)
|
||||||
|
* @param {object} o seams:
|
||||||
|
* { planetName: string, // the world's proper name (top-bar meta)
|
||||||
|
* onClose: () => void } // the ✕ door (the scene's full leave: relabel + unlock)
|
||||||
|
*/
|
||||||
|
constructor(scene, o = {}) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
// v4 quirk: a directly-constructed GameObject must register itself.
|
||||||
|
this.scene.add.existing(this);
|
||||||
|
this.setScrollFactor(0); // UI — pinned to the screen
|
||||||
|
// The build screen's depth — nearly full-screen, ABOVE the deck (50)
|
||||||
|
// + drawer (60) + the upper-left surface HUD (60): the board covers
|
||||||
|
// the planet text, and the ✕ (or the JOB BOARD slot's press) is the
|
||||||
|
// way-out (see the module note above).
|
||||||
|
this.setDepth(80);
|
||||||
|
|
||||||
|
this.planetName = String(o.planetName ?? '').trim();
|
||||||
|
this.onClose = typeof o.onClose === 'function' ? o.onClose : null;
|
||||||
|
|
||||||
|
this.openState = 'closed'; // closed | opening | open | closing
|
||||||
|
this.booted = false;
|
||||||
|
this.reveal = []; // { o, d, dur, mode, baseY, t0 }
|
||||||
|
this.decodes = []; // { txt, dec } — polled ScrambleDecodes
|
||||||
|
this.glitch = { until: 0, next: 0, level: 0.8 };
|
||||||
|
this.sweep = { t0: 0, next: 0 };
|
||||||
|
|
||||||
|
this._build();
|
||||||
|
this.setVisible(true);
|
||||||
|
this.setAlpha(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ helpers
|
||||||
|
hasVideo(key) {
|
||||||
|
const c = this.scene.cache?.video;
|
||||||
|
return !!(c && typeof c.has === 'function' && c.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyVideo(v) {
|
||||||
|
if (!v) return;
|
||||||
|
try {
|
||||||
|
v.off();
|
||||||
|
v.stop(false);
|
||||||
|
v.destroy();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode text into `txt` starting at `t0` (driven by update()). */
|
||||||
|
decodeTo(txt, str, t0, dur = 520) {
|
||||||
|
this.decodes = this.decodes.filter((d) => d.txt !== txt);
|
||||||
|
if (txt.text === str) return;
|
||||||
|
txt.setText('');
|
||||||
|
this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) });
|
||||||
|
}
|
||||||
|
|
||||||
|
sfx(name) {
|
||||||
|
playSfxOn(this.scene, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ geometry
|
||||||
|
_measure() {
|
||||||
|
const W = this.scene.scale.width;
|
||||||
|
const H = this.scene.scale.height;
|
||||||
|
const m = 10;
|
||||||
|
const pad = 14;
|
||||||
|
const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m };
|
||||||
|
const titleH = 46;
|
||||||
|
// Nearly full-screen — the build screen's shape (its body runs to
|
||||||
|
// the window's bottom padding; the deck + drawer + HUD are covered).
|
||||||
|
const bodyY = rect.y + titleH + pad;
|
||||||
|
const bodyBottom = rect.y + rect.h - pad;
|
||||||
|
const bodyH = bodyBottom - bodyY;
|
||||||
|
const a = config.get('actionbar.surface.explore.video.aspect', [2, 3]);
|
||||||
|
const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3;
|
||||||
|
const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8);
|
||||||
|
const videoW = Math.max(90, videoH * ar);
|
||||||
|
const leftX = rect.x + pad;
|
||||||
|
const leftW = videoW + 30;
|
||||||
|
const rightX = leftX + leftW + pad;
|
||||||
|
const rightW = Math.max(120, rect.x + rect.w - pad - rightX);
|
||||||
|
// right column: the two placeholder sections (the build console's
|
||||||
|
// list-over-detail shape) — the title now lives in the top bar
|
||||||
|
// (the build's titleTxt spot), so the column starts at the body's
|
||||||
|
// top edge and the postings panel gets the whole flex room:
|
||||||
|
// AVAILABLE JOBS (the postings — flex) over JOB DETAILS (fixed).
|
||||||
|
const headH = 12; // a section's header line (10 px font)
|
||||||
|
const headGap = 10; // header → panel
|
||||||
|
const secGap = 16; // available panel → details header
|
||||||
|
const detailH = 150; // the details panel (the build's detail height)
|
||||||
|
const availHeadY = bodyY + 8; // the AVAILABLE JOBS header (just under the top bar)
|
||||||
|
const availTop = availHeadY + headH + headGap;
|
||||||
|
const detailPanelY = bodyBottom - detailH;
|
||||||
|
const detailHeadY = detailPanelY - headGap - headH; // the JOB DETAILS header baseline
|
||||||
|
const availH = Math.max(
|
||||||
|
80,
|
||||||
|
detailHeadY - headGap - headH - secGap - availTop,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
W, H, rect, titleH, pad,
|
||||||
|
bodyY, bodyBottom, bodyH,
|
||||||
|
videoH, videoW, leftX, leftW,
|
||||||
|
rightX, rightW,
|
||||||
|
availHeadY, availTop, availH,
|
||||||
|
detailHeadY, detailPanelY, detailH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ build
|
||||||
|
_build() {
|
||||||
|
const s = this.scene.add;
|
||||||
|
const G = (d = 0) => {
|
||||||
|
const g = s.graphics().setScrollFactor(0).setDepth(d);
|
||||||
|
this.add(g);
|
||||||
|
return g;
|
||||||
|
};
|
||||||
|
const T = (x, y, str, style, d = 0) => {
|
||||||
|
const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d);
|
||||||
|
this.add(t);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.geo = this._measure();
|
||||||
|
const { rect, titleH } = this.geo;
|
||||||
|
|
||||||
|
// ── window body ─────────────────────────────────────────────────
|
||||||
|
const bg = G(0);
|
||||||
|
bg.clear();
|
||||||
|
panel(bg, rect.x, rect.y, rect.w, rect.h, {
|
||||||
|
notch: 20,
|
||||||
|
fill: C.bg,
|
||||||
|
fillAlpha: 1, // fully opaque — the upper-left HUD sits UNDER this window (depth 80 > 60) and must not ghost through
|
||||||
|
stroke: 0x0e5f86,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
});
|
||||||
|
bg.fillStyle(C.neon, 0.16);
|
||||||
|
bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5);
|
||||||
|
this.bgG = bg;
|
||||||
|
|
||||||
|
// ── top bar — the meta line + ✕ at the RIGHT (the build console's
|
||||||
|
// titleMeta corner; the window now covers the upper-left HUD), the rail
|
||||||
|
const meta = String(this.planetName || 'SURFACE').toUpperCase();
|
||||||
|
this.topMeta = T(0, 0, `${meta} · SURFACE BOARD`, {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
letterSpacing: 2.5,
|
||||||
|
}, 1);
|
||||||
|
this.topMeta.setOrigin(1, 0);
|
||||||
|
this.topMeta.setPosition(rect.x + rect.w - 66, rect.y + 15);
|
||||||
|
this.titleRailG = G(1);
|
||||||
|
this.titleRailG.clear();
|
||||||
|
this.titleRailG.lineStyle(1, 0x14324f, 0.8);
|
||||||
|
this.titleRailG.lineBetween(rect.x + 16, rect.y + titleH - 1, rect.x + rect.w - 16, rect.y + titleH - 1);
|
||||||
|
this.titleRailG.fillStyle(C.neon, 0.9);
|
||||||
|
this.titleRailG.fillRect(rect.x + 16, rect.y + titleH - 2, 90, 2);
|
||||||
|
|
||||||
|
// close button (top right)
|
||||||
|
const cw = 30;
|
||||||
|
const cx = rect.x + rect.w - 18 - cw / 2;
|
||||||
|
const cy = rect.y + titleH / 2 - 2;
|
||||||
|
this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false };
|
||||||
|
this.closeG = G(2);
|
||||||
|
this.closeTxt = T(cx, cy + 1, '✕', {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '15px',
|
||||||
|
color: toCss(C.neon2),
|
||||||
|
align: 'center',
|
||||||
|
}, 2);
|
||||||
|
// Text's default origin is (0,0) — without this the ✕ anchors its
|
||||||
|
// top-left at the plate centre and sits right-of-centre.
|
||||||
|
this.closeTxt.setOrigin(0.5, 0.5);
|
||||||
|
const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw);
|
||||||
|
this.closeG.setInteractive({
|
||||||
|
useHandCursor: true,
|
||||||
|
hitArea: closeRect,
|
||||||
|
hitAreaCallback: (area, px, py) => area.contains(px, py),
|
||||||
|
});
|
||||||
|
this.closeG.on('pointerover', () => {
|
||||||
|
this.closeBtn.hover = true;
|
||||||
|
this._paintClose();
|
||||||
|
});
|
||||||
|
this.closeG.on('pointerout', () => {
|
||||||
|
this.closeBtn.hover = false;
|
||||||
|
this._paintClose();
|
||||||
|
});
|
||||||
|
this.closeG.on('pointerdown', () => {
|
||||||
|
this.sfx('ui_click');
|
||||||
|
// The scene's full leave (relabel the drawer's slot back to JOB
|
||||||
|
// BOARD + stand the deck back up) — close() alone would skip it.
|
||||||
|
if (this.onClose) this.onClose();
|
||||||
|
else this.close();
|
||||||
|
});
|
||||||
|
this._paintClose();
|
||||||
|
|
||||||
|
// glitch layers (top of the window)
|
||||||
|
this.glitchG = G(10);
|
||||||
|
this.titleGhostA = this._ghost();
|
||||||
|
this.titleGhostB = this._ghost();
|
||||||
|
|
||||||
|
// ── left: the board feed (video) ─────────────────────────────────
|
||||||
|
this.videoPanel = this._buildVideoPanel();
|
||||||
|
this.add(this.videoPanel);
|
||||||
|
this.videoPanel.setDepth(1);
|
||||||
|
|
||||||
|
// ── top bar: the JOB BOARD title in the build's titleTxt spot
|
||||||
|
// (upper left; the meta line + ✕ sit at the right) ────────────────
|
||||||
|
this.rightTitle = T(rect.x + 18, rect.y + 13, '', {
|
||||||
|
fontFamily: HEADER,
|
||||||
|
fontSize: '17px',
|
||||||
|
color: toCss(C.ink),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
letterSpacing: 7,
|
||||||
|
}, 1);
|
||||||
|
this.rightTitleCursor = G(1);
|
||||||
|
this._buildSections();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ghost() {
|
||||||
|
const t = this.scene.add
|
||||||
|
.text(0, 0, '', {
|
||||||
|
fontFamily: HEADER,
|
||||||
|
fontSize: '20px',
|
||||||
|
color: toCss(C.neon),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
letterSpacing: 6,
|
||||||
|
})
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
t.setDepth(2);
|
||||||
|
this.add(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── right: the two placeholder sections ────────────────────────────
|
||||||
|
/**
|
||||||
|
* AVAILABLE JOBS (the board's postings) over JOB DETAILS (the selected
|
||||||
|
* job's contract) — PLACEHOLDERS for now: framed, labelled, and
|
||||||
|
* standing by. The postings list + the selection land later; refresh()
|
||||||
|
* is the repaint seam (the build console's rows/detail shape is the
|
||||||
|
* model to copy when they arrive).
|
||||||
|
*/
|
||||||
|
_buildSections() {
|
||||||
|
const s = this.scene.add;
|
||||||
|
const { rightX, rightW } = this.geo;
|
||||||
|
const mk = (headY, panelY, panelH, head, main, sub) => {
|
||||||
|
// NOTE: added to THE CONTAINER (this.add), not left scene-level —
|
||||||
|
// the container carries the window's depth (10), and a scene-level
|
||||||
|
// object at depth 0 hides behind the window's own opaque backdrop.
|
||||||
|
const headTxt = s
|
||||||
|
.text(rightX + 6, headY, head, {
|
||||||
|
fontFamily: HEADER,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.dim),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
letterSpacing: 3,
|
||||||
|
})
|
||||||
|
.setScrollFactor(0);
|
||||||
|
this.add(headTxt);
|
||||||
|
const bg = s.graphics().setScrollFactor(0);
|
||||||
|
this.add(bg);
|
||||||
|
bg.clear();
|
||||||
|
panel(bg, rightX, panelY, rightW, panelH, {
|
||||||
|
notch: 10,
|
||||||
|
fill: 0x081120,
|
||||||
|
fillAlpha: 0.85,
|
||||||
|
stroke: 0x1b3a5a,
|
||||||
|
strokeAlpha: 0.7,
|
||||||
|
});
|
||||||
|
bg.fillStyle(C.neon, 0.3);
|
||||||
|
bg.fillRect(rightX + 12, panelY + 1, rightW - 24, 1.5);
|
||||||
|
const mainTxt = s
|
||||||
|
.text(rightX + rightW / 2, panelY + panelH / 2 - 8, main, {
|
||||||
|
fontFamily: HEADER,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
letterSpacing: 3,
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
this.add(mainTxt);
|
||||||
|
const subTxt = s
|
||||||
|
.text(rightX + rightW / 2, panelY + panelH / 2 + 12, sub, {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
letterSpacing: 2,
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
this.add(subTxt);
|
||||||
|
return { headTxt, bg, mainTxt, subTxt };
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = this.geo;
|
||||||
|
this.jobsSection = mk(
|
||||||
|
g.availHeadY, g.availTop, g.availH,
|
||||||
|
'AVAILABLE JOBS',
|
||||||
|
'NO CONTRACTS ON FILE',
|
||||||
|
'THE BOARD’S POSTINGS LAND HERE',
|
||||||
|
);
|
||||||
|
this.detailSection = mk(
|
||||||
|
g.detailHeadY, g.detailPanelY, g.detailH,
|
||||||
|
'JOB DETAILS',
|
||||||
|
'SELECT A JOB',
|
||||||
|
'PICK A LISTING TO READ ITS CONTRACT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── left panel: the board feed ──────────────────────────────────────────
|
||||||
|
_buildVideoPanel() {
|
||||||
|
const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
|
||||||
|
const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY);
|
||||||
|
const s = this.scene.add;
|
||||||
|
|
||||||
|
// header: ● BOARD FEED // LIVE
|
||||||
|
this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
|
||||||
|
cont.add(this.recDot);
|
||||||
|
const head = s
|
||||||
|
.text(20, 6, 'BOARD FEED // LIVE', {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
letterSpacing: 2.5,
|
||||||
|
})
|
||||||
|
.setScrollFactor(0);
|
||||||
|
cont.add(head);
|
||||||
|
|
||||||
|
// frame + the clip
|
||||||
|
const vx = (leftW - videoW) / 2;
|
||||||
|
const vy = 26;
|
||||||
|
const frameG = s.graphics().setScrollFactor(0);
|
||||||
|
frameG.clear();
|
||||||
|
brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 });
|
||||||
|
frameG.lineStyle(1, 0x14324f, 0.55);
|
||||||
|
frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2);
|
||||||
|
frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2);
|
||||||
|
cont.add(frameG);
|
||||||
|
|
||||||
|
this.videoCx = vx + videoW / 2;
|
||||||
|
this.videoCy = vy + videoH / 2;
|
||||||
|
this.videoRect = { x: vx, y: vy, w: videoW, h: videoH };
|
||||||
|
|
||||||
|
this.video = null;
|
||||||
|
if (this.hasVideo(JobBoardWindow.VIDEO_KEY)) {
|
||||||
|
const v = s.video(0, 0, JobBoardWindow.VIDEO_KEY);
|
||||||
|
v.setOrigin(0.5);
|
||||||
|
v.setScrollFactor(0);
|
||||||
|
// The feed is ambience: muted + looping, as the console requires.
|
||||||
|
v.setVolume(0);
|
||||||
|
v.setLoop(true);
|
||||||
|
if (v.video) v.video.muted = true;
|
||||||
|
const fit = (vv, iw = 0, ih = 0) => {
|
||||||
|
const el = vv.video;
|
||||||
|
const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544;
|
||||||
|
const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800;
|
||||||
|
const sc = Math.min(videoW / vw, videoH / vh);
|
||||||
|
vv.setPosition(this.videoCx, this.videoCy);
|
||||||
|
vv.setScale(sc);
|
||||||
|
};
|
||||||
|
fit(v);
|
||||||
|
const ready = (vv, w, h) => {
|
||||||
|
if (vv !== v) return;
|
||||||
|
fit(vv, w, h);
|
||||||
|
vv.setVisible(true);
|
||||||
|
};
|
||||||
|
v.on('created', ready);
|
||||||
|
if (v.video && v.video.readyState >= 1) ready(v, 0, 0);
|
||||||
|
cont.add(v);
|
||||||
|
this.video = v;
|
||||||
|
} else {
|
||||||
|
// NO SIGNAL plate (asset missing) — the console still works.
|
||||||
|
const ph = s.graphics().setScrollFactor(0);
|
||||||
|
ph.clear();
|
||||||
|
panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 });
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
ph.fillStyle(C.neon, rand(0.02, 0.08));
|
||||||
|
ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1);
|
||||||
|
}
|
||||||
|
cont.add(ph);
|
||||||
|
const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', {
|
||||||
|
fontFamily: HEADER,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: toCss(C.ink),
|
||||||
|
fontStyle: 'bold',
|
||||||
|
letterSpacing: 5,
|
||||||
|
align: 'center',
|
||||||
|
}).setScrollFactor(0);
|
||||||
|
const nsub = s.text(this.videoCx, this.videoCy + 12, 'BOARD FEED OFFLINE', {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
letterSpacing: 3,
|
||||||
|
align: 'center',
|
||||||
|
}).setScrollFactor(0);
|
||||||
|
cont.add(ns);
|
||||||
|
cont.add(nsub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanlines over the feed (the menu's CRT recipe — the build feed's
|
||||||
|
// twin, its own texture key)
|
||||||
|
const scanKey = 'jobboard_scanlines';
|
||||||
|
if (!this.scene.textures.exists(scanKey)) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 4;
|
||||||
|
c.height = 8;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.fillStyle = 'rgba(2,6,12,0.5)';
|
||||||
|
ctx.fillRect(0, 0, 4, 3);
|
||||||
|
ctx.fillStyle = 'rgba(120,220,255,0.05)';
|
||||||
|
ctx.fillRect(0, 4, 4, 1);
|
||||||
|
this.scene.textures.addCanvas(scanKey, c);
|
||||||
|
}
|
||||||
|
this.scan = s.tileSprite(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5);
|
||||||
|
cont.add(this.scan);
|
||||||
|
|
||||||
|
// sweep band (crawls down the feed on a loop)
|
||||||
|
const swKey = 'jobboard_sweep';
|
||||||
|
if (!this.scene.textures.exists(swKey)) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 8;
|
||||||
|
c.height = 64;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, 64);
|
||||||
|
grad.addColorStop(0, 'rgba(127,223,255,0)');
|
||||||
|
grad.addColorStop(0.5, 'rgba(127,223,255,0.5)');
|
||||||
|
grad.addColorStop(1, 'rgba(127,223,255,0)');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, 8, 64);
|
||||||
|
this.scene.textures.addCanvas(swKey, c);
|
||||||
|
}
|
||||||
|
this.sweepBand = s
|
||||||
|
.image(this.videoCx, this.videoRect.y - 40, swKey)
|
||||||
|
.setDisplaySize(videoW, 48)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
cont.add(this.sweepBand);
|
||||||
|
this.sweepCfg = config.get('builds.fx', null) ?? {};
|
||||||
|
|
||||||
|
// bottom status strip (the build feed's shape)
|
||||||
|
const sy = vy + videoH + 14;
|
||||||
|
const strip = s.graphics().setScrollFactor(0);
|
||||||
|
strip.clear();
|
||||||
|
strip.lineStyle(1, 0x14324f, 0.8);
|
||||||
|
strip.lineBetween(2, sy, leftW - 2, sy);
|
||||||
|
strip.fillStyle(C.neon, 0.7);
|
||||||
|
strip.fillRect(2, sy - 1, 26, 2);
|
||||||
|
cont.add(strip);
|
||||||
|
this.statusTxt = s
|
||||||
|
.text(2, sy + 10, 'BOARD STANDBY — NO CONTRACTS ON FILE', {
|
||||||
|
fontFamily: BODY,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: toCss(C.faint),
|
||||||
|
letterSpacing: 2,
|
||||||
|
})
|
||||||
|
.setScrollFactor(0);
|
||||||
|
cont.add(this.statusTxt);
|
||||||
|
|
||||||
|
return cont;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── painting ─────────────────────────────────────────────────────────────
|
||||||
|
_paintClose() {
|
||||||
|
const b = this.closeBtn;
|
||||||
|
const g = this.closeG;
|
||||||
|
g.clear();
|
||||||
|
panel(g, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, {
|
||||||
|
notch: 7,
|
||||||
|
fill: C.panel,
|
||||||
|
fillAlpha: b.hover ? 0.85 : 0.4,
|
||||||
|
stroke: C.neon2,
|
||||||
|
strokeAlpha: b.hover ? 1 : 0.55,
|
||||||
|
});
|
||||||
|
this.closeTxt.setAlpha(b.hover ? 1 : 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaint the placeholder sections (the seam — the postings + the
|
||||||
|
* selected job's contract repaint here when they land). */
|
||||||
|
refresh() {
|
||||||
|
// The sections stand as built (NO CONTRACTS ON FILE / SELECT A JOB).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── open / close ─────────────────────────────────────────────────────────
|
||||||
|
open() {
|
||||||
|
if (this.openState === 'open' || this.openState === 'opening') return;
|
||||||
|
this.openState = 'opening';
|
||||||
|
this.setAlpha(0);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.openState === 'closed' || this.openState === 'closing') return;
|
||||||
|
this.openState = 'closing';
|
||||||
|
this.video?.pause?.();
|
||||||
|
this.sfx('ui_close');
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Power2',
|
||||||
|
onComplete: () => {
|
||||||
|
this.openState = 'closed';
|
||||||
|
this.glitchG.clear();
|
||||||
|
this.titleGhostA.setAlpha(0);
|
||||||
|
this.titleGhostB.setAlpha(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'closing' counts as open: the close door flips openState in the
|
||||||
|
// SAME input pass, before the scene's pointerdown guard runs — while
|
||||||
|
// the window is still on screen (fading out) it must keep owning the
|
||||||
|
// click, or the close tap leaks through as a world click.
|
||||||
|
get isOpen() {
|
||||||
|
return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── boot reveal ────────────────────────────────────────────────────────────
|
||||||
|
_startReveal() {
|
||||||
|
const now = this.scene.time.now;
|
||||||
|
const list = [];
|
||||||
|
const push = (o, d, dur, mode, baseY) => list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now });
|
||||||
|
this.decodeTo(this.rightTitle, 'JOB BOARD', now, 520);
|
||||||
|
|
||||||
|
if (!this.booted) {
|
||||||
|
push(this, 0, 240, 'fade');
|
||||||
|
push(this.topMeta, 120, 300, 'fade');
|
||||||
|
push(this.titleRailG, 160, 220, 'fade');
|
||||||
|
push(this.closeG, 200, 200, 'fade');
|
||||||
|
push(this.closeTxt, 200, 200, 'fade');
|
||||||
|
push(this.videoPanel, 260, 380, 'fade');
|
||||||
|
push(this.rightTitle, 300, 300, 'rise', this.rightTitle.y);
|
||||||
|
push(this.jobsSection.headTxt, 420, 240, 'fade');
|
||||||
|
push(this.jobsSection.bg, 440, 260, 'rise', 0);
|
||||||
|
push(this.jobsSection.mainTxt, 480, 240, 'fade');
|
||||||
|
push(this.jobsSection.subTxt, 520, 240, 'fade');
|
||||||
|
push(this.detailSection.headTxt, 560, 240, 'fade');
|
||||||
|
push(this.detailSection.bg, 580, 260, 'rise', 0);
|
||||||
|
push(this.detailSection.mainTxt, 620, 240, 'fade');
|
||||||
|
push(this.detailSection.subTxt, 660, 240, 'fade');
|
||||||
|
this.booted = true;
|
||||||
|
this.glitch.next = now + rand(6000, 13000);
|
||||||
|
} else {
|
||||||
|
push(this, 0, 160, 'fade');
|
||||||
|
}
|
||||||
|
this.reveal = list;
|
||||||
|
this.openState = 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
_glitchBurst(level, at) {
|
||||||
|
this.glitch.until = at + rand(220, 420);
|
||||||
|
this.glitch.level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-frame (called by the scene's update) ──────────────────────────────
|
||||||
|
update(time) {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
|
// reveal timeline
|
||||||
|
if (this.reveal.length) {
|
||||||
|
let done = true;
|
||||||
|
for (const r of this.reveal) {
|
||||||
|
const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur));
|
||||||
|
const e = easeIO(u);
|
||||||
|
if (r.mode === 'fade') r.o.setAlpha(e);
|
||||||
|
else if (r.mode === 'rise') {
|
||||||
|
r.o.setAlpha(e);
|
||||||
|
r.o.setY(r.baseY + (1 - e) * 14);
|
||||||
|
}
|
||||||
|
if (u < 1) done = false;
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
for (const r of this.reveal) {
|
||||||
|
r.o.setAlpha(1);
|
||||||
|
if (r.mode === 'rise') r.o.setY(r.baseY);
|
||||||
|
}
|
||||||
|
this.reveal = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodes (ScrambleDecode polling)
|
||||||
|
if (this.decodes.length) {
|
||||||
|
for (const d of [...this.decodes]) {
|
||||||
|
if (!d.dec.started(time)) continue;
|
||||||
|
d.txt.setText(d.dec.display(time));
|
||||||
|
if (d.dec.finished(time)) {
|
||||||
|
d.txt.setText(d.dec.value);
|
||||||
|
const i = this.decodes.indexOf(d);
|
||||||
|
if (i >= 0) this.decodes.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ambient glitch bursts (the build console's recipe)
|
||||||
|
if (time >= this.glitch.next) {
|
||||||
|
this._glitchBurst(rand(0.5, 1), time);
|
||||||
|
this.glitch.next = time + rand(6000, 13000);
|
||||||
|
}
|
||||||
|
if (time < this.glitch.until) {
|
||||||
|
const lvl = this.glitch.level ?? 0.8;
|
||||||
|
const { rect } = this.geo;
|
||||||
|
const g = this.glitchG;
|
||||||
|
g.clear();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
g.fillStyle(i % 2 ? C.neon : C.neon2, rand(0.03, 0.1) * lvl);
|
||||||
|
g.fillRect(rect.x + rand(-8, 8), rand(rect.y, rect.y + rect.h), rect.w, rand(2, 18));
|
||||||
|
}
|
||||||
|
const dx = 2 + lvl * 2;
|
||||||
|
const t = this.rightTitle;
|
||||||
|
this.titleGhostA.setText(t.text).setPosition(t.x + dx, t.y).setAlpha(0.5 * lvl);
|
||||||
|
this.titleGhostB.setText(t.text).setPosition(t.x - dx, t.y).setAlpha(0.5 * lvl);
|
||||||
|
this.titleGhostB.setColor(toCss(C.neon2));
|
||||||
|
} else if (this.titleGhostA?.alpha > 0) {
|
||||||
|
this.titleGhostA.setAlpha(0);
|
||||||
|
this.titleGhostB.setAlpha(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// title cursor blink (the build console's title-cursor recipe)
|
||||||
|
const blink = Math.floor(time / 480) % 2 === 0 ? 1 : 0.15;
|
||||||
|
this.rightTitleCursor.clear();
|
||||||
|
this.rightTitleCursor.fillStyle(C.neon, blink);
|
||||||
|
this.rightTitleCursor.fillRect(
|
||||||
|
this.rightTitle.x + this.rightTitle.width + 10,
|
||||||
|
this.rightTitle.y - 16,
|
||||||
|
9,
|
||||||
|
19,
|
||||||
|
);
|
||||||
|
|
||||||
|
// sweep band over the feed
|
||||||
|
const cfg = this.sweepCfg;
|
||||||
|
if (cfg.enabled !== false && this.sweepBand) {
|
||||||
|
const [lo, hi] = cfg.everyMs ?? [4200, 8600];
|
||||||
|
if (time >= this.sweep.next) {
|
||||||
|
this.sweep.t0 = time;
|
||||||
|
this.sweep.next = time + rand(lo, hi);
|
||||||
|
}
|
||||||
|
const dur = cfg.durationMs ?? 1500;
|
||||||
|
const u = (time - this.sweep.t0) / dur;
|
||||||
|
if (u >= 0 && u <= 1) {
|
||||||
|
const { y, h } = this.videoRect;
|
||||||
|
this.sweepBand.setY(y - 48 + u * (h + 96));
|
||||||
|
this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5);
|
||||||
|
} else {
|
||||||
|
this.sweepBand.setAlpha(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REC dot pulse
|
||||||
|
this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── teardown ───────────────────────────────────────────────────────────────
|
||||||
|
destroy() {
|
||||||
|
this.destroyVideo(this.video);
|
||||||
|
this.video = null;
|
||||||
|
super.destroy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue