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:
parent
95d1527ff3
commit
4334d8cc93
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ✔');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue