Add "Leaving the Nest" quest chain and READY tracker state
- New second main quest "Leaving the Nest" (data/quests.json) that unlocks when the starter quest's reward is claimed, with four checks: system charted, gates activated, Trestle talked, Trestle met - Quest unlock chain in QuestState (isUnlockMet / grantUnlocked) and GameScene._grantUnlockedQuests — grants on claim + scene create for loaded saves, announces via SFX, OAC quest-grant voice (new oac-quest-new comm), and NEW QUEST toast - Trestle story beats: three-brief ACK sequence (trestle-brief-1/2/3, trigger trestleBrief) armed when home jumpgates go online, and arrival beat (trestle-arrive, trigger trestleArrival) on landing in a neighbor system; CommsHub gains onStart hook so the played-ledger marks feed quest checks live - Quest check types: systemCharted, gatesActivated, trestleTalked, trestleMet — evaluated in GameScene._questCheckEval / _trestleCheck against the comms played-ledger (survives reloads; mid-sequence quit re-arms only unplayed beats) - System-complete milestone moved from research completion to the chart-completing discovery (celebrateDiscovery), one fire per system via _systemCompleteSeen set - QuestTrackerHud READY state: completed-unclaimed rows turn amber with breathing tint, percent reads "READY" (bold), one-shot flash + diamond burst on flip, CLAIM REWARD button in row popup claims in place; silent when tracker is slid off (toast + chime carry it) - New speech clips (trestle q2-01/02/03), lava-takeoff video, updated lava-land video, renamed lava-planet image, new laval-surface image - Dev: quest-ready.html/mjs capture harness for the READY state shot; tests updated across comms, quests, quest-tracker, and system-hud suites
This commit is contained in:
parent
54fbf4091c
commit
3137a800bf
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's 'Unlock Jumpgates' research is unlocked (GameScene._applyResearchEffects, the activateGates effect) — that research is only researchable once EVERY NAV point of the system is discovered (the SYSTEM tree's available hook), so the full chart and the jumpgate unlock land together, once per system (once:false re-arms per system). It arms BEFORE oac-research-complete on the same completion, so the FIFO plays the specific milestone first. Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.",
|
||||
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive: the 'Leaving the Nest' quest's arrival in a home-neighbor system (GameScene._trestleBeats on scene create; it in the played-ledger is the quest's 'Meet Trestle' witness). Reserved for future comms: researchNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.",
|
||||
"enabled": true,
|
||||
"audio": {
|
||||
"$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.",
|
||||
|
|
@ -42,6 +42,16 @@
|
|||
"audio": "assets/speech/oac/intro-02.mp3",
|
||||
"once": true
|
||||
},
|
||||
{
|
||||
"$comment": "The quest-grant voice — the run's NEW QUEST beat (trigger 'questNew'). once:false: it re-arms on EVERY quest grant — the starter on the first boot, 'Leaving the Nest' (data/quests.json → leaving_the_nest) on the starter's claim, and future unlocks — a fresh voice each time, no box (voice only; the NEW QUEST toast + the dossier already show the quest). Armed by GameScene._grantUnlockedQuests (the grant itself — starter boot + every unlock).",
|
||||
"id": "oac-quest-new",
|
||||
"from": "oac",
|
||||
"type": "audio",
|
||||
"trigger": "questNew",
|
||||
"audio": "assets/speech/oac/quest-new.mp3",
|
||||
"once": false,
|
||||
"delayMs": 1800
|
||||
},
|
||||
{
|
||||
"$comment": "Welcome beat three (the first instruction): scan the tether region. The player clicks SCAN on the deck after acknowledging this — oac-upgrade (firstScan) is the reaction to that click.",
|
||||
"id": "oac-scan",
|
||||
|
|
@ -105,7 +115,7 @@
|
|||
"delayMs": 1500
|
||||
},
|
||||
{
|
||||
"$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene._applyResearchEffects, the activateGates effect: the moment the system's 'Unlock Jumpgates' research is unlocked). That research was only researchable while EVERY NAV point of the system — planets, space stations, jump gates — was discovered (the SYSTEM tree's available hook, js/research/SystemCategory.js), so the chart-complete + jumpgate-unlock conditions hold together, and the node unlocks permanently — the comm plays once per system (once:false just lets the NEXT system re-arm it). type 'visual': OAC's box READS the milestone line and the voice rides with it, no pause — a passing sign-off, held by the busy() contract until the research console (or any modal/action) clears. Arming happens BEFORE the generic 'researchComplete' sign-off on the same completion, so the FIFO plays specific → generic. delayMs beats the completion chime (1.5 s, sfx.discovery). audioPool: the voice rolls ONE of the three discovery-complete clips at random per arm (the hub's deferred load fetches only the chosen file).",
|
||||
"$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene.celebrateDiscovery: the moment the system's NAV chart COMPLETES — its last planet / space station / jump-gate discovery — which is exactly when the 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js — the node flips live in the research console); NOT on the research's completion). One fire per system: once:false just lets the NEXT system re-arm it, and the scene's _systemCompleteSeen set keeps the fire to the chart-completing discovery (a later cluster discovery in a charted system still passes isSystemNavComplete() — clusters aren't NAV points). type 'visual': OAC's box READS the milestone line and the voice rides with it, no pause — a passing sign-off, held by the busy() contract while the research console (or any modal/action) is open. delayMs beats the discovery chime (1.5 s, sfx.discovery). audioPool: the voice rolls ONE of the three discovery-complete clips at random per arm (the hub's deferred load fetches only the chosen file).",
|
||||
"id": "oac-system-complete",
|
||||
"from": "oac",
|
||||
"type": "visual",
|
||||
|
|
@ -173,11 +183,53 @@
|
|||
"audio": "assets/speech/trestle/intro-02.mp3",
|
||||
"once": true,
|
||||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "The 'Leaving the Nest' quest's BRIEFING, beat 1 of 3 (trigger 'trestleBrief') — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — i.e. the moment the player can first jump OUT of the nest), or already-online when the quest is granted / a save loads (GameScene._trestleBeats). It lands in FRONT of the generic 'researchComplete' sign-off _completeResearch queues after the effects — the hub's FIFO plays this sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed), then OAC's sign-off. The quest's 'Talk to Trestle' witness is the SEQUENCE: all three briefs in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) complete the check (GameScene._questCheckEval); a quit mid-sequence re-arms only the unplayed beats on the next load.",
|
||||
"id": "trestle-brief-1",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "trestleBrief",
|
||||
"text": "Hey Kid. You've discovered all the important objects in the system. That means Planets, Space Stations and Jump gates. As you move around the galaxy, you can always find out how many objects a system has in the map view on your console.",
|
||||
"audio": "assets/speech/trestle/q2-01.mp3",
|
||||
"once": true,
|
||||
"delayMs": 1500
|
||||
},
|
||||
{
|
||||
"$comment": "Briefing beat 2 of 3 (trigger 'trestleBrief') — queued right behind beat 1: the hub's FIFO plays it the moment beat 1 is acknowledged (delayMs 0). He sets up the arc that follows (moving cargo, a friend named Carol who has something to deliver).",
|
||||
"id": "trestle-brief-2",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "trestleBrief",
|
||||
"text": "Listen, I could use your help. Nothing major, but I think it's time you learned how to move cargo. I have a friend here named Carol who has something we need to get delivered pronto.",
|
||||
"audio": "assets/speech/trestle/q2-02.mp3",
|
||||
"once": true,
|
||||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).",
|
||||
"id": "trestle-brief-3",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "trestleBrief",
|
||||
"text": "I'm in the system right next to you in this star cluster's capital. I'm sending the coordinates to your computer right now. Just follow the orange markers on your compass or map and we'll talk more when you get here.",
|
||||
"audio": "assets/speech/trestle/q2-03.mp3",
|
||||
"once": true,
|
||||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "The 'Leaving the Nest' quest's ARRIVAL (trigger 'trestleArrival') — Trestle's side of the gate: plays when the player LANDS in a home-neighbor system (the jump destination; a loaded save parked there too) — the payoff of his briefing's last line ('we'll talk more when you get here'). Text-only (his voice for this beat isn't recorded yet — his recorded lines are the two starter clips + the q2-01/02/03 briefs). It in the run's comms played-ledger is the quest's 'Meet Trestle in the neighboring system' witness (GameScene._questCheckEval). Armed by GameScene._trestleBeats on scene create — AFTER the boot comms, so the jump-in landing doesn't lose it to the queue race.",
|
||||
"id": "trestle-arrive",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "trestleArrival",
|
||||
"text": "There he is. You left the nest, kid… not bad for a first jump. Out here the galaxy gets a lot bigger — and a lot stranger. You're on your own now. Well… not entirely. I'll be around. Go make something of it, kid.",
|
||||
"once": true,
|
||||
"delayMs": 1500
|
||||
}
|
||||
],
|
||||
"reservedTriggers": [
|
||||
"researchNew",
|
||||
"questNew",
|
||||
"questComplete",
|
||||
"discovery",
|
||||
"landing",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
],
|
||||
"sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET",
|
||||
"starterToast": {
|
||||
"_comment": "The one-shot note when the starter quest lands on a fresh ledger (GameScene.create).",
|
||||
"_comment": "The one-shot note when a quest lands on the ledger — the starter on the run's first boot (GameScene.create) and an unlock grant (leaving_the_nest, when the starter's reward is claimed — GameScene._grantUnlockedQuests).",
|
||||
"glyph": "◆",
|
||||
"color": "#ffc94d",
|
||||
"text": "NEW QUEST — {title}"
|
||||
|
|
@ -97,6 +97,29 @@
|
|||
"minerals": 200,
|
||||
"_comment": "The completion reward — 200 Minerals, claimed via the CLAIM REWARD action (fits the ship's hold: ship.stats.mineralStorage)."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "leaving_the_nest",
|
||||
"category": "main",
|
||||
"icon": "diamond",
|
||||
"title": "Leaving the Nest",
|
||||
"description": "The nest did its job — it kept you alive long enough to learn the sky. Now the sky is yours. Chart the whole system, light its gates, and come find me out past the gate. I'll be waiting.",
|
||||
"issuer": "Trestle",
|
||||
"_comment": "The run's SECOND quest — the starter's follow-up (the 'Leaving the Nest' arc). It GRANTS the moment the starter quest is COMPLETED AND CLAIMED (unlocks.claimed — QuestState.isUnlockMet, armed by GameScene._grantUnlockedQuests on the claim itself + on scene create for loaded saves whose starter is already paid out). Trestle — the 'A Friend' behind the starter — is the issuer: his firstWorld pair (data/comms.json) promised the meeting. The four checks are the run's exit arc, witnessed by the run's COMMS played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run, so the witness survives reloads and a quit mid-sequence): the system's full NAV chart (the same set that gates the jumpgate research), the home system's gates ACTIVATED (the activateGates effect), Trestle's THREE-BRIEF sequence (trestle-brief-1/2/3, trigger 'trestleBrief' — armed when the home gates go online, GameScene._trestleBeats; ALL THREE heard = 'Talk to Trestle'), and his ARRIVAL beat when the player lands in a home-neighbor system (trestle-arrive, trigger 'trestleArrival'; 'Meet Trestle'). Both Trestle checks are read data-driven from the trigger's comms (GameScene._questCheckEval).",
|
||||
"unlocks": {
|
||||
"_comment": "The grant condition: the listed quests' rewards CLAIMED (the ledger's paid-out set) — 'unlocked as soon as the first quest is completed and its reward collected'.",
|
||||
"claimed": ["start_your_journey"]
|
||||
},
|
||||
"checks": [
|
||||
{ "type": "systemCharted", "system": "home", "label": "Discover all Planets, Space Stations and Jump Gates in the system" },
|
||||
{ "type": "gatesActivated", "system": "home", "label": "Activate System Jump Gates" },
|
||||
{ "type": "trestleTalked", "label": "Talk to Trestle" },
|
||||
{ "type": "trestleMet", "label": "Meet Trestle in the neighboring system" }
|
||||
],
|
||||
"reward": {
|
||||
"minerals": 250,
|
||||
"_comment": "The completion reward — the ship's full hold (ship.stats.mineralStorage), claimed via the CLAIM REWARD action."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
* oac-relaunch → oac-explore on tetherL2Launch, and
|
||||
* oac-research-complete (audio-only, once:false) on researchComplete,
|
||||
* oac-system-complete (visual, once:false, audioPool) on systemComplete,
|
||||
* and trestle-meetup → trestle-quest on firstWorld);
|
||||
* oac-quest-new (audio-only, once:false) on questNew, and
|
||||
* trestle-meetup → trestle-quest on firstWorld,
|
||||
* trestle-brief-1 → trestle-brief-2 → trestle-brief-3 (the
|
||||
* 'Leaving the Nest' three-brief sequence, ack) on trestleBrief,
|
||||
* trestle-arrive (ack) on trestleArrival);
|
||||
* - the reserved triggers are RESERVED (no comm uses them yet);
|
||||
* - normalizeComm drops what can't be shown (bad type / no trigger /
|
||||
* no text for a box / no speech for an audio comm / unknown sender),
|
||||
|
|
@ -73,7 +77,7 @@ const trestle = charEntry(cfg, 'trestle');
|
|||
check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4');
|
||||
|
||||
const roster = listComms(cfg);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 14);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 19);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
@ -187,7 +191,7 @@ check(
|
|||
]),
|
||||
);
|
||||
check('oac-system-complete: every pool clip ships on disk', sysPool.every((f) => existsSync(join(root, f))));
|
||||
check('oac-system-complete: beats the completion chime (delayMs > 0)', (byId['oac-system-complete']?.delayMs ?? 0) > 0);
|
||||
check('oac-system-complete: beats the discovery chime (delayMs > 0)', (byId['oac-system-complete']?.delayMs ?? 0) > 0);
|
||||
|
||||
// The 200-mineral threshold — OAC sends the player home to build.
|
||||
check(
|
||||
|
|
@ -251,10 +255,81 @@ check(
|
|||
check('trestle-quest: the first-quest/claim brief (the brief, verbatim)', byId['trestle-quest']?.text === 'Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!');
|
||||
check('trestle-quest: speaks trestle/intro-02.mp3', byId['trestle-quest']?.audio === 'assets/speech/trestle/intro-02.mp3');
|
||||
|
||||
// The quest-grant voice — the NEW QUEST beat (audio-only, once:false:
|
||||
// every grant re-arms it — the starter boot + every unlock, the scene's
|
||||
// _grantUnlockedQuests triggers 'questNew').
|
||||
check(
|
||||
'oac-quest-new: the quest-grant voice (audio, questNew, once:false)',
|
||||
!!byId['oac-quest-new'] &&
|
||||
byId['oac-quest-new'].from === 'oac' &&
|
||||
byId['oac-quest-new'].type === 'audio' &&
|
||||
byId['oac-quest-new'].trigger === 'questNew' &&
|
||||
byId['oac-quest-new'].once === false,
|
||||
);
|
||||
check('oac-quest-new: speaks oac/quest-new.mp3', byId['oac-quest-new']?.audio === 'assets/speech/oac/quest-new.mp3');
|
||||
check('oac-quest-new: the clip ships on disk', existsSync(join(root, byId['oac-quest-new']?.audio ?? '')));
|
||||
check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new']?.delayMs ?? 0) > 0);
|
||||
|
||||
// The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the
|
||||
// home gates go online; ALL THREE in the run's comms played-ledger are
|
||||
// the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and
|
||||
// the arrival (the player lands in the neighboring system; it in the
|
||||
// ledger is the 'Meet Trestle' witness).
|
||||
check(
|
||||
'trestle-brief-1: the gates-online briefing, beat 1/3 (ack, trestleBrief, trestle, once)',
|
||||
!!byId['trestle-brief-1'] &&
|
||||
byId['trestle-brief-1'].from === 'trestle' &&
|
||||
byId['trestle-brief-1'].type === 'ack' &&
|
||||
byId['trestle-brief-1'].trigger === 'trestleBrief' &&
|
||||
byId['trestle-brief-1'].once === true,
|
||||
);
|
||||
check('trestle-brief-1: the charted-system brief (the brief, verbatim)', byId['trestle-brief-1']?.text === 'Hey Kid. You\'ve discovered all the important objects in the system. That means Planets, Space Stations and Jump gates. As you move around the galaxy, you can always find out how many objects a system has in the map view on your console.');
|
||||
check('trestle-brief-1: speaks trestle/q2-01.mp3', byId['trestle-brief-1']?.audio === 'assets/speech/trestle/q2-01.mp3');
|
||||
check('trestle-brief-1: the clip ships on disk', existsSync(join(root, byId['trestle-brief-1']?.audio ?? '')));
|
||||
check('trestle-brief-1: lands after the gates-online beat (delayMs > 0)', (byId['trestle-brief-1']?.delayMs ?? 0) > 0);
|
||||
check(
|
||||
'trestle-brief-2: the cargo-setup brief, beat 2/3 (ack, trestleBrief, once, immediate)',
|
||||
!!byId['trestle-brief-2'] &&
|
||||
byId['trestle-brief-2'].from === 'trestle' &&
|
||||
byId['trestle-brief-2'].type === 'ack' &&
|
||||
byId['trestle-brief-2'].trigger === 'trestleBrief' &&
|
||||
byId['trestle-brief-2'].once === true &&
|
||||
(byId['trestle-brief-2']?.delayMs ?? 0) === 0,
|
||||
);
|
||||
check('trestle-brief-2: the cargo/Carol brief (the brief, verbatim)', byId['trestle-brief-2']?.text === 'Listen, I could use your help. Nothing major, but I think it\'s time you learned how to move cargo. I have a friend here named Carol who has something we need to get delivered pronto.');
|
||||
check('trestle-brief-2: speaks trestle/q2-02.mp3', byId['trestle-brief-2']?.audio === 'assets/speech/trestle/q2-02.mp3');
|
||||
check('trestle-brief-2: the clip ships on disk', existsSync(join(root, byId['trestle-brief-2']?.audio ?? '')));
|
||||
check(
|
||||
'trestle-brief-3: the rendezvous brief, beat 3/3 (ack, trestleBrief, once, immediate)',
|
||||
!!byId['trestle-brief-3'] &&
|
||||
byId['trestle-brief-3'].from === 'trestle' &&
|
||||
byId['trestle-brief-3'].type === 'ack' &&
|
||||
byId['trestle-brief-3'].trigger === 'trestleBrief' &&
|
||||
byId['trestle-brief-3'].once === true &&
|
||||
(byId['trestle-brief-3']?.delayMs ?? 0) === 0,
|
||||
);
|
||||
check('trestle-brief-3: the coordinates/orange-marker brief (the brief, verbatim)', byId['trestle-brief-3']?.text === 'I\'m in the system right next to you in this star cluster\'s capital. I\'m sending the coordinates to your computer right now. Just follow the orange markers on your compass or map and we\'ll talk more when you get here.');
|
||||
check('trestle-brief-3: speaks trestle/q2-03.mp3', byId['trestle-brief-3']?.audio === 'assets/speech/trestle/q2-03.mp3');
|
||||
check('trestle-brief-3: the clip ships on disk', existsSync(join(root, byId['trestle-brief-3']?.audio ?? '')));
|
||||
check(
|
||||
'trestle-arrive: the arrival (ack, trestleArrival, trestle, once, text-only)',
|
||||
!!byId['trestle-arrive'] &&
|
||||
byId['trestle-arrive'].from === 'trestle' &&
|
||||
byId['trestle-arrive'].type === 'ack' &&
|
||||
byId['trestle-arrive'].trigger === 'trestleArrival' &&
|
||||
byId['trestle-arrive'].once === true,
|
||||
);
|
||||
check('trestle-arrive: the arrival brief (you left the nest)', byId['trestle-arrive']?.text.includes('You left the nest'));
|
||||
check('trestle-arrive: no speech clip yet (the voice is text-only)', byId['trestle-arrive']?.audio === null || byId['trestle-arrive']?.audio === '');
|
||||
check('trestle-arrive: lands after the jump-in beat (delayMs > 0)', (byId['trestle-arrive']?.delayMs ?? 0) > 0);
|
||||
|
||||
check(
|
||||
'tetherResearch arms the pair in config order (minerals → fly-mine)',
|
||||
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
|
||||
);
|
||||
check('questNew arms the quest-grant voice', commsForTrigger(cfg, 'questNew').length === 1 && commsForTrigger(cfg, 'questNew')[0].id === 'oac-quest-new');
|
||||
check('trestleBrief arms the three-brief sequence in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleBrief').map((c) => c.id)) === JSON.stringify(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3']));
|
||||
check('trestleArrival arms the arrival', commsForTrigger(cfg, 'trestleArrival').length === 1 && commsForTrigger(cfg, 'trestleArrival')[0].id === 'trestle-arrive');
|
||||
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1 && commsForTrigger(cfg, 'firstCluster')[0].id === 'oac-first-cluster');
|
||||
check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete');
|
||||
check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete');
|
||||
|
|
@ -269,7 +344,7 @@ check(
|
|||
|
||||
check(
|
||||
'the reserved triggers are reserved (no comm uses them yet)',
|
||||
['researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
|
||||
['researchNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
|
||||
(t) => !roster.some((c) => c.trigger === t),
|
||||
),
|
||||
);
|
||||
|
|
@ -331,9 +406,9 @@ warnOn = true;
|
|||
// unknown senders are dropped by the ROSTER (listComms), not normalize
|
||||
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
|
||||
const ghostList = listComms(withGhost);
|
||||
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 14 && ghostList[0].id === 'oac-intro');
|
||||
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 19 && ghostList[0].id === 'oac-intro');
|
||||
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 14 && dupList[0].id === 'oac-intro');
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 19 && dupList[0].id === 'oac-intro');
|
||||
warnOn = true;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev: the quest tracker's READY state</title>
|
||||
<!-- This page lives in /dev, but the game's relative asset paths are rooted
|
||||
at the project root — resolve them against it. ("../" keeps this
|
||||
working even if the project is served from a subdirectory.) -->
|
||||
<base href="../" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/quest-ready.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Dev-only: the quest tracker's READY state (the "A + C" attract), shot live.
|
||||
*
|
||||
* Boots the GameScene (deterministic seed), waits for the open dossier to
|
||||
* auto-fold (the tracker slides in), then completes the starter quest all
|
||||
* at once — the flip fires the one-shot moment (flash + ◆ burst) and the
|
||||
* same-frame repaint goes READY (amber row). `window.__QUEST_READY__` is
|
||||
* set the instant the burst is still alive, so the capture lands on it:
|
||||
*
|
||||
* node dev/server.mjs 8080
|
||||
* node dev/shot-firefox.mjs http://localhost:8080/dev/quest-ready.html \
|
||||
* "window.__QUEST_READY__" /tmp/quest-ready.png 45000
|
||||
*/
|
||||
import Phaser from '../js/vendor/phaser.js';
|
||||
import { config } from '../js/config/Config.js';
|
||||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||||
import { createGameConfig } from '../js/config/GameConfig.js';
|
||||
import { GameScene } from '../js/scenes/GameScene.js';
|
||||
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
const gameConfig = createGameConfig();
|
||||
globalThis.__ORBIT_DEV_SEED = '7'; // deterministic galaxy — reproducible shots
|
||||
|
||||
// Dev: surface console errors on the flag (a shot with a READY row AND a
|
||||
// console error is a regression, even if the shot looks fine).
|
||||
const __errors = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
const origErr = console.error.bind(console);
|
||||
console.error = (...a) => { __errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||
window.addEventListener('error', (e) => __errors.push(String(e.message)));
|
||||
}
|
||||
|
||||
gameConfig.scene = [GameScene, SurfaceScene];
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
window.game = game;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 30000) => {
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
if (Date.now() - t0 > ms) return null;
|
||||
await sleep(200);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const s = await until(() => (game.scene.getScene('GameScene')?.questTracker ? game.scene.getScene('GameScene') : null));
|
||||
if (!s) { window.__QUEST_READY__ = { error: 'scene never became ready' }; return; }
|
||||
|
||||
// Wait for the open dossier to auto-fold → the tracker slides in
|
||||
// (the tracker's first poll establishes its READY baseline while the
|
||||
// quest is still open, so the flip below is a true transition).
|
||||
await until(() => s.questTracker.shown === true);
|
||||
await sleep(250); // let the slide fully settle
|
||||
|
||||
// Complete the starter quest (all four checks), live — the flip:
|
||||
s.totalMined = 200;
|
||||
s.researchState.isUnlocked = (cat, node) => true;
|
||||
s.tetherLevelFor = (name) => 2;
|
||||
s.discoveredWorldCount = () => 1;
|
||||
s._questRefresh(); // toast + chime + tracker repaint (the same frame)
|
||||
await sleep(380); // inside the burst's life (700–1000 ms) — catch the glyphs
|
||||
|
||||
const snap = s.questSnapshot();
|
||||
const q = snap.quests.find((x) => x.id === 'start_your_journey');
|
||||
window.__QUEST_READY__ = {
|
||||
trackerShown: s.questTracker.shown,
|
||||
questComplete: q?.complete === true,
|
||||
questClaimed: q?.claimed === true,
|
||||
baselineHasStarter: s.questTracker._readyBaseline?.has('start_your_journey') === true,
|
||||
errors: __errors.slice(0, 3),
|
||||
};
|
||||
// This geckodriver can't read the flag back (execute/sync → null), so
|
||||
// paint the verdict on the canvas too — the screenshot IS the readout.
|
||||
s.add.text(16, 690, `READY-FLAG ${JSON.stringify(window.__QUEST_READY__)}`,
|
||||
{ fontFamily: 'monospace', fontSize: '11px', color: '#7ce8a4', align: 'left' })
|
||||
.setOrigin(0, 1).setScrollFactor(0).setDepth(9000);
|
||||
} catch (err) {
|
||||
window.__QUEST_READY__ = { error: String(err?.stack ?? err) };
|
||||
}
|
||||
}, 400);
|
||||
|
|
@ -125,6 +125,10 @@ const PhaserStub = {
|
|||
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub },
|
||||
Geom: { Rectangle: RectStub },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
// Filter plumbing (js/visuals/SystemEffects.js — the class declarations
|
||||
// the GameScene import pulls in; only the base classes are needed):
|
||||
Filters: { Controller: class {} },
|
||||
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
|
||||
Math: {
|
||||
Linear: (a, b, t) => a + (b - a) * t,
|
||||
FloatBetween: (a, b) => a + Math.random() * (b - a),
|
||||
|
|
@ -770,7 +774,8 @@ const T0 = 100000;
|
|||
const checksAfter = snapCheckStates(s);
|
||||
check('the checklist tracks LIVE: 0/4 → 4/4 as the run progresses',
|
||||
checksBefore.every((c) => c.done === false) && checksAfter.every((c) => c.done === true));
|
||||
check('the % readout hits 100%', s.add.texts.some((t) => t.text === '100%'));
|
||||
check('a READY row flips its % readout to READY (amber, bold)',
|
||||
s.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d' && t.style?.fontStyle === 'bold'));
|
||||
|
||||
// The scene's priority actions (real setPriorityQuest / clearPriorityQuest).
|
||||
const saved = config.data;
|
||||
|
|
@ -860,6 +865,116 @@ const T0 = 100000;
|
|||
/onSetPriority/.test(QuestWindow.toString()) && /getPriority/.test(QuestWindow.toString()));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 8) READY — a completed, unclaimed quest: the amber row (the persistent
|
||||
// attract), the one-shot moment (flash + ◆ burst — once, shown only),
|
||||
// and the popup's CLAIM REWARD (claims in place via onClaim)
|
||||
// ===========================================================================
|
||||
{
|
||||
const recordTweens = (sc) => {
|
||||
const log = [];
|
||||
const orig = sc.tweens.add;
|
||||
sc.tweens.add = (opts) => {
|
||||
log.push(opts);
|
||||
return orig(opts);
|
||||
};
|
||||
return log;
|
||||
};
|
||||
const glyphs = (log) => log.filter((t) => t.targets instanceof FakeText && t.targets.text === '◆');
|
||||
|
||||
const scene = makeScene();
|
||||
scene.time.now = 5000;
|
||||
const snap = {
|
||||
categories: [],
|
||||
quests: [{ id: 'q_open', category: 'main', title: 'Open Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }],
|
||||
};
|
||||
const claimed = [];
|
||||
const tweens = recordTweens(scene);
|
||||
const trk = new QuestTrackerHud(scene, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snap,
|
||||
resolvePriority: () => null,
|
||||
onClaim: (id) => claimed.push(id),
|
||||
});
|
||||
trk.show(scene.time.now);
|
||||
trk.update(scene.time.now);
|
||||
const rowTints = () => trk._kids.filter((k) => k instanceof FakeGraphics && !k.input).length;
|
||||
check('an open row stays plain: 0% readout, no READY label, no tint',
|
||||
scene.add.texts.some((t) => t.text === '0%') &&
|
||||
!scene.add.texts.some((t) => t.text === 'READY') &&
|
||||
rowTints() === 0);
|
||||
|
||||
// The quest COMPLETES (a check flips) — the same-frame repaint goes READY.
|
||||
snap.quests[0].checks[0].done = true;
|
||||
snap.quests[0].complete = true;
|
||||
snap.quests[0].progress = '1/1';
|
||||
tweens.length = 0; // (the constructor's first poll is not the moment)
|
||||
trk.update(scene.time.now);
|
||||
check('READY row: the % readout flips to READY (amber, bold)',
|
||||
scene.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d' && t.style?.fontStyle === 'bold'));
|
||||
check('READY row: the title brightens to ink',
|
||||
(() => {
|
||||
const t = scene.add.texts.find((x) => x.text === 'OPEN QUEST' && x.x === 24);
|
||||
return !!t && t.color === '#eaf6ff';
|
||||
})());
|
||||
check('READY row: the breathing tint is up (a plate + a tint per row)', rowTints() === 1);
|
||||
check('READY row: the tint breathes (a slow yoyo sine, 0.1 → 0.3)',
|
||||
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0.3 && t.repeat === -1 && t.yoyo === true));
|
||||
check('the MOMENT fired: a row-flash + four ◆ glyph bursts (tweened)',
|
||||
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0) &&
|
||||
glyphs(tweens).length === 4);
|
||||
|
||||
// NO re-fire: a drop-and-re-flip of the SAME quest stays silent.
|
||||
tweens.length = 0;
|
||||
snap.quests[0].checks[0].done = false;
|
||||
snap.quests[0].complete = false;
|
||||
trk.update(scene.time.now);
|
||||
snap.quests[0].checks[0].done = true;
|
||||
snap.quests[0].complete = true;
|
||||
trk.update(scene.time.now);
|
||||
check('a re-flip of the SAME quest does not re-burst (once per quest)', glyphs(tweens).length === 0);
|
||||
|
||||
// The popup: CLAIM REWARD — FIRST, and it claims in place.
|
||||
const plate = trk._kids.find((k) => k instanceof FakeGraphics && k.input);
|
||||
plate.fire('pointerdown');
|
||||
check('the READY row\u2019s popup offers CLAIM REWARD FIRST (before SET/VIEW)',
|
||||
trk._popup.btns.length === 3 &&
|
||||
trk._popup.btns[0].labelText.text === 'CLAIM REWARD' &&
|
||||
trk._popup.btns[1].labelText.text === 'SET PRIORITY' &&
|
||||
trk._popup.btns[2].labelText.text === 'VIEW QUEST');
|
||||
trk._popup.btns[0].panel.fire('pointerdown');
|
||||
check('CLAIM REWARD → onClaim(q_open) in place + the popup closes',
|
||||
claimed.length === 1 && claimed[0] === 'q_open' && trk.popupOpen === false);
|
||||
trk.destroy();
|
||||
|
||||
// While SLID OFF, the flip is silent (the scene\u2019s toast + chime carry it)
|
||||
// — but the row still paints READY when the tracker re-shows.
|
||||
const sceneH = makeScene();
|
||||
sceneH.time.now = 5100;
|
||||
const snapH = {
|
||||
categories: [],
|
||||
quests: [{ id: 'q2', category: 'main', title: 'Hidden Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }],
|
||||
};
|
||||
const tweensH = recordTweens(sceneH);
|
||||
const trkH = new QuestTrackerHud(sceneH, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snapH,
|
||||
resolvePriority: () => null,
|
||||
});
|
||||
check('slid off: the tracker starts hidden (the dossier is open)', trkH.state === 'hidden' && trkH.shown === false);
|
||||
snapH.quests[0].checks[0].done = true;
|
||||
snapH.quests[0].complete = true;
|
||||
tweensH.length = 0;
|
||||
trkH.update(sceneH.time.now);
|
||||
check('a flip while slid off: NO burst (the toast + chime carry it)',
|
||||
glyphs(tweensH).length === 0 && !tweensH.some((t) => t.targets instanceof FakeGraphics && t.alpha === 0));
|
||||
check('…and the row is READY-styled when it re-shows',
|
||||
sceneH.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d'));
|
||||
trkH.destroy();
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} quest-tracker test(s) FAILED`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@
|
|||
* - the starter quest is well-formed: Main Story, one starter flag,
|
||||
* a non-empty checklist of KNOWN requirement types, a reward that
|
||||
* fits the ship's hold;
|
||||
* - the 'Leaving the Nest' quest (the starter's follow-up) is
|
||||
* well-formed: its four checks (the system's full nav chart, the
|
||||
* home gates activated, Trestle's two story beats), its unlock
|
||||
* requirement (the starter's reward CLAIMED), and its reward fits
|
||||
* the ship's hold;
|
||||
* - the 'research' check points at a REAL node of its category's tree
|
||||
* (data/research/<category>.json — the check would never complete
|
||||
* otherwise);
|
||||
* - the quest ledger (js/quests/QuestState.js) behaves: idempotent
|
||||
* give/claim, claimed ⊆ granted, a save round-trip, and a
|
||||
* corrupted save degrades to an empty ledger (not a throw);
|
||||
* give/claim, claimed ⊆ granted, a save round-trip, a corrupted
|
||||
* save degrades to an empty ledger (not a throw), and the unlock
|
||||
* chain (grantUnlocked arms the follow-up exactly when its
|
||||
* prerequisite's reward is claimed);
|
||||
* - the PRIORITY (the tracker HUD's featured quest): set/clear,
|
||||
* the auto-assignment (first active story quest), the cleared-
|
||||
* flag sticks until a grant/claim, and the save round-trip;
|
||||
|
|
@ -46,7 +53,6 @@ config.init({ quests, ship });
|
|||
const { QuestState, questDefs, questDef, starterQuestIds } = await import(
|
||||
join(root, 'js/quests/QuestState.js')
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
|
|
@ -113,7 +119,7 @@ check('starter: reward fits the ship\u2019s hold',
|
|||
|
||||
// The requirement types the scene evaluates (GameScene._questCheckEval)
|
||||
// — a typo here would be a checklist row that never completes.
|
||||
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']);
|
||||
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet']);
|
||||
check('starter: every check type is a known live check',
|
||||
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||||
|
||||
|
|
@ -137,6 +143,36 @@ for (const c of starter?.checks ?? []) {
|
|||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 5b. The 'Leaving the Nest' quest — the starter's follow-up chain
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const q = (quests.quests ?? []).find((x) => x.id === 'leaving_the_nest');
|
||||
check('nest: the quest is defined', !!q);
|
||||
check('nest: sits in the MAIN story', q?.category === 'main');
|
||||
check('nest: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
|
||||
check('nest: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
|
||||
check('nest: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
|
||||
// The grant condition — the starter's reward CLAIMED ('unlocked as
|
||||
// soon as the first quest is completed and its reward collected').
|
||||
check('nest: unlocks on the starter quest being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify([starter.id]));
|
||||
check('nest: the starter id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
|
||||
// The four checks, in order — the run's exit arc.
|
||||
const labels = (q?.checks ?? []).map((c) => c.label);
|
||||
check('nest: the four checklist lines (the brief, verbatim)', JSON.stringify(labels) === JSON.stringify([
|
||||
'Discover all Planets, Space Stations and Jump Gates in the system',
|
||||
'Activate System Jump Gates',
|
||||
'Talk to Trestle',
|
||||
'Meet Trestle in the neighboring system',
|
||||
]));
|
||||
check('nest: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
|
||||
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
|
||||
check('nest: systemCharted names the home system', byType?.systemCharted?.system === 'home');
|
||||
check('nest: gatesActivated names the home system', byType?.gatesActivated?.system === 'home');
|
||||
check('nest: the reward is a positive integer (minerals)', Number.isInteger(q?.reward?.minerals) && q.reward.minerals > 0);
|
||||
check('nest: the reward fits the ship\u2019s hold', q?.reward?.minerals <= (ship.stats?.mineralStorage ?? 0));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6. The quest ledger — QuestState (give/claim/save-restore)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -167,6 +203,31 @@ check('helpers: starterQuestIds() is exactly [the starter]',
|
|||
check('ledger: save round-trip keeps granted + claimed',
|
||||
restored.isGranted(starter.id) && restored.isClaimed(starter.id));
|
||||
}
|
||||
|
||||
// The UNLOCK CHAIN — the 'Leaving the Nest' grant requirement:
|
||||
// - unclaimed starter → nothing new grants;
|
||||
// - claimed starter → exactly [leaving_the_nest] grants;
|
||||
// - idempotent (a re-call is a no-op).
|
||||
{
|
||||
const st = new QuestState();
|
||||
st.give(starter.id);
|
||||
check('unlock: fresh — the follow-up is NOT granted (starter unclaimed)',
|
||||
st.grantUnlocked().length === 0 && !st.isGranted('leaving_the_nest'));
|
||||
check('unlock: the starter\u2019s claim arms the follow-up', (() => {
|
||||
st.claim(starter.id);
|
||||
return JSON.stringify(st.grantUnlocked()) === JSON.stringify(['leaving_the_nest']);
|
||||
})());
|
||||
check('unlock: the follow-up is now held', st.isGranted('leaving_the_nest'));
|
||||
check('unlock: grantUnlocked() is idempotent', st.grantUnlocked().length === 0);
|
||||
check('unlock: a pre-follow-up save (no leaving_the_nest) still loads', (() => {
|
||||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [starter.id] });
|
||||
return r.isGranted(starter.id) && r.isClaimed(starter.id) && !r.isGranted('leaving_the_nest');
|
||||
})());
|
||||
check('unlock: a save claiming a quest it does not hold drops the claim', (() => {
|
||||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: ['leaving_the_nest'] });
|
||||
return r.isGranted(starter.id) && !r.isClaimed('leaving_the_nest') && !r.isClaimed(starter.id);
|
||||
})());
|
||||
}
|
||||
// A corrupted save degrades — never throws, never pays out a quest the
|
||||
// player does not hold.
|
||||
{
|
||||
|
|
@ -216,22 +277,23 @@ check('helpers: starterQuestIds() is exactly [the starter]',
|
|||
return st2.setPriority(starter.id) === false;
|
||||
})());
|
||||
}
|
||||
// Multi-quest behavior — the real data set has ONE quest today (side
|
||||
// quests are deferred by design), so the ordering rules are pinned on a
|
||||
// synthetic set: a second MAIN + one SIDE (config is a plain singleton —
|
||||
// re-init for the test, restore after).
|
||||
// Multi-quest behavior — the real data set has the starter + the
|
||||
// 'Leaving the Nest' follow-up today (side quests are deferred by
|
||||
// design), so the ordering rules are pinned on a synthetic set: a
|
||||
// second MAIN + one SIDE (config is a plain singleton — re-init for the
|
||||
// test, restore after).
|
||||
{
|
||||
const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 20, label: 'MINE 20' }], reward: { minerals: 20 } };
|
||||
const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } };
|
||||
const savedConfig = config.data;
|
||||
config.init({ ...config.data, quests: { ...quests, quests: [...quests.quests, MAIN2, SIDE1] } });
|
||||
try {
|
||||
const order = questDefs().map((q) => q.id); // [starter, MAIN2, SIDE1] — file order
|
||||
const order = questDefs().map((q) => q.id); // file order
|
||||
const main = questDefs().filter((q) => q.category === 'main').map((q) => q.id);
|
||||
const side = questDefs().filter((q) => q.category === 'side').map((q) => q.id);
|
||||
const display = [...main, ...side];
|
||||
check('priority: display order is main story first, then side quests',
|
||||
JSON.stringify(display) === JSON.stringify([order[0], order[1], order[2]]));
|
||||
JSON.stringify(display) === JSON.stringify(order));
|
||||
|
||||
const st = new QuestState();
|
||||
for (const id of display) st.give(id);
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ const PhaserStub = {
|
|||
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub },
|
||||
Geom: { Rectangle: class {} },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
// Filter plumbing (js/visuals/SystemEffects.js — the class declarations
|
||||
// the GameScene import pulls in; only the base classes are needed):
|
||||
Filters: { Controller: class {} },
|
||||
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
|
||||
Math: {
|
||||
Linear: (a, b, t) => a + (b - a) * t,
|
||||
FloatBetween: (a, b) => a + Math.random() * (b - a),
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export class CommsHub {
|
|||
* the mineral bar's lower-right (the box docks
|
||||
* below it; null → the standard corner fallback)
|
||||
* onPause?: (on: boolean) => void, the ack pause's scene-side effects
|
||||
* onStart?: (comm: object) => void, the comm STARTED playing (its id
|
||||
* is on the run's played-ledger
|
||||
* already — scene bookkeeping
|
||||
* that rides on a comm starting)
|
||||
* }} [o]
|
||||
*/
|
||||
constructor(scene, o = {}) {
|
||||
|
|
@ -78,6 +82,7 @@ export class CommsHub {
|
|||
this.busy = typeof o.busy === 'function' ? o.busy : null;
|
||||
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
|
||||
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
|
||||
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
|
||||
|
||||
this.queue = []; // { def, dueAt, assets, loadingDone }
|
||||
this.current = null; // the comm in play (its def)
|
||||
|
|
@ -238,6 +243,17 @@ export class CommsHub {
|
|||
this.current = def;
|
||||
this._voiceDone = false;
|
||||
this.played.add(def.id); // the run has heard it (or is hearing it)
|
||||
// The scene's bookkeeping for the STARTED comm (the 'Leaving the
|
||||
// Nest' quest's Trestle witnesses flip on exactly this ledger mark —
|
||||
// the checklist row ticks and the READY moment rides on it). A
|
||||
// failing hook must not hold the play.
|
||||
if (this.onStart) {
|
||||
try {
|
||||
this.onStart(def);
|
||||
} catch (e) {
|
||||
console.warn('[comms] onStart hook failed', e);
|
||||
}
|
||||
}
|
||||
// The ack comm freezes the action FIRST (before the box slides in,
|
||||
// before the voice) — the ship is already parked as the box lands.
|
||||
// Visual/audio comms never pause (the agreed contract — they are the
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
* Only the two things the run owns are state:
|
||||
*
|
||||
* granted — which quest ids the player HOLDS (the starter quest is
|
||||
* granted on the run's first boot; future quests are granted
|
||||
* by whatever event issues them)
|
||||
* granted on the run's first boot; the run's chain quests are
|
||||
* granted by their unlock requirement — see isUnlockMet / grantUnlocked)
|
||||
* claimed — which of the held quests have PAID OUT their reward
|
||||
* priority — the ONE priority quest the tracker HUD features (title +
|
||||
* live checklist). The player sets/clears it; the ledger
|
||||
|
|
@ -42,6 +42,21 @@ export function starterQuestIds() {
|
|||
.map((q) => q.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quest's unlock requirement (data/quests.json → quests[].unlocks):
|
||||
* claimed: [ids] — the quests whose rewards must be CLAIMED (the
|
||||
* ledger's paid-out set) before this one is granted.
|
||||
* Absent/empty = no requirement (the quest grants on first sight — the
|
||||
* boot's own are flagged starter:true instead). Pure: data + the ledger.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUnlockMet(state, id) {
|
||||
const def = questDef(id);
|
||||
if (!def || !state) return false;
|
||||
const req = Array.isArray(def?.unlocks?.claimed) ? def.unlocks.claimed : [];
|
||||
return req.every((x) => typeof x === 'string' && x !== '' && state.claimed.has(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* A held-quest ledger. The ids come from data/quests.json (validated
|
||||
* against it on load — a save pointing at a removed quest just drops it).
|
||||
|
|
@ -71,6 +86,24 @@ export class QuestState {
|
|||
return this.granted.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant every not-yet-held quest whose unlock requirement (unlocks.
|
||||
* claimed) is met. The starter quests are the boot's own (grant via
|
||||
* starterQuestIds on a fresh ledger); this is the RUN's chain — the
|
||||
* claim that pays one quest is what arms the next (the scene calls it
|
||||
* on the claim + on scene create for loaded saves). give() is
|
||||
* idempotent, so re-calls are silent no-ops.
|
||||
* @returns {string[]} the NEWLY granted ids (data order)
|
||||
*/
|
||||
grantUnlocked() {
|
||||
const fresh = [];
|
||||
for (const def of questDefs()) {
|
||||
if (!def || this.granted.has(def.id) || def.starter === true) continue;
|
||||
if (isUnlockMet(this, def.id) && this.give(def.id)) fresh.push(def.id);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
isClaimed(id) {
|
||||
return this.claimed.has(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ export function captureState(scene, now) {
|
|||
quests: scene.questState ? scene.questState.toJSON() : null,
|
||||
// Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once`
|
||||
// comms the run has heard, so a load doesn't re-fire them and a new
|
||||
// game re-plays the welcome). The ledger lives in the registry
|
||||
// game re-plays the welcome — INCLUDING Trestle's 'Leaving the Nest'
|
||||
// beats: trestle-brief-1/2/3 + trestle-arrive, the witnesses of the
|
||||
// quest's two social checks, GameScene._trestleCheck). The ledger
|
||||
// lives in the registry
|
||||
// ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating
|
||||
// comms has no field; the restore stages an empty set (old saves
|
||||
// load).
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { SavePanel } from '../ui/SavePanel.js';
|
|||
import { SaveManager } from '../save/SaveManager.js';
|
||||
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||
import { CommsHub } from '../comms/CommsHub.js';
|
||||
import { commsForTrigger } from '../comms/CommsModel.js';
|
||||
import { TetherField } from '../tether/TetherField.js';
|
||||
import { Mining } from '../mining/Mining.js';
|
||||
import { MiningPopup } from '../ui/MiningPopup.js';
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
systemIdOfGatesNode,
|
||||
buildSystemTree,
|
||||
isNavComplete,
|
||||
navChart,
|
||||
activationKeys,
|
||||
applyActivation,
|
||||
} from '../research/SystemCategory.js';
|
||||
|
|
@ -908,6 +910,16 @@ export class GameScene extends Phaser.Scene {
|
|||
this.ship.stop();
|
||||
if (this.mining && this.mining.isActive) this.mining.stop();
|
||||
},
|
||||
// The comm has STARTED (its id is on the run's played-ledger): the
|
||||
// 'Leaving the Nest' quest's Trestle witnesses flip on exactly this
|
||||
// moment — all three briefs in the ledger = 'Talk to Trestle' done,
|
||||
// the arrival in it = 'Meet Trestle' done (GameScene._trestleCheck
|
||||
// reads the ledger live). Refresh the quest ledger so the checklist
|
||||
// row ticks over and, if the quest just completed, the READY moment
|
||||
// (toast + chime) carries it when the tracker is slid off.
|
||||
onStart: (def) => {
|
||||
if (def && def.from === 'trestle') this._questRefresh();
|
||||
},
|
||||
});
|
||||
|
||||
// The run's FIRST rock field (the 'firstCluster' comm beat — see
|
||||
|
|
@ -917,6 +929,13 @@ export class GameScene extends Phaser.Scene {
|
|||
// re-play a comm the ledger already holds).
|
||||
this._firstClusterSeen = false;
|
||||
this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness)
|
||||
// The systems whose NAV chart COMPLETED this scene life (the
|
||||
// 'systemComplete' milestone — oac-system-complete, see
|
||||
// celebrateDiscovery): one fire per system. A later asteroid-cluster
|
||||
// discovery in an already-charted system still passes
|
||||
// isSystemNavComplete() (clusters aren't NAV points), so the set —
|
||||
// not the chart state — is the arming gate.
|
||||
this._systemCompleteSeen = new Set();
|
||||
|
||||
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
|
||||
// comm beat — see onOre): same one-shot shape; the threshold check
|
||||
|
|
@ -1069,7 +1088,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.questState = new QuestState();
|
||||
this.registry.set('quests', this.questState);
|
||||
}
|
||||
this._questAnnounced = new Set(); // the 'QUEST READY' toasts, once each
|
||||
this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each
|
||||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.questSnapshot(),
|
||||
onClaim: (id) => this.claimQuest(id),
|
||||
|
|
@ -1170,6 +1189,13 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
}
|
||||
// The RUN's chain (data/quests.json → quests[].unlocks): the quests
|
||||
// whose unlock requirement (unlocks.claimed — the reward CLAIMED) is
|
||||
// met land on the ledger now. On a fresh run the starter is unclaimed
|
||||
// — a no-op; on a LOADED save whose starter is already paid out,
|
||||
// 'Leaving the Nest' grants here (a fresh run's later claim grants it
|
||||
// via claimQuest → the same method; give() is idempotent either way).
|
||||
this._grantUnlockedQuests();
|
||||
// The field's native set (home + the activated gates) is above; the
|
||||
// tethers the player INSTALLED on this system's other worlds are
|
||||
// world state (built records) — re-anchor them on their worlds, since
|
||||
|
|
@ -1491,6 +1517,15 @@ export class GameScene extends Phaser.Scene {
|
|||
// run the count is zero so this is a no-op; the hub's ledger /
|
||||
// in-flight guards keep the pair a one-shot either way.
|
||||
if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld');
|
||||
// The 'Leaving the Nest' beats (Trestle's briefing + arrival —
|
||||
// data/comms.json: trestle-brief-1/2/3 / trestle-arrive): the scene-
|
||||
// create arm covers the loaded-save case — a save parked in the
|
||||
// neighboring system (the arrival's witness already earned) or one
|
||||
// whose home gates were already online (the briefing's) still gets
|
||||
// its beats. _trestleBeats is the guard (quest held + unclaimed) and
|
||||
// the comm's once-ledger dedupes the rest (a mid-sequence quit re-
|
||||
// arms only the unplayed briefs).
|
||||
this._trestleBeats();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1759,6 +1794,7 @@ export class GameScene extends Phaser.Scene {
|
|||
onClearPriority: () => this.clearPriorityQuest(),
|
||||
onViewQuest: (id) => this.openQuestAt(id),
|
||||
onShowMore: (id) => this.openQuestAt(id),
|
||||
onClaim: (id) => this.claimQuest(id),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3085,6 +3121,23 @@ export class GameScene extends Phaser.Scene {
|
|||
this._firstWorldSeen = true;
|
||||
this.comms?.trigger('firstWorld');
|
||||
}
|
||||
|
||||
// The system-complete milestone (data/comms.json: systemComplete —
|
||||
// oac-system-complete): the moment this system's NAV chart COMPLETES —
|
||||
// the discovery that makes its 'Unlock Jumpgates' research AVAILABLE
|
||||
// (the SYSTEM tree's available hook, js/research/SystemCategory.js —
|
||||
// the node flips live in the research console), NOT the research's
|
||||
// completion. OAC reads the milestone; the voice rolls one of the
|
||||
// three discovery-complete clips (audioPool); delayMs lets the
|
||||
// discovery chime + toast land first. One fire per system — the
|
||||
// arming set in create(), because a later cluster discovery in a
|
||||
// charted system still passes isSystemNavComplete() (clusters aren't
|
||||
// NAV points).
|
||||
const sysId = this.systemRecord?.id;
|
||||
if (sysId && this.isSystemNavComplete() && !this._systemCompleteSeen.has(sysId)) {
|
||||
this._systemCompleteSeen.add(sysId);
|
||||
this.comms?.trigger('systemComplete');
|
||||
}
|
||||
}
|
||||
|
||||
showTargetMarker(x, y) {
|
||||
|
|
@ -3263,9 +3316,88 @@ export class GameScene extends Phaser.Scene {
|
|||
const done = have >= count;
|
||||
return { label, done, detail: `${have}/${count}` };
|
||||
}
|
||||
if (type === 'systemCharted') {
|
||||
// EVERY NAV point of the named system ('home' = the home system)
|
||||
// — the same discoverable set that gates the jumpgate research
|
||||
// (navChart: planets, stations, gates — the central body is
|
||||
// implicit, clusters don't count).
|
||||
const sysId = this._questSystemId(check);
|
||||
const content = sysId && this.galaxy && typeof this.galaxy.contentOf === 'function'
|
||||
? (() => { try { return this.galaxy.contentOf(sysId); } catch { return null; } })()
|
||||
: null;
|
||||
const chart = sysId && content ? navChart(this.discovery, sysId, content) : null;
|
||||
const done = !!chart && chart.complete;
|
||||
return { label, done, detail: chart ? `${chart.discovered}/${chart.total}` : '??' };
|
||||
}
|
||||
if (type === 'gatesActivated') {
|
||||
// The named system's jumpgates are LIVE (the activateGates effect's
|
||||
// keys — the same set _activateSystemJumpgates writes).
|
||||
const sysId = this._questSystemId(check);
|
||||
const keys = sysId ? activationKeys(this.galaxy, sysId) : [];
|
||||
const have = keys.filter((k) => this.activatedGates?.has?.(k)).length;
|
||||
const done = keys.length > 0 && have === keys.length;
|
||||
return { label, done, detail: `${have}/${keys.length}` };
|
||||
}
|
||||
if (type === 'trestleTalked') {
|
||||
// Trestle's THREE-BRIEF sequence (data/comms.json, trigger
|
||||
// 'trestleBrief' — the gates-online briefing): the run's comms
|
||||
// played-ledger (registry 'commsPlayed') is the witness — all
|
||||
// three heard = the player talked to him.
|
||||
return this._trestleCheck('trestleBrief', label, 'TALKED');
|
||||
}
|
||||
if (type === 'trestleMet') {
|
||||
// His ARRIVAL beat (trigger 'trestleArrival' — the player lands in
|
||||
// a home-neighbor system) in the ledger = met.
|
||||
return this._trestleCheck('trestleArrival', label, 'MET');
|
||||
}
|
||||
return { label, done: false, detail: '??' };
|
||||
}
|
||||
|
||||
/**
|
||||
* A Trestle story-beat check (the 'Leaving the Nest' quest's two
|
||||
* social lines): every comm on `trigger` (data/comms.json) in the
|
||||
* run's comms played-ledger (registry 'commsPlayed' — CommsHub marks
|
||||
* it when a comm STARTS; SaveData saves/restores it) = done. Reading
|
||||
* the trigger's roster data-driven keeps the check and the comms
|
||||
* from drifting (a beat added to the sequence completes the check
|
||||
* only once it too has been heard), and a quit mid-sequence can't
|
||||
* fake it — the unplayed beats re-arm on the next load and the
|
||||
* ledger knows.
|
||||
* @param {string} trigger the comm trigger the witness rides on
|
||||
* @param {string} label the checklist line
|
||||
* @param {string} doneWord the detail once done
|
||||
*/
|
||||
_trestleCheck(trigger, label, doneWord) {
|
||||
const ids = commsForTrigger(config.section('comms', {}), trigger).map((c) => c.id);
|
||||
const played = this._commsPlayed();
|
||||
const have = ids.filter((id) => played.has(id)).length;
|
||||
const done = ids.length > 0 && have === ids.length;
|
||||
return { label, done, detail: done ? doneWord : `${have}/${ids.length}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's comms played-ledger (registry 'commsPlayed' — CommsHub
|
||||
* owns it; SaveData captures/restores it under record.comms.played).
|
||||
* Empty set when the run hasn't armed a comm yet (headless harnesses
|
||||
* included — the checks just read zero).
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
_commsPlayed() {
|
||||
const cur = this.registry?.get?.('commsPlayed');
|
||||
return cur instanceof Set ? cur : new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* A quest check's system: 'home' → the home system (the nest); a
|
||||
* literal id otherwise (future quests may name a specific system).
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_questSystemId(check) {
|
||||
const sys = check?.system;
|
||||
if (sys === 'home') return this.galaxy?.homeSystemId ?? null;
|
||||
return typeof sys === 'string' && sys ? sys : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many DISCOVERED worlds are planets or space stations (the
|
||||
* 'Discover another planet or space station' requirement). The home
|
||||
|
|
@ -3372,6 +3504,92 @@ export class GameScene extends Phaser.Scene {
|
|||
},
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
// The RUN's chain — the claim that pays this quest is what arms the
|
||||
// next (data/quests.json → leaving_the_nest.unlocks.claimed): the
|
||||
// follow-up grants ONTO the claim (sfx + NEW QUEST toast + the OAC's
|
||||
// quest-grant voice), queued after this claim's own toast.
|
||||
this._grantUnlockedQuests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant every not-yet-held quest whose unlock requirement is met
|
||||
* (QuestState.grantUnlocked — pure ledger work) and announce each new
|
||||
* grant in the console's voice: the 'something new is here' SFX, the
|
||||
* OAC's quest-grant voice (data/comms.json: questNew — once:false,
|
||||
* re-armed on every grant), and the NEW QUEST toast (the starter's
|
||||
* own toast style — quests.starterToast, one toast slot: the 2.7 s
|
||||
* delay lands it after the claim toast that preceded it). A fresh
|
||||
* run's starter boot and a loaded save's boot both call this (the
|
||||
* create() arm); claimQuest calls it on the claim. Idempotent —
|
||||
* give() is, and the announcement is per-NEW-grant.
|
||||
* @returns {string[]} the newly granted ids
|
||||
*/
|
||||
_grantUnlockedQuests() {
|
||||
if (config.get('quests.enabled', true) === false || !this.questState) return [];
|
||||
const fresh = this.questState.grantUnlocked();
|
||||
for (const id of fresh) {
|
||||
const def = questDef(id);
|
||||
this.playSfx('discovery'); // the 'something new is here' voice
|
||||
this.comms?.trigger('questNew'); // the OAC's quest-grant voice (audio-only)
|
||||
this.time.delayedCall(2700, () => {
|
||||
this.consoleToast(
|
||||
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
|
||||
.replace('{title}', String(def?.title ?? id).toUpperCase()),
|
||||
{
|
||||
glyph: config.get('quests.starterToast.glyph', '◆'),
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
durationMs: 3200,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
if (fresh.length) {
|
||||
this._questRefresh(); // a grant may have landed already-complete
|
||||
this._trestleBeats(); // the gates may already be live (the claim was the last piece)
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'Leaving the Nest' quest's Trestle beats (data/comms.json:
|
||||
* the trestle-brief-1/2/3 sequence + trestle-arrive) — armed from
|
||||
* every point where their preconditions could have become true while
|
||||
* the quest is held and unclaimed:
|
||||
* - the BRIEFING (three back-to-back ack comms), when the home
|
||||
* system's jumpgates are LIVE — the moment the player can jump
|
||||
* out of the nest (the activateGates effect — _applyResearchEffects,
|
||||
* or already-live when the quest grants / a save loads);
|
||||
* - the ARRIVAL, when the player is IN a home-neighbor system
|
||||
* (Trestle's side of the gate — scene create on the jump-in or a
|
||||
* loaded save parked there).
|
||||
* The comm's once-ledger + the hub's in-flight guard keep each beat a
|
||||
* one-shot; the QUEST CHECKS are the authoritative witnesses (the
|
||||
* played-ledger, read by _trestleCheck), so a beat the player never
|
||||
* heard can't fake the check — and a quit mid-sequence re-arms only
|
||||
* the unplayed beats here, on the next load.
|
||||
*/
|
||||
_trestleBeats() {
|
||||
if (config.get('quests.enabled', true) === false || !this.questState) return;
|
||||
const qid = 'leaving_the_nest';
|
||||
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
|
||||
const homeId = this.galaxy?.homeSystemId;
|
||||
if (!homeId) return;
|
||||
// The BRIEFING — home's gates are live (the quest's own 'Activate
|
||||
// System Jump Gates' check holds): Trestle walks the player to the
|
||||
// gate (one trigger arms all three comms; the hub's FIFO plays them
|
||||
// back-to-back).
|
||||
const homeKeys = activationKeys(this.galaxy, homeId);
|
||||
if (homeKeys.length && homeKeys.every((k) => this.activatedGates?.has?.(k))) {
|
||||
this.comms?.trigger('trestleBrief');
|
||||
}
|
||||
// The ARRIVAL — the player is IN a home-neighbor system (the gate's
|
||||
// other side). Only home's gates lead out of the nest, so a neighbor
|
||||
// is exactly 'the neighboring system'.
|
||||
const cur = this.systemRecord?.id;
|
||||
if (cur && cur !== homeId) {
|
||||
const neighbors = this.galaxy?.jumpNetwork?.gates?.get(homeId) ?? [];
|
||||
if (neighbors.includes(cur)) this.comms?.trigger('trestleArrival');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3443,6 +3661,7 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const q of snap.quests) {
|
||||
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
|
||||
this._questAnnounced.add(q.id);
|
||||
this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest
|
||||
const t = config.get('quests.readyToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
|
||||
|
|
@ -4076,18 +4295,21 @@ export class GameScene extends Phaser.Scene {
|
|||
// `id` (passed in) is the source of truth — NOT `node.id`.
|
||||
const sysId = systemIdOfGatesNode(id);
|
||||
if (sysId) this._activateSystemJumpgates(sysId);
|
||||
// The comms beat (data/comms.json: systemComplete —
|
||||
// oac-system-complete): the milestone — EVERY planet, space station,
|
||||
// and jump gate of the system is discovered AND its jumpgate
|
||||
// research is now unlocked. The gates research was only researchable
|
||||
// while that full chart held (the SYSTEM tree's available hook), so
|
||||
// both conditions land together exactly here — once per system.
|
||||
// OAC's box reads the milestone; the voice rolls one of the
|
||||
// discovery-complete clips at random (audioPool). Arming it here,
|
||||
// before the 'researchComplete' sign-off _completeResearch queues
|
||||
// next, keeps the FIFO order specific → generic.
|
||||
if (sysId && this.isSystemNavComplete()) {
|
||||
this.comms?.trigger('systemComplete');
|
||||
// (The 'systemComplete' milestone beat moved to
|
||||
// celebrateDiscovery — it fires when this research BECOMES
|
||||
// AVAILABLE, the chart-completing discovery, not on completion.)
|
||||
// The 'Leaving the Nest' quest's BRIEFING (data/comms.json:
|
||||
// trestle-brief-1/2/3 — the three back-to-back ack comms) — the
|
||||
// HOME system's gates are the run's door out of the nest: the
|
||||
// moment the player can jump out, Trestle keeps his starter-beat
|
||||
// promise ('I'll ping you once you're ready'). It lands in front
|
||||
// of the generic 'researchComplete' sign-off _completeResearch
|
||||
// queues after the effects — the hub's FIFO plays the specific
|
||||
// character beat first, then the system sign-off. _trestleBeats
|
||||
// guards it (quest held + unclaimed) and the comm's once-ledger
|
||||
// dedupes.
|
||||
if (sysId && sysId === this.galaxy?.homeSystemId) {
|
||||
this._trestleBeats();
|
||||
}
|
||||
} else {
|
||||
console.warn(`[orbit] research: unknown effect ${type}`, spec, id);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@
|
|||
*
|
||||
* Or, with nothing tracked: QUESTS / "NONE TRACKED" (grayed out).
|
||||
*
|
||||
* A COMPLETE-but-unclaimed quest is READY — the reward is sitting there:
|
||||
* its row turns AMBER (the console's READY color — amber = claimable, the
|
||||
* green accent stays the tracker's), the percent reads READY, and the
|
||||
* plate breathes (a slow ~1.2 s sine on an amber tint — a pulse, never a
|
||||
* strobe). The flip is a one-shot MOMENT: a bright row-flash + a small ◆
|
||||
* burst off the row's right edge (when the row is on screen — the
|
||||
* scene's toast + 'discovery' chime carry it when the tracker is slid
|
||||
* off), and the row's popup gains a CLAIM REWARD button (the console's
|
||||
* claim-plate palette) that claims in place (GameScene.claimQuest — the
|
||||
* console's own path).
|
||||
*
|
||||
* Lifecycle (driven by GameScene — see GameScene.updateHud /
|
||||
* startExpand): the tracker is VISIBLE ⇔ the dossier is FOLDED, armed by
|
||||
* the dossier's FIRST fold (the one-shot auto-fold, or the player's
|
||||
|
|
@ -57,6 +68,7 @@ const BODY = fontStack('body', FONT_FALLBACK);
|
|||
/** The tracker's palette — green is the tracker's accent (data/quests.json). */
|
||||
const C = {
|
||||
green: themeColor('green', 0x67e863),
|
||||
amber: themeColor('amber', 0xffc94d), // the console's READY color — claimable
|
||||
ink: themeColor('ink', 0xeaf6ff),
|
||||
dim: themeColor('dim', 0x7d92c4),
|
||||
faint: themeColor('faint', 0x3d4c74),
|
||||
|
|
@ -103,6 +115,8 @@ export class QuestTrackerHud {
|
|||
* @param {(id: string) => void} [o.onViewQuest] — VIEW QUEST pressed
|
||||
* @param {(id: string) => void} [o.onShowMore] — SHOW MORE pressed (the
|
||||
* first hidden quest)
|
||||
* @param {(id: string) => void} [o.onClaim] — CLAIM REWARD pressed (a
|
||||
* READY row's popup)
|
||||
*/
|
||||
constructor(scene, o = {}) {
|
||||
this.scene = scene;
|
||||
|
|
@ -112,6 +126,7 @@ export class QuestTrackerHud {
|
|||
this.onClearPriority = typeof o.onClearPriority === 'function' ? o.onClearPriority : null;
|
||||
this.onViewQuest = typeof o.onViewQuest === 'function' ? o.onViewQuest : null;
|
||||
this.onShowMore = typeof o.onShowMore === 'function' ? o.onShowMore : null;
|
||||
this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null;
|
||||
|
||||
const cfg = config.section('quests.tracker', {});
|
||||
this.maxVisible = Math.max(1, Math.floor(cfg.maxVisible ?? 5));
|
||||
|
|
@ -157,6 +172,8 @@ export class QuestTrackerHud {
|
|||
this._lastPopup = null; // { rect, at } — the just-closed popup (the same-frame guard)
|
||||
this._fp = null; // the last painted fingerprint
|
||||
this._priorityId = null; // the last resolved priority (for the popup)
|
||||
this._readyBaseline = null; // the last observed READY set (transitions fire the moment)
|
||||
this._readyFired = new Set(); // the moment already fired — one celebration per quest, ever
|
||||
this.state = 'hidden'; // hidden | showing | shown | hiding
|
||||
this._destroyed = false;
|
||||
|
||||
|
|
@ -272,11 +289,101 @@ export class QuestTrackerHud {
|
|||
this._fp = fp;
|
||||
this._priorityId = priorityId;
|
||||
this._paint(active, visible, priorityId, overflowFirst);
|
||||
this._readyTransitions(active); // the one-shot moment for any newly-READY row
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the READY state
|
||||
/**
|
||||
* A quest just became claimable — the one-shot MOMENT (the scene's
|
||||
* toast + 'discovery' chime carry it when the row isn't on screen):
|
||||
* a bright amber row-flash + a small ◆ glyph burst off the row's right
|
||||
* edge. Runs right after the repaint the flip triggered (a check
|
||||
* completing changes the fingerprint, so it lands on the SAME frame
|
||||
* the row repaints READY-styled). The baseline absorbs whatever was
|
||||
* ALREADY ready on the tracker's first poll (a loaded run), so a scene
|
||||
* start doesn't re-fire.
|
||||
*/
|
||||
_readyTransitions(active) {
|
||||
const readyIds = active.filter((q) => q.complete === true).map((q) => q.id);
|
||||
if (this._readyBaseline === null) {
|
||||
this._readyBaseline = new Set(readyIds); // first poll — establish, don't celebrate
|
||||
return;
|
||||
}
|
||||
const fresh = readyIds.filter((id) => !this._readyBaseline.has(id) && !this._readyFired.has(id));
|
||||
this._readyBaseline = new Set(readyIds);
|
||||
for (const id of fresh) {
|
||||
this._readyFired.add(id); // the run is monotonic, but this is the guard
|
||||
if (!this.shown) continue; // slid off — the toast + chime carry it
|
||||
const r = this._rowRectById.get(id);
|
||||
if (r) this._burstRow({ x: r.x, y: r.y, w: r.w, h: r.h });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's completion moment: a bright amber flash over the row
|
||||
* (~420 ms fade) + four ◆ glyphs drifting up-right off its right edge
|
||||
* and fading (plain tweens, staggered). Transient — NOT in _kids (no
|
||||
* teardown; each self-destroys) and added to the root AFTER the paint,
|
||||
* so it renders on top and slides with the tracker if a slide starts.
|
||||
*/
|
||||
_burstRow(r) {
|
||||
const s = this.scene;
|
||||
const flash = s.add.graphics().setScrollFactor(0);
|
||||
flash.clear();
|
||||
flash.fillStyle(C.amber, 1);
|
||||
flash.fillRect(r.x, r.y, r.w, r.h);
|
||||
this.root.add(flash);
|
||||
s.tweens.add({
|
||||
targets: flash,
|
||||
alpha: 0,
|
||||
duration: 420,
|
||||
ease: 'Sine.easeOut',
|
||||
onComplete: () => {
|
||||
try {
|
||||
flash.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
});
|
||||
const n = 4;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const g = s.add
|
||||
.text(r.x + r.w - 10, r.y + r.h / 2, '◆', {
|
||||
fontFamily: BODY,
|
||||
fontSize: `${9 + (i % 2) * 4}px`,
|
||||
color: toCss(C.amber),
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0);
|
||||
this.root.add(g);
|
||||
s.tweens.add({
|
||||
targets: g,
|
||||
x: g.x + 16 + i * 9,
|
||||
y: g.y - (10 + i * 8),
|
||||
alpha: 0,
|
||||
duration: 460 + i * 90,
|
||||
delay: i * 40,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
try {
|
||||
g.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the dynamic content (the title + rule are permanent). */
|
||||
_teardown() {
|
||||
for (const k of this._kids) {
|
||||
try {
|
||||
this.scene.tweens.killTweensOf(k); // the READY row's breathing tint
|
||||
} catch {
|
||||
/* no tweens */
|
||||
}
|
||||
try {
|
||||
k.destroy();
|
||||
} catch {
|
||||
|
|
@ -318,6 +425,7 @@ export class QuestTrackerHud {
|
|||
|
||||
for (const q of visible) {
|
||||
const isPrio = q.id === priorityId;
|
||||
const isReady = q.complete === true; // ACTIVE quests only — claimed are filtered out above
|
||||
const doneN = (q.checks ?? []).filter((c) => c?.done).length;
|
||||
const total = (q.checks ?? []).length;
|
||||
const pct = total > 0 ? Math.round((100 * doneN) / total) : 0;
|
||||
|
|
@ -327,12 +435,13 @@ export class QuestTrackerHud {
|
|||
const rh = 20;
|
||||
|
||||
// The row plate (a VISIBLE object — the v4 hit-test rule) + the
|
||||
// left tick (green = the priority quest, faint = the rest).
|
||||
// left tick (amber = READY / claimable, green = the priority quest,
|
||||
// faint = the rest).
|
||||
const plate = add(s.add.graphics().setScrollFactor(0));
|
||||
plate.clear();
|
||||
plate.fillStyle(C.panel, 0.35);
|
||||
plate.fillRect(rx, ry, rw, rh);
|
||||
plate.fillStyle(isPrio ? C.green : C.faint, isPrio ? 0.95 : 0.7);
|
||||
plate.fillStyle(isReady ? C.amber : isPrio ? C.green : C.faint, isReady ? 1 : isPrio ? 0.95 : 0.7);
|
||||
plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6);
|
||||
plate.setInteractive({
|
||||
useHandCursor: true,
|
||||
|
|
@ -348,25 +457,47 @@ export class QuestTrackerHud {
|
|||
this.openPopup(q, { x: rx, y: ry, w: rw, h: rh });
|
||||
});
|
||||
|
||||
// The READY tint — an amber wash OVER the plate, UNDER the text,
|
||||
// breathing on a slow sine (a pulse, never a strobe).
|
||||
if (isReady) {
|
||||
const tint = add(s.add.graphics().setScrollFactor(0));
|
||||
tint.clear();
|
||||
tint.fillStyle(C.amber, 1);
|
||||
tint.fillRect(rx, ry, rw, rh);
|
||||
tint.setAlpha(0.1);
|
||||
s.tweens.add({
|
||||
targets: tint,
|
||||
alpha: 0.3,
|
||||
duration: 1200,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
add(
|
||||
s.add
|
||||
.text(this.X + 8, ry + 3, String(q.title ?? '').toUpperCase(), {
|
||||
fontFamily: BODY,
|
||||
fontSize: '12px',
|
||||
color: toCss(isPrio ? C.ink : C.dim),
|
||||
color: toCss(isReady || isPrio ? C.ink : C.dim),
|
||||
letterSpacing: 1,
|
||||
})
|
||||
.setOrigin(0, 0)
|
||||
.setScrollFactor(0),
|
||||
);
|
||||
const labelStyle = {
|
||||
fontFamily: BODY,
|
||||
fontSize: '11px',
|
||||
color: toCss(isReady ? C.amber : isPrio ? C.green : C.dim),
|
||||
letterSpacing: 1,
|
||||
};
|
||||
if (isReady) {
|
||||
labelStyle.fontStyle = 'bold';
|
||||
labelStyle.letterSpacing = 1.5;
|
||||
}
|
||||
add(
|
||||
s.add
|
||||
.text(this.X + rw, ry + rh / 2, `${pct}%`, {
|
||||
fontFamily: BODY,
|
||||
fontSize: '11px',
|
||||
color: toCss(isPrio ? C.green : C.dim),
|
||||
letterSpacing: 1,
|
||||
})
|
||||
s.add.text(this.X + rw, ry + rh / 2, isReady ? 'READY' : `${pct}%`, labelStyle)
|
||||
.setOrigin(1, 0.5)
|
||||
.setScrollFactor(0),
|
||||
);
|
||||
|
|
@ -438,9 +569,10 @@ export class QuestTrackerHud {
|
|||
// ------------------------------------------------------------ the row popup
|
||||
/**
|
||||
* The row's context popup (a click on a quest row): a small cut-corner
|
||||
* panel to the right of the row with the quest's name and two buttons —
|
||||
* SET PRIORITY (CLEAR PRIORITY, when this quest IS the priority) and
|
||||
* VIEW QUEST (the QUESTS console on this quest). A click on a button
|
||||
* panel to the right of the row with the quest's name and its buttons —
|
||||
* CLAIM REWARD (READY rows only, first — claims in place), SET PRIORITY
|
||||
* (CLEAR PRIORITY, when this quest IS the priority), and VIEW QUEST
|
||||
* (the QUESTS console on this quest). A click on a button
|
||||
* is the button's (MenuButton's own pointerdown); any click ELSEWHERE
|
||||
* closes the popup and is consumed (GameScene's pointerdown, via
|
||||
* popupContains() below).
|
||||
|
|
@ -452,6 +584,7 @@ export class QuestTrackerHud {
|
|||
this.closePopup();
|
||||
const s = this.scene;
|
||||
const isPrio = this._priorityId === quest.id;
|
||||
const isReady = quest.complete === true; // ACTIVE quests only — claimed are out of the list
|
||||
const W = 212;
|
||||
const mk = (label, onClick) =>
|
||||
new MenuButton(s, 0, 0, label, onClick, {
|
||||
|
|
@ -466,6 +599,23 @@ export class QuestTrackerHud {
|
|||
hoverColor: C.panel,
|
||||
textColor: toCss(C.ink),
|
||||
});
|
||||
const btns = [];
|
||||
// CLAIM REWARD — READY rows only, FIRST (the popup's primary action):
|
||||
// the console's claim-plate palette (amber edge, dark amber fill —
|
||||
// QuestWindow's CLAIM REWARD), claiming in place via the scene's
|
||||
// claimQuest (the console's own path — idempotent, toast + tick).
|
||||
if (isReady) {
|
||||
const bClaim = mk('CLAIM REWARD', () => {
|
||||
this.onClaim?.(quest.id);
|
||||
this.closePopup();
|
||||
});
|
||||
bClaim.style.fill = 0x2a1f08;
|
||||
bClaim.style.hoverFill = 0x3d2c0e;
|
||||
bClaim.style.stroke = C.amber;
|
||||
bClaim.style.neon = C.amber;
|
||||
bClaim.paint('base');
|
||||
btns.push(bClaim);
|
||||
}
|
||||
const bPrio = mk(isPrio ? 'CLEAR PRIORITY' : 'SET PRIORITY', () => {
|
||||
if (isPrio) this.onClearPriority?.();
|
||||
else this.onSetPriority?.(quest.id);
|
||||
|
|
@ -481,10 +631,9 @@ export class QuestTrackerHud {
|
|||
bView.style.stroke = C.dim;
|
||||
bView.style.neon = C.green;
|
||||
bView.paint('base');
|
||||
btns.push(bPrio, bView);
|
||||
|
||||
const h1 = bPrio.style.height;
|
||||
const h2 = bView.style.height;
|
||||
const H = 26 + 6 + h1 + 6 + h2 + 8;
|
||||
const H = 26 + btns.reduce((a, b) => a + b.style.height, 0) + (btns.length - 1) * 6 + 8;
|
||||
// The panel plate (a cut-corner plate — CyberShape at its centre).
|
||||
const g = s.add.graphics().setScrollFactor(0);
|
||||
g.clear();
|
||||
|
|
@ -492,10 +641,10 @@ export class QuestTrackerHud {
|
|||
notch: Math.min(12, H * 0.18),
|
||||
fill: C.panel,
|
||||
fillAlpha: 0.96,
|
||||
stroke: isPrio ? C.faint : C.green,
|
||||
stroke: isReady ? C.amber : isPrio ? C.faint : C.green,
|
||||
strokeAlpha: 0.85,
|
||||
lineWidth: 1.5,
|
||||
glow: isPrio ? C.dim : C.green,
|
||||
glow: isReady ? C.amber : isPrio ? C.dim : C.green,
|
||||
glowAlpha: 0.18,
|
||||
});
|
||||
g.setPosition(W / 2, H / 2);
|
||||
|
|
@ -503,13 +652,16 @@ export class QuestTrackerHud {
|
|||
.text(10, 8, String(quest.title ?? '').toUpperCase(), {
|
||||
fontFamily: BODY,
|
||||
fontSize: '10px',
|
||||
color: toCss(C.dim),
|
||||
color: toCss(isReady ? C.amber : C.dim),
|
||||
letterSpacing: 1.5,
|
||||
})
|
||||
.setOrigin(0, 0)
|
||||
.setScrollFactor(0);
|
||||
bPrio.setPosition(W / 2, 26 + h1 / 2);
|
||||
bView.setPosition(W / 2, 26 + h1 + 6 + h2 / 2);
|
||||
let by = 26;
|
||||
for (const b of btns) {
|
||||
b.setPosition(W / 2, by + b.style.height / 2);
|
||||
by += b.style.height + 6;
|
||||
}
|
||||
|
||||
// Placement: right of the row (falling back to its left when the
|
||||
// right side runs off the screen edge).
|
||||
|
|
@ -527,14 +679,13 @@ export class QuestTrackerHud {
|
|||
cont.setDepth(31); // just above the tracker's own plate (30)
|
||||
cont.add(g);
|
||||
cont.add(header);
|
||||
cont.add(bPrio);
|
||||
cont.add(bView);
|
||||
for (const b of btns) cont.add(b);
|
||||
this._popup = {
|
||||
questId: quest.id,
|
||||
rect: { x: px, y: py, w: W, h: H },
|
||||
sourceRect: { ...rowRect }, // the row that opened it (the scene's same-click test)
|
||||
openedAt: this.scene.time.now,
|
||||
btns: [bPrio, bView],
|
||||
btns,
|
||||
obj: cont,
|
||||
};
|
||||
this.scene.playSfx?.('ui_window');
|
||||
|
|
|
|||
Loading…
Reference in New Issue