diff --git a/assets/videos/arid-land-03.mp4 b/assets/videos/arid-land-03.mp4 new file mode 100644 index 0000000..387d3ee Binary files /dev/null and b/assets/videos/arid-land-03.mp4 differ diff --git a/data/quests.json b/data/quests.json index 1c32d85..82b53de 100644 --- a/data/quests.json +++ b/data/quests.json @@ -97,6 +97,13 @@ "text": "PRIORITY CLEARED" } }, + "tutorial": { + "_comment": "The starter quest's POINTING FINGER (the beat table in js/quests/TutorialGuide.js, applied per frame by GameScene._applyTutorialHighlight): the ONE control asking for the player's attention during the opening arc, pulsing on the surface that owns it (ActionBar.setHighlight, ResearchWindow.setHighlight). The beats derive LIVE from the run's records — the comms' played-ledger (which of OAC's lines were heard — data/comms.json) + the research state — so nothing extra is saved and a loaded run picks the same beat back up: (1) the welcome sequence acknowledged (oac-intro/oac-tether/oac-scan all heard) and SCAN not yet pressed → scan.slot pulses on the deck; (2) oac-upgrade heard (the 'research a Tether Level 2 upgrade' line) → research.slot pulses while the console is closed, and inside the open console the console.category tab — then the console.node plate, once the player clicks it — wears the ACKNOWLEDGE button's own amber pulse (comms.box.pulse: halo + two staggered sonar rings, ResearchWindow._paintAckPulse); the finger rests the moment the node's research is in flight or done (its completion ignition + toasts carry the news). The rim hides under the deck's standby — while an 'ack' comm holds, the ACKNOWLEDGE button is the only live control.", + "enabled": true, + "scan": { "slot": "scan" }, + "research": { "slot": "research" }, + "console": { "category": "exploration", "node": "tether_l2" } + }, "quests": [ { "id": "start_your_journey", diff --git a/dev/actionbar.test.mjs b/dev/actionbar.test.mjs index a23ed4a..7f7d511 100644 --- a/dev/actionbar.test.mjs +++ b/dev/actionbar.test.mjs @@ -63,6 +63,8 @@ function stubObject(extra = {}) { setScale() { return this; }, setTint() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; }, + setX(v) { this.x = v; return this; }, + setY(v) { this.y = v; return this; }, setStrokeStyle() { return this; }, setSize(w, h) { this.width = w; this.height = h; return this; }, setBlendMode() { return this; }, @@ -247,4 +249,55 @@ console.log('== LABEL + LOCK — relabeling a locked slot keeps the standby pain assert.notEqual(s.label.color, '#5a6478', 'unlocking paints the base label back'); } +// =========================================================================== +console.log('== HIGHLIGHT — the tutorial\u2019s pointing finger (setHighlight) =='); +{ + const { bar } = makeBar(); + // The rim is drawn by update() — count its stroke passes on the layer. + let strokes = 0; + bar.highlightG.strokePoints = (pts, close) => { strokes += 1; return bar.highlightG; }; + + assert.equal(bar.highlightId, null, 'no finger at construction'); + bar.update(1000, 16); + assert.equal(strokes, 0, 'no rim with no highlight'); + + // 'scan' is a FLIGHT-deck id — on this surface deck it degrades to a clear. + bar.setHighlight('scan'); + assert.equal(bar.highlightId, null, 'an unknown id (on this deck) is a safe no-op'); + bar.update(1100, 16); + assert.equal(strokes, 0, '…and draws no rim'); + + bar.setHighlight('build'); + assert.equal(bar.highlightId, 'build', 'setHighlight arms a live slot of the deck'); + bar.update(1200, 16); + assert.ok(strokes >= 2, 'the rim drew (soft pass + sharp rim)'); + + assert.equal(bar.setHighlight('nope'), undefined, 'unknown id is a safe no-op'); + assert.equal(bar.highlightId, null, '…and clears the finger'); + const cleared = strokes; + bar.update(1300, 16); + assert.equal(strokes, cleared, 'no rim once cleared'); + + // The deck\u2019s standby (the 'ack' comm\u2019s hold) owns the screen: the rim + // hides — the ACKNOWLEDGE button is the only live control then. + bar.setHighlight('build'); + bar.setEnabled(false); + const held = strokes; + bar.update(1400, 16); + assert.equal(strokes, held, 'the rim is hidden under the deck standby'); + bar.setEnabled(true); + bar.update(1500, 16); + assert.ok(strokes > held, 'the rim returns when the hold lifts'); + + // A per-slot lock (deck-locked slot) also hides the rim. + bar.setSlotEnabled('build', false); + const locked = strokes; + bar.update(1600, 16); + assert.equal(strokes, locked, 'the rim is hidden over a locked slot'); + bar.setSlotEnabled('build', true); + + bar.setHighlight(null); + assert.equal(bar.highlightId, null, 'null clears it'); +} + console.log('\nAll ActionBar checks passed ✔'); diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index 7938bef..c6ab111 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -520,6 +520,35 @@ check('helpers: starterQuestIds() is exactly [the starter]', (t.priorityToast?.color ?? '').toLowerCase() === '#67e863'); } +// ---------------------------------------------------------------------- +// 6d. quests.json → tutorial — the starter quest's POINTING FINGER +// (data the beat table in js/quests/TutorialGuide.js stands on) +// ---------------------------------------------------------------------- +{ + const tut = quests.tutorial ?? {}; + check('tutorial: section present with the master switch', typeof tut.enabled === 'boolean'); + check('tutorial: enabled (the finger is live)', tut.enabled === true); + // The two deck beats name REAL live slots on the flight deck (a typo + // here would be a highlight that never lands on a button). + const flightSlots = (actionbar.buttons ?? []).filter((b) => typeof b.id === 'string' && b.id.length > 0).map((b) => b.id); + check('tutorial: the SCAN beat names a real flight-deck slot', flightSlots.includes(tut.scan?.slot)); + check('tutorial: the RESEARCH beat names a real flight-deck slot', flightSlots.includes(tut.research?.slot)); + check('tutorial: the two deck beats point at different slots', tut.scan?.slot !== tut.research?.slot); + // The console beat points at the SAME node the starter quest's research + // check demands (the arc's goal) — the finger and the quest can't drift. + const researchCheck = (starter?.checks ?? []).find((c) => c.type === 'research'); + check('tutorial: the console beat matches the starter research check\u2019s node', + !!researchCheck && tut.console?.category === researchCheck.category && tut.console?.node === researchCheck.node); + // …and that node is REAL in its category's static tree (the highlight + // would never land otherwise — the research.json registry knows which + // categories are file trees). + if (tut.console?.category && tut.console?.node) { + const tree = read(`research/${tut.console.category}.json`); + check('tutorial: the node exists in data/research/' + tut.console.category + '.json', + !!tree?.nodes && typeof tree.nodes[tut.console.node] === 'object'); + } +} + // ---------------------------------------------------------------------- // 7. The command decks — the QUESTS slot (flight: right of SHIP, // surface: right of EXPLORE — the surface deck's SHIP slot became diff --git a/dev/tutorial.test.mjs b/dev/tutorial.test.mjs new file mode 100644 index 0000000..778ba9c --- /dev/null +++ b/dev/tutorial.test.mjs @@ -0,0 +1,134 @@ +/** + * TutorialGuide test (dev tool, run with Node — no browser): + * + * node dev/tutorial.test.mjs + * + * Pins the starter quest's POINTING FINGER beat table + * (js/quests/TutorialGuide.js — the pure core of the opening-arc + * guidance, applied per frame by GameScene._applyTutorialHighlight onto + * ActionBar.setHighlight / ResearchWindow.setHighlight): + * - the welcome sequence acknowledged (oac-intro → oac-tether → + * oac-scan all on the run's comms played-ledger) and SCAN not yet + * pressed → 'scan'; + * - OAC's 'research a Tether Level 2 upgrade' line (oac-upgrade) heard + * → 'research' (console closed) / 'console' (console open); + * - the goal node's research in flight / done → REST (null); + * - missing records and the data's master switches degrade to rest; + * - the data the beats stand on (data/comms.json's newGame/firstScan + * triggers) is what the module's constants name. + * The rim's drawing is the surface's job (ActionBar's update gate — + * dev/actionbar.test.mjs pins the standby hiding). + */ +import fs from 'node:fs'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; +import { tutorialTarget, WELCOME_COMMS, UPGRADE_COMM } from '../js/quests/TutorialGuide.js'; + +const dataDir = join(dirname(new URL(import.meta.url).pathname), '..', 'data'); +const comms = JSON.parse(fs.readFileSync(join(dataDir, 'comms.json'), 'utf8')); +const quests = JSON.parse(fs.readFileSync(join(dataDir, 'quests.json'), 'utf8')); + +// --------------------------------------------------------------------------- +// The data the beats stand on — the module's constants name REAL comms, +// in the triggers' real order (a typo here would be a beat that never +// arms — the finger would rest where it should be pointing). +// --------------------------------------------------------------------------- +{ + const byTrigger = (t) => comms.comms.filter((c) => c.trigger === t).map((c) => c.id); + assert.deepEqual( + WELCOME_COMMS, + byTrigger('newGame'), + 'WELCOME_COMMS = the newGame sequence, in order', + ); + assert.ok(byTrigger('firstScan').includes(UPGRADE_COMM), 'UPGRADE_COMM is a firstScan comm'); + assert.equal(UPGRADE_COMM, 'oac-upgrade', 'the scan reaction is oac-upgrade'); + // The starter quest's research check is the arc's goal — the tutorial's + // console beat (category + node) points at the same node. + const starter = quests.quests.find((q) => q.starter === true); + const researchCheck = starter.checks.find((c) => c.type === 'research'); + assert.equal(quests.tutorial.console.category, researchCheck.category, 'tutorial category = the starter check\u2019s category'); + assert.equal(quests.tutorial.console.node, researchCheck.node, 'tutorial node = the starter check\u2019s node'); + assert.ok(quests.tutorial.scan.slot, 'the scan beat names a slot'); + assert.ok(quests.tutorial.research.slot, 'the research beat names a slot'); +} + +// A run's records: the starter granted + the played-ledger as an id set. +const records = (...ids) => ({ + starterGranted: true, + hasPlayed: (id) => ids.includes(id), +}); + +// --------------------------------------------------------------------------- +// Fresh run — the comms own the screen (the welcome is mid-sequence). +// --------------------------------------------------------------------------- +assert.equal(tutorialTarget({}), null, 'no records → rest'); +assert.equal(tutorialTarget(records()), null, 'a fresh ledger → rest (the comms own the screen)'); +assert.equal(tutorialTarget(records('oac-intro')), null, 'welcome beat one playing → rest'); +assert.equal(tutorialTarget(records('oac-intro', 'oac-tether')), null, 'welcome beat two playing → rest'); + +// --------------------------------------------------------------------------- +// Beat 1 — the welcome is acknowledged: the deck's SCAN slot is the finger. +// --------------------------------------------------------------------------- +assert.equal(tutorialTarget(records('oac-intro', 'oac-tether', 'oac-scan')), 'scan', 'welcome ack\u2019d, SCAN unpressed → scan'); +// The player pressed SCAN (the scene's one-shot): the beat is past — the +// finger rests until oac-upgrade starts (which re-arms it, below). +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan'), scanPressed: true }), + null, + 'welcome ack\u2019d, SCAN already pressed → rest (the comm is coming)', +); + +// --------------------------------------------------------------------------- +// Beat 2 — oac-upgrade is on the ledger (the 'research a Tether Level 2' +// line): the deck's RESEARCH slot (console closed) or the console itself +// (console open — the tab's rim, then the node's, by the window). +// --------------------------------------------------------------------------- +assert.equal( + tutorialTarget(records('oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade')), + 'research', + 'oac-upgrade heard, console closed → research', +); +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade'), consoleOpen: true }), + 'console', + 'oac-upgrade heard, console open → console', +); +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade'), consoleOpen: true, scanPressed: true }), + 'console', + 'the scan one-shot is irrelevant once the upgrade line is heard', +); +// The scan beat's slot and the research beat's slot are the data's own +// names (GameScene reads them — the pure table just picks the beat). +assert.notEqual(quests.tutorial.scan.slot, quests.tutorial.research.slot, 'the two deck beats point at different slots'); + +// --------------------------------------------------------------------------- +// REST — the arc's goal is underway / done: the finger steps off. +// --------------------------------------------------------------------------- +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade'), nodeResolving: true }), + null, + 'the goal research in flight → rest', +); +assert.equal(tutorialTarget({ ...records(), nodeResolving: true }), null, 'goal done on a fresh ledger → rest'); + +// --------------------------------------------------------------------------- +// Degradation — missing records / the data's master switches → rest. +// --------------------------------------------------------------------------- +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan'), starterGranted: false }), + null, + 'the starter quest not granted → rest', +); +assert.equal( + tutorialTarget({ starterGranted: true }), + null, + 'no played-ledger to read → rest (never a throw)', +); +assert.equal( + tutorialTarget({ ...records('oac-intro', 'oac-tether', 'oac-scan'), enabled: false }), + null, + 'the data\u2019s master switch off → rest', +); + +console.log('all TutorialGuide checks passed \u2714'); diff --git a/js/quests/TutorialGuide.js b/js/quests/TutorialGuide.js new file mode 100644 index 0000000..f83b545 --- /dev/null +++ b/js/quests/TutorialGuide.js @@ -0,0 +1,69 @@ +/** + * TutorialGuide — the starter quest's POINTING FINGER: which control is + * asking for the player's attention right now, given the run's records. + * + * Pure: no Phaser, no config, no DOM — node-testable (the QuestState / + * ResearchState shape). The scene (GameScene._tutorialTarget) feeds it + * the live records and applies the result per frame (ActionBar and + * ResearchWindow's setHighlight — the pulsing rims). The targets' names + * (the deck slots, the category + node) ride on data/quests.json → + * tutorial (the data's master switch + the re-targetable ids). + * + * The beats (the starter quest's opening arc — research the Tether + * Level 2, data/quests.json → start_your_journey): + * 'scan' the welcome sequence is acknowledged — oac-intro → + * oac-tether → oac-scan all on the run's comms played-ledger + * (data/comms.json, trigger 'newGame') — and the player + * hasn't pressed SCAN yet; + * 'research' OAC's 'research a Tether Level 2 upgrade' line + * (oac-upgrade, trigger 'firstScan') is on the ledger and + * the research console is CLOSED; + * 'console' the same beat with the console OPEN — the window's + * setHighlight paints the category tab's rim, and the node's + * plate's rim once that category is the one on screen; + * null REST — the comms still own the screen (nothing has been + * heard yet), or the goal (the named node's research) is in + * flight / done. + * + * Derived from the records only — nothing extra is saved; a loaded run + * picks the same beat back up. `scanPressed` is the scene-local one-shot + * covering the window between a SCAN press and oac-upgrade's start (the + * comm's own played-ledger mark is the authoritative witness once it has + * started — from then on the beat is 'research'/'console'). + */ + +/** The 'newGame' welcome sequence, in play order (data/comms.json). */ +export const WELCOME_COMMS = ['oac-intro', 'oac-tether', 'oac-scan']; +/** The scan reaction (trigger 'firstScan') — the 'research a Tether + * Level 2 upgrade' line (data/comms.json). */ +export const UPGRADE_COMM = 'oac-upgrade'; + +/** + * @param {{ + * enabled?: boolean, false → rest (the data's master switch) + * starterGranted?: boolean, the starter quest is on the quest ledger + * hasPlayed?: (id: string) => boolean, + * the run's comms played-ledger + * scanPressed?: boolean, the deck's SCAN has been pressed this beat + * consoleOpen?: boolean, the research console is on screen + * nodeResolving?: boolean, the goal node's research is in flight/done + * }} [r] + * @returns {'scan'|'research'|'console'|null} the beat to point at + */ +export function tutorialTarget(r = {}) { + if (r.enabled === false) return null; + if (r.starterGranted !== true) return null; + if (typeof r.hasPlayed !== 'function') return null; + // The goal is underway / done — the finger rests (the completion + // ignition + toasts carry the news from here on). + if (r.nodeResolving === true) return null; + // Beat 2 — OAC has said the 'research a Tether Level 2' line. + if (r.hasPlayed(UPGRADE_COMM)) { + return r.consoleOpen ? 'console' : 'research'; + } + // Beat 1 — the welcome is acknowledged and SCAN hasn't been pressed. + if (WELCOME_COMMS.every((id) => r.hasPlayed(id)) && r.scanPressed !== true) { + return 'scan'; + } + return null; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index eb61d23..4490e53 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -52,6 +52,7 @@ import { MapWindow } from '../ui/MapWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; import { QuestTrackerHud } from '../ui/QuestTrackerHud.js'; import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; +import { tutorialTarget } from '../quests/TutorialGuide.js'; import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js'; import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; import { planRoute } from '../galaxy/Route.js'; @@ -996,6 +997,12 @@ export class GameScene extends Phaser.Scene { // also runs at boot (create, below) so a loaded run that already // crossed it still gets the beat. this._minerals200Armed = false; + // The tutorial's pointing finger (data/quests.json → tutorial, the beat + // table in js/quests/TutorialGuide.js): the 'press SCAN' beat is past + // the moment the player presses SCAN — the scene-local one-shot (the + // comm's played-ledger marks oac-upgrade the moment it starts, and is + // the authoritative witness from then on). + this._tutScanPressed = false; // Hint (pinned just ABOVE the command deck, not under it) this.hint = this.add @@ -2076,6 +2083,9 @@ export class GameScene extends Phaser.Scene { if (_researchDone?.length) { for (const d of _researchDone) this._completeResearch(d.category, d.id); } + // The starter quest's pointing finger — applied before the surfaces + // draw (their update loops paint the rim's pulse from the target). + this._applyTutorialHighlight(); this.researchWindow?.update(_time); // the console's living details (open state) this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll @@ -3623,6 +3633,78 @@ export class GameScene extends Phaser.Scene { console.info(`[orbit] command deck: ${id}`); } + // ------------------------------------------------------------ tutorial + /** + * The starter quest's POINTING FINGER (data/quests.json → tutorial — + * the targets' names ride on the data): which control is asking for + * the player's attention right now. The beat table is pure and + * node-tested in js/quests/TutorialGuide.js; this feeds it the run's + * live records: + * null the comms still own the screen, or the arc's goal (the + * Tether Level 2 research) is in flight / done — rest; + * 'scan' the welcome sequence is acknowledged (oac-intro → + * oac-tether → oac-scan all on the run's comms played-ledger) + * and SCAN hasn't been pressed yet → the deck's SCAN slot; + * 'research' OAC's 'research a Tether Level 2 upgrade' line + * (oac-upgrade) is on the ledger and the console is closed → + * the deck's RESEARCH slot; + * 'console' the same beat with the console open → the console's + * category tab, and — once the player has clicked it — the + * node's plate (ResearchWindow paints the right half from + * its own active category, so the click completes the beat). + * Derived from the records only — nothing extra is saved; a loaded run + * picks the same beat back up. + */ + _tutorialTarget() { + if (config.get('quests.enabled', true) !== true) return null; + const tut = config.section('quests.tutorial', {}); + if (tut.enabled === false) return null; + const played = this.comms?.played; + if (!(played instanceof Set)) return null; + const cat = tut.console?.category ?? 'exploration'; + const node = tut.console?.node ?? 'tether_l2'; + const run = this.researchState?.getActive(); + const resolving = + this.researchState?.isUnlocked(cat, node) === true || + (run && run.category === cat && run.id === node); + return tutorialTarget({ + starterGranted: this.questState?.isGranted('start_your_journey') === true, + hasPlayed: (id) => played.has(id), + scanPressed: this._tutScanPressed === true, + consoleOpen: this.researchWindow?.isOpen === true, + nodeResolving: resolving, + }); + } + + /** + * Apply the pointing finger to whichever surface owns it (per frame, + * update() — before the surfaces draw, so their update() paints the + * pulse from the target). Both setters are idempotent and no-ops for + * their current value, so re-applying every frame is cheap; the + * ActionBar hides the rim under the deck's standby (the 'ack' hold's + * contract — the ACKNOWLEDGE button is the only live control then), + * and the window hides it while closed. + */ + _applyTutorialHighlight() { + const target = this._tutorialTarget(); + const tut = config.section('quests.tutorial', {}); + this.actionBar?.setHighlight( + target === 'scan' + ? tut.scan?.slot ?? 'scan' + : target === 'research' + ? tut.research?.slot ?? 'research' + : null, + ); + const con = this.researchWindow; + if (con) { + if (target === 'console') { + con.setHighlight(tut.console?.category ?? 'exploration', tut.console?.node ?? 'tether_l2'); + } else { + con.setHighlight(null, null); + } + } + } + // ------------------------------------------------------------ quests /** @@ -5196,6 +5278,7 @@ export class GameScene extends Phaser.Scene { return; } if (this.scanPulse.busy) return; // a sweep is already out + this._tutScanPressed = true; // the tutorial's finger moves off SCAN now // The comms reaction (data/comms.json: firstScan — oac-upgrade): the // comm's own delayMs is the beat after this click; the busy() contract // below (scanPulse.busy) holds it until the sweep settles. diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js index 8503d41..d18f7aa 100644 --- a/js/ui/ActionBar.js +++ b/js/ui/ActionBar.js @@ -121,6 +121,13 @@ export class ActionBar extends Phaser.GameObjects.Container { this.buildRails(); this.buildSlots(ab, bc, x0, y0, barW, barH); this.buildFx(barW, barH); // scanlines + panel RGB ghost (topmost of the bar) + // The tutorial's pointing finger (setHighlight — the starter quest's + // 'press THIS' beat, GameScene._tutorialTarget): an additive rim on + // one slot, above the slots + scanlines; drawn in update(). + this.highlightId = null; + this.highlightG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + this.add(this.highlightG); + this._hlLive = false; this.boot(); } @@ -576,6 +583,21 @@ export class ActionBar extends Phaser.GameObjects.Container { return s; } + /** + * setHighlight(id) — the TUTORIAL's pointing finger: the one slot that + * is asking for the player's press (the starter quest's opening arc — + * GameScene._tutorialTarget, data/quests.json → tutorial) wears a + * pulsing accent rim, drawn every frame in update() over the slot. + * `null` clears it. Idempotent (the scene applies its beat per frame). + * The rim is hidden while the deck's standby holds (setEnabled(false) — + * the 'ack' comm's contract: the ACKNOWLEDGE button is the only live + * control then, and it already owns the player's eyes). + */ + setHighlight(id) { + const want = id != null ? (this.slots ?? []).find((s) => s.live && s.id === id) : null; + this.highlightId = want ? want.id : null; + } + // ------------------------------------------------------------------ // Boot // ------------------------------------------------------------------ @@ -739,6 +761,35 @@ export class ActionBar extends Phaser.GameObjects.Container { s.ghostCyan.setAlpha(a * 0.95).setPosition(off * 0.85, this.style.labelY - off * 0.3 + jy); } + // The tutorial's pointing finger — a pulsing accent rim on the slot + // that is asking for a press (setHighlight): a soft outer pass under + // a sharp rim + a breath of fill (the panel-glow idiom, at button + // scale). Hidden under the deck's standby and over a locked slot. + const hlSlot = this.highlightId ? this.slots.find((s) => s.id === this.highlightId) : null; + if (hlSlot && hlSlot.live && hlSlot.disabled !== true && this.enabled !== false) { + const { bw, bh, notch } = this.style; + const pulse = 0.5 + 0.5 * Math.sin(t * 2.6); // ~2.4 s breath + const g = this.highlightG; + g.clear(); + const rim = CyberShape.rectPoints( + hlSlot.slot.x - bw / 2 - 5, + hlSlot.slot.y - bh / 2 - 5, + bw + 10, + bh + 10, + notch + 2.5, + ); + g.lineStyle(5, hlSlot.accent, 0.15 + 0.2 * pulse); // the soft outer pass + g.strokePoints(rim, true); + g.lineStyle(2, hlSlot.accent, 0.6 + 0.4 * pulse); // the sharp rim + g.strokePoints(rim, true); + g.fillStyle(hlSlot.accent, 0.04 + 0.05 * pulse); // a breath of fill + g.fillPoints(rim, true); + this._hlLive = true; + } else if (this._hlLive) { + this.highlightG.clear(); + this._hlLive = false; + } + // The bar itself: the panel outline pulled apart in magenta/cyan while // the burst is live — the RGB separation on the bar, not just the text. if (L > 0.02) { diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index b3a266b..f4d9d16 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -367,6 +367,25 @@ export class ResearchWindow extends Phaser.GameObjects.Container { .setBlendMode(Phaser.BlendModes.ADD); this.add(this.burstG); + // The tutorial's pointing finger (setHighlight — the starter quest's + // 'go THERE / press THIS' beat, GameScene._tutorialTarget): a pulsing + // rim on the category tab, or on the node's plate while its tree is + // the one on screen; drawn every frame in update() (the window's top + // layer, additive). The pulse is the ACKNOWLEDGE button's own + // (comms.box.pulse — amber halo + two staggered sonar rings), so the + // finger reads as the same 'press me' the comm holds already taught. + const pulseCfg = config.section('comms.box.pulse', {}); + this._hlColor = toColor(pulseCfg.color, 0xffd84d); + this._hlPeriod = Math.max(600, pulseCfg.periodMs ?? 1300); + this.highlightG = s + .graphics() + .setScrollFactor(0) + .setDepth(9) + .setBlendMode(Phaser.BlendModes.ADD); + this.add(this.highlightG); + this.highlight = null; // { catId, nodeId } + this._hlLive = false; + // glitch layers (top of the window) this.glitchG = G(10); this.titleGhostA = this._ghost(); @@ -1470,6 +1489,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container { this.video?.pause?.(); this.bursts.length = 0; // no ignitions over a closing window this.burstG?.clear(); + this.highlightG?.clear(); // the pointing finger rests with the window + this._hlLive = false; this.sfx('ui_close'); this.scene.tweens.add({ targets: this, @@ -1610,6 +1631,98 @@ export class ResearchWindow extends Phaser.GameObjects.Container { } } + /** + * setHighlight(catId, nodeId) — the TUTORIAL's pointing finger inside + * the console (GameScene._tutorialTarget, applied per frame): while + * the category's tree is NOT the one on screen, the ACKNOWLEDGE-style + * pulse on that category's tab (the 'go to EXPLORATION' beat); once it + * IS on screen, the pulse moves to the node's plate (the 'research + * THIS one' beat). (null, null) clears it. Idempotent; unknown ids + * degrade to a clear. + */ + setHighlight(catId, nodeId) { + const c = catId != null && this.trees.has(catId) ? catId : null; + const n = c != null && nodeId != null && this.trees.get(c).nodes.has(nodeId) ? nodeId : null; + if (this.highlight?.catId === c && this.highlight?.nodeId === n) return; + this.highlight = c != null ? { catId: c, nodeId: n } : null; + } + + /** + * One pulse pass over a target's rect (center cx/cy, size w/h) — the + * ACKNOWLEDGE button's attention pulse (CommsBox._pulse, same + * comms.box.pulse config): a soft amber halo breathing under the + * plate, and two sonar rings expanding out on a half-period stagger + * (one always leaving while the other sets). The rings' scale/fade is + * a pure function of `time` — computed as scaled corner-cut rects + * (scaling a centered cut-corner rect scales the cut with it, so this + * matches CommsBox's setScale exactly). + */ + _paintAckPulse(g, cx, cy, w, h, notch, time) { + const P = this._hlPeriod; + const col = this._hlColor; + // The halo — the plate's own soft glow, breathing once per cycle. + const b = 0.5 + 0.5 * Math.sin(((time % P) / P) * Math.PI * 2); + g.fillStyle(col, 0.12 + 0.26 * b); + g.fillPoints( + CyberShape.rectPoints(cx - (w + 8) / 2, cy - (h + 8) / 2, w + 8, h + 8, notch + 2), + true, + ); + // The sonar rings — a touch larger than the plate, expanding + fading + // (the floor keeps the target framed between waves). + for (const off of [0, P / 2]) { + const u = ((time + off) % P) / P; + const s = 1 + 0.3 * u; + g.lineStyle(2.5, col, 0.18 + (1 - u) * 0.67); + g.strokePoints( + CyberShape.rectPoints( + cx - ((w + 12) * s) / 2, + cy - ((h + 12) * s) / 2, + (w + 12) * s, + (h + 12) * s, + (notch + 3) * s, + ), + true, + ); + } + } + + /** The pointing finger's frame — the ACK pulse on the tab or the node. */ + _paintHighlight(time) { + const g = this.highlightG; + if (!g) return; + const h = this.highlight; + // 'open' only (not isOpen — no pulse over the fading window) and + // after the boot reveal (it shouldn't race the pop-in). + const on = !!(h && this.openState === 'open' && !this.reveal.length); + if (!on) { + if (this._hlLive) { + g.clear(); + this._hlLive = false; + } + return; + } + g.clear(); + if (this.activeCat !== h.catId) { + // The category tab — the row is always on screen (the other trees + // stay hidden — their plates are invisible, so the node is off). + const tab = this.tabs.find((t) => t.id === h.catId); + if (tab) { + const { bodyY, tabH } = this.geo; + const y = bodyY + 1; + const hh = tabH - 2; + this._paintAckPulse(g, tab.x + tab.w / 2, y + hh / 2, tab.w, hh, 10, time); + } + } else if (h.nodeId) { + // The node's plate — its tree is the one on screen. + const entry = this.trees.get(h.catId); + const node = entry?.nodes.get(h.nodeId); + if (entry && node) { + this._paintAckPulse(g, node.cont.x, node.cont.y, node.w, node.h, 12, time); + } + } + this._hlLive = true; + } + /** Tier lines for one category tree (re-issuable). */ _paintRows(entry) { const { rightX, rightW, treeTop, treeH } = this.geo; @@ -1800,6 +1913,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container { // one-shot completion ignitions (rings / edge pulses / child flares) this._paintBursts(time); + // the tutorial's pointing finger (the tab's or the node's rim) + this._paintHighlight(time); // tab pips: the in-progress one pulses (the rest are static — painted // in _paintTabs on state flips)