Add tutorial "pointing finger" highlight for starter quest

- New TutorialGuide.js beat table picks which control to pulse based on live comms/research records, so a loaded run resumes the same beat
- ActionBar and ResearchWindow gain setHighlight() with pulsing rims (ACKNOWLEDGE-style sonar rings in the console) that hide under deck standby/locked slots/closed window
- GameScene applies the highlight per frame before surfaces draw, tracks scanPressed one-shot, and reads targets from data/quests.json → tutorial
- Add tutorial section to quests.json plus tests for beat table, ActionBar rim gating, and data consistency (slot ids, console node matches starter research check)
This commit is contained in:
Brian Fertig 2026-09-13 18:48:06 -06:00
parent 95d1527ff3
commit 4334d8cc93
9 changed files with 541 additions and 0 deletions

Binary file not shown.

View File

@ -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",

View File

@ -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 ✔');

View File

@ -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

134
dev/tutorial.test.mjs Normal file
View File

@ -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');

View File

@ -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;
}

View File

@ -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.

View File

@ -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) {

View File

@ -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)