diff --git a/assets/speech/vega/tutorial-intro-02.mp3 b/assets/speech/vega/tutorial-intro-02.mp3 new file mode 100644 index 0000000..52d8e20 Binary files /dev/null and b/assets/speech/vega/tutorial-intro-02.mp3 differ diff --git a/data/mastervega-tutorial.json b/data/mastervega-tutorial.json new file mode 100644 index 0000000..8409f8f --- /dev/null +++ b/data/mastervega-tutorial.json @@ -0,0 +1,343 @@ +{ + "version": 1, + "vars": ["species"], + "confirmSkip": { + "body": "Are you sure you want to skip the entire tutorial?", + "confirmLabel": "Skip tutorial", + "cancelLabel": "Keep learning" + }, + "steps": [ + { + "id": "intro", + "kind": "modal", + "voice": "vega/tutorial-intro-01", + "body": "All alone. Your species, the {species}, has spent its entire existence on a single planet.... but no more. Recent advances have given you the ability to explore and colonize nearby stars. It is time for your species to begin your exploration of the universe. Soon, you will find you're not alone afterall. But for now, you have prepared a scout ship and a colony ship with a singular mission: Find a habitable planet nearby to begin your expansion.", + "highlights": [], + "buttons": [ + { "action": "skip", "label": "Skip tutorial" }, + { "action": "next", "label": "Next" } + ] + }, + { + "id": "your-fleet", + "kind": "callout", + "voice": null, + "anchor": "homeFleet", + "highlights": ["homeFleet", "homeStar"], + "calloutText": "This is your fleet. A Scout Ship and Colony Ship. Click here to select them.", + "advanceOn": "hotspot", + "hotspotAction": "selectHomeFleet", + "buttons": [] + }, + { + "id": "ship-profiles", + "kind": "callout", + "voice": null, + "anchor": "fleetShipProfiles", + "highlights": ["fleetShipProfiles"], + "calloutText": "These are the ships in your fleet. Click on a ship's profile picture or video to view details of that ship.", + "advanceOn": "shipDetailClosed", + "buttons": [] + }, + { + "id": "ship-counts", + "kind": "callout", + "voice": null, + "anchor": "fleetCountControls", + "highlights": ["fleetCountControls"], + "calloutText": "You can remove or increase or decrease the number of ships in this fleet by clicking one of these buttons.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "nearby-stars", + "kind": "callout", + "voice": null, + "anchor": "homeFleet", + "highlights": ["homeFleet", "homeStar", "nearbyStars"], + "calloutText": "Your fleet isn't limited to your home star — it can also travel to any of the other stars highlighted nearby.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "map-controls", + "kind": "callout", + "voice": null, + "anchor": "homeFleet", + "highlights": ["mapArea"], + "freePan": true, + "calloutText": "Before you send your fleet anywhere, here's how to get around the map. Click and drag anywhere to pan in any direction. Scroll your mouse wheel up to zoom in, or down to zoom out. Try it now, then click Next when you're ready.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "click-star", + "kind": "callout", + "voice": null, + "anchor": "nearbyStars", + "highlights": ["nearbyStars"], + "starPick": "info", + "advanceOn": "starPick", + "fitReachableStars": true, + "calloutText": "Now click on one of the highlighted stars to see what's there.", + "buttons": [] + }, + { + "id": "read-star-info", + "kind": "callout", + "voice": null, + "anchor": "panel", + "highlights": ["panel"], + "calloutText": "This star hasn't been explored yet, so for now the panel only shows its class and a general description — different star classes support different amounts of population. Once a scout of yours actually reaches a system, this same panel will also reveal its worlds, how many of them are habitable for your species, and any colonies or fleets stationed there.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "move-fleet", + "kind": "callout", + "voice": null, + "anchor": "panel", + "highlights": ["nearbyStars", "panel"], + "starPick": "order", + "advanceOn": "fleetInFlight", + "selectHomeFleet": true, + "calloutText": "Let's send your fleet on its way. Click one of the highlighted stars again, then review the order in this panel and press Accept to launch your fleet.", + "buttons": [] + }, + { + "id": "fleet-moving", + "kind": "callout", + "voice": null, + "anchor": "travelingFleet", + "highlights": ["travelingFleet"], + "calloutText": "Your fleet is now underway! The number above it shows how many turns remain until it arrives at its destination.", + "buttons": [ + { "action": "next", "label": "Got it!" } + ] + }, + { + "id": "home-star", + "kind": "callout", + "voice": null, + "anchor": "homeStar", + "highlights": ["homeStar"], + "calloutText": "This is your home star. Click it to see your system's information.", + "advanceOn": "hotspot", + "hotspotAction": "showHomeStar", + "buttons": [] + }, + { + "id": "home-star-info", + "kind": "callout", + "voice": null, + "anchor": "panel", + "highlights": ["panel", "homeStar"], + "calloutText": "This panel shows your home system: how many worlds orbit it and how many are habitable, your colony's population, factories, output, and defences, and what it's currently building. Press View System at the bottom to see the whole system in detail.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "view-system", + "kind": "callout", + "voice": null, + "anchor": "panel", + "highlights": ["panel"], + "calloutText": "Press View System to open a detailed view of your home system.", + "advanceOn": "hotspot", + "hotspotAction": "openSystemView", + "buttons": [] + }, + { + "id": "system-view-planets", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "body": "This is your system view. Click on any of the worlds orbiting your star to inspect it — the panel on the right updates with its type, size, and richness.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "system-view-colony", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceOn": "external", + "body": "Click on your home world — the one flying a small colony flag above it — then press View Colony to open its colony screen.", + "buttons": [] + }, + { + "id": "colony-overview", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "colonyCenter", + "body": "This is your colony screen — everything you need to run this world lives here. The panel on the right shows its population, factories, output, trade, and defences.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "colony-allocation", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "colonyCenter", + "body": "Below that, the Allocation sliders split your production across five channels: Construction, Defence, Industry, Ecology, and Research. Drag a slider to change its share — the rest renormalise automatically. Click a channel's lock icon to hold its share fixed while you adjust the others. Ecology is always funded first, off the top.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "colony-focus", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "colonyCenter", + "body": "Colony Focus automates your build queue toward a goal — Expansion, Colony Improvement, Research, Fleet Production, Population Growth, Trade, or Homeworld Defense — instead of you queuing every building by hand. Allocation Focus applies a one-time preset to the sliders above, tuned to the same kinds of strategies. Open either one and your advisors will point out their recommended pick with a pulsing arrow and a short reason.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "colony-close", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceOn": "external", + "body": "When you're ready, close this colony screen, then close the system view too, to get back to the star map.", + "buttons": [] + }, + { + "id": "end-turn", + "kind": "callout", + "voice": null, + "anchor": "endTurnButton", + "highlights": ["endTurnButton"], + "calloutText": "You've given your fleet its orders and looked over your colony — press End Turn to advance to the next turn.", + "advanceOn": "hotspot", + "hotspotAction": "endTurn", + "buttons": [] + }, + { + "id": "empire-menu", + "kind": "callout", + "voice": null, + "anchor": "empireButton", + "highlights": ["empireButton"], + "calloutText": "Your turn is underway. Let's look at the rest of your empire — click the Empire menu to open it.", + "advanceOn": "hotspot", + "hotspotAction": "openEmpireMenu", + "buttons": [] + }, + { + "id": "open-research", + "kind": "callout", + "voice": null, + "anchor": "empireMenuResearch", + "highlights": ["empireMenuResearch"], + "calloutText": "Click Research to see what your empire is investigating.", + "advanceOn": "hotspot", + "hotspotAction": "openResearch", + "buttons": [] + }, + { + "id": "research-info", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceAction": "closeEmpireScreenReopenMenu", + "body": "Each tile is a research field — its current level, and a slider (with the same lock icon your colony's allocation sliders have) for how much of your research output feeds it. Click a field to see what you're currently working toward there and what comes after it.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "open-diplomacy", + "kind": "callout", + "voice": null, + "anchor": "empireMenuDiplomacy", + "highlights": ["empireMenuDiplomacy"], + "calloutText": "Click Diplomacy to see your relations with the species you've met.", + "advanceOn": "hotspot", + "hotspotAction": "openDiplomacy", + "buttons": [] + }, + { + "id": "diplomacy-info", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceAction": "closeEmpireScreenReopenMenu", + "body": "You haven't met another species yet, so this window is empty for now. Once you do, it will list each one here — their portrait, treaty status and attitude toward you, how many colonies and how much power they hold, and a Seek Audience button to open negotiations.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "open-leaders", + "kind": "callout", + "voice": null, + "anchor": "empireMenuLeaders", + "highlights": ["empireMenuLeaders"], + "calloutText": "Click Leaders to see who's available to hire.", + "advanceOn": "hotspot", + "hotspotAction": "openLeaders", + "buttons": [] + }, + { + "id": "leaders-info", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceAction": "closeEmpireScreenReopenMenu", + "body": "Leaders available for hire are listed here, each with their specialty and a one-time cost. Hire one to post them to a fleet or colony for a standing bonus.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "open-colonies", + "kind": "callout", + "voice": null, + "anchor": "empireMenuColonies", + "highlights": ["empireMenuColonies"], + "calloutText": "Click Colonies to see every world you own in one place.", + "advanceOn": "hotspot", + "hotspotAction": "openColonies", + "buttons": [] + }, + { + "id": "colonies-info", + "kind": "modal", + "voice": null, + "noDim": true, + "windowPos": "topBanner", + "advanceAction": "closeEmpireScreenAndMenu", + "body": "This lists every colony you own — population, factories, what's building, and its Colony Focus and Allocation Focus — so you can check and adjust any of them from one screen instead of opening each individually.", + "buttons": [ + { "action": "next", "label": "Next" } + ] + }, + { + "id": "keep-exploring", + "kind": "modal", + "voice": null, + "body": "That's the Empire menu. From here, keep exploring nearby star systems with your scouts — once you find a habitable planet, bring a colony ship and found a new colony there to keep growing your empire.", + "buttons": [ + { "action": "next", "label": "Understood" } + ] + } + ] +} diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index 576f040..de8be8b 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -1772,6 +1772,71 @@ Mirror-match-bias and species-spread assertions (verifier sections 5/11) both still passed at their existing tolerances — not re-measured as a standalone figure here. +## Guided tutorial (2026-08-28) + +An in-game guided tutorial that opens on every brand-new game (never a +resumed/loaded one) and is re-triggerable from the ☰ menu ("Replay tutorial", +greyed once the starting fleet has moved/split). Phase 1 ships the framework +plus four steps: a centred intro modal, a "select your fleet" callout, a +"these are your ships" callout over the side panel's ship rows, and a +"change ship counts" callout over the −/+/✕ cluster. + +- **The script is data.** `data/mastervega-tutorial.json` — an ordered `steps[]` + list, each with `kind` (`modal` | `callout`), `body`/`calloutText`, `voice` + (path under `assets/speech/`, no `.mp3`, or `null`), `highlights[]` / + `anchor` (string ids), `advanceOn` (`hotspot` = click the lit anchor; + `shipDetailClosed` = the player opened and closed a ship detail window), + and `buttons[]` (`{action, label}`, action ∈ next/back/skip/finish). A step + with `buttons: []` is legal only when `advanceOn` is set. `{token}` + placeholders are interpolated against a `vars` allow-list (only `species` + so far → `rules.species[emp.speciesId].plural`). Adding later steps = + editing this file; no code change unless a step needs a **new** highlight + target or advance mode. +- **Target ids** (`TUTORIAL_TARGET_IDS`): `homeStar` / `homeFleet` on the star + map (accent ring), `fleetShipProfiles` / `fleetCountControls` on the side + panel (yellow box). Panel regions resolve through a new + `VegaSidePanel.tutorialRegion(name)` — screen-space union of per-stack rects + recorded in `stackRow()` into `this._tutorRows` on every `rebuild()`, keyed + off `this.x0/this.y0` (the panel's resting position, so it is right even + while the panel is still sliding in). +- **Two modules, split like `VegaGnn` / `VegaGnnScreen`.** + `VegaTutorialData.js` is **Phaser-free** (schema validation, interpolation, + `TUTORIAL_TARGET_IDS`) so `tools/verifyMasterOfVega.js` imports it (section + 12). `VegaTutorial.js` is the Phaser half (overlay, callout, hotspot, + state machine). A highlightable thing needs an id in `TUTORIAL_TARGET_IDS` + **and** a `_resolveTarget` case in `VegaTutorial.js`. +- **Darken = four opaque strips framing one rectangular hole** (the union of + the step's resolved highlight rects, padded), NOT a mask cutout — every + target worth highlighting is rectangular. A pulsing accent ring is stroked + around each target on top. Empty `highlights` → one full-screen dim rect. +- **The map is frozen by `scene.modalOpen = true`** for the tutorial's whole + life. That one flag blocks star-map pan/zoom (`blockPointer`/`blockWheel`) + and every map/HUD handler — **but not the side panel's own controls** + (`detailHit`, `tinyButton`, `tinyCircleButton`, `openShipDetail` — none + check `modalOpen`), which is what lets the fleet-ship steps work: the hole + over the panel exposes real, clickable panel widgets (`input.topOnly` is on, + so the dark strips must genuinely not cover them — the four-strip hole does + exactly that). `centerOn(emp.homeStar)` on start. `finish()` restores + `modalOpen = false` + `refreshAll()`, mirroring `openModal`'s `done()`. +- **`D.tutorial = 75`** — deliberately just *below* `D.detail` (76). The + "these are your ships" step tells the player to click a ship profile, which + opens the real `openShipDetail` window; sitting below `D.detail` lets it + layer cleanly on top of the overlay. The step then advances when + `panel.detailOpen` goes true-then-false (polled in `VegaTutorial.update()`). +- The "click here to select them" hotspot is a transparent interactive rect + over the fleet marker; its handler does the real low-level selection + (`scene.selectedFleet = f; map.setSelectedFleet(f); panel.showFleet(f)`, + bypassing `onFleetClick`'s `modalOpen` guard) then `advance()`. +- The skip button opens a small confirm prompt drawn on the tutorial's own + container (not `openModal`), so it never touches `modalOpen`. Skip is a + per-step button in the JSON, present only on the intro step — once the + player clicks Next it is gone. A step with an empty `buttons[]` is legal + **only** when it has `advanceOn: "hotspot"` (the callout step advances by + clicking the fleet, nothing else); `validateTutorialData` enforces that. +- No `VegaLogic` change — runs every new game, so there is no "seen" flag to + serialize. A malformed JSON file is validated in `create()` and disables the + feature with a `console.warn` rather than crashing. + ## Files touched to register the game `src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js` diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index a67c5a2..6eb738b 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -148,6 +148,7 @@ export const MANIFEST = { // only the JSON does. mastervega: [ { type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' }, + { type: 'json', key: 'mastervega-tutorial', path: 'data/mastervega-tutorial.json' }, (scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']), (scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'), (scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'), diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 11e66f6..615a836 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -44,6 +44,8 @@ import { openCouncilSessionScreen } from './VegaCouncilSession.js'; import { openAudienceScreen } from './VegaAudience.js'; import { playIntroVideo } from './VegaIntroVideo.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; +import { VegaTutorial } from './VegaTutorial.js'; +import { validateTutorialData } from './VegaTutorialData.js'; const SAVE_KEY = 'mastervega-save'; // 10 manual slots, independent of the single SAVE_KEY auto-save above (which @@ -78,6 +80,12 @@ export default class MasterOfVegaGame extends Phaser.Scene { // Arcade already relies on instead of a bespoke in-place teardown. this.pendingSavedState = data?.savedState ?? null; this.modalOpen = false; + // Set true by VegaTutorial for the one step that teaches pan/zoom — lets + // the star map's own drag/wheel handlers through despite modalOpen, while + // onStarClick/onFleetClick (and every other modalOpen-gated control) + // still check modalOpen directly and stay frozen. See blockWheel/ + // blockPointer below. + this.tutorialFreePan = false; this.busy = false; // Empire indices with a freshly-claimed contact waiting for their // full-screen Audience — see runAudienceQueue(). @@ -95,6 +103,17 @@ export default class MasterOfVegaGame extends Phaser.Scene { console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`); } + // Guided-tutorial script (data/mastervega-tutorial.json). A malformed file + // must never take the game down with it — log and disable the feature. + this.tutorialData = this.cache.json.get('mastervega-tutorial') ?? null; + if (this.tutorialData) { + const { ok, errors } = validateTutorialData(this.tutorialData); + if (!ok) { + console.warn('[MasterOfVega] tutorial disabled — invalid mastervega-tutorial.json:', errors); + this.tutorialData = null; + } + } + try { this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); } catch (err) { /* music is optional */ } @@ -129,6 +148,8 @@ export default class MasterOfVegaGame extends Phaser.Scene { } teardown() { + this.tutorial?.destroy(); + this.tutorial = null; resetSpeechQueue(); this.panel?.destroy(); this.map?.destroy(); @@ -637,15 +658,17 @@ export default class MasterOfVegaGame extends Phaser.Scene { onStarClick: (idx) => this.onStarClick(idx), onFleetClick: (fleet) => this.onFleetClick(fleet), onEmptyClick: () => this.clearSelection(), - blockWheel: () => this.modalOpen || !!this.panel?.detailOpen, + blockWheel: () => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen, // A modal blocks the map outright; so does the panel's ship detail // pop-over, which veils the whole screen without being a modal. // Otherwise only the side panel's own footprint does (so dragging a // slider there doesn't pan the galaxy underneath it). The old // `!this.modalOpen && ...` form always short-circuited to false while a // modal was open, which let drags pan the star map right through the - // System View window. - blockPointer: (p) => this.modalOpen || !!this.panel?.detailOpen + // System View window. `tutorialFreePan` punches a narrow exception + // through modalOpen for pan/zoom only — onStarClick/onFleetClick are + // untouched, so clicking a star or fleet is still fully frozen. + blockPointer: (p) => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen || !!this.panel?.containsPoint(p.x, p.y), }); @@ -663,6 +686,38 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.buildHud(); this.refreshHud(); + + // A brand-new game (never a resumed/loaded one) opens with the guided + // tutorial. Runs every new game — there is no "seen" flag — and is also + // re-triggerable from the ☰ menu. + if (!savedState) this.startTutorial({ replay: false }); + } + + // ------------------------------------------------------------- tutorial + + startTutorial({ replay = false } = {}) { + if (this.tutorial || !this.tutorialData || !this.map || !this.panel) return; + if (replay && !this.canReplayTutorial()) return; + const emp = this.state.empires[this.state.humanIndex]; + const species = this.rules.species[emp?.speciesId]?.plural + ?? this.rules.species[emp?.speciesId]?.name ?? 'your people'; + this.tutorial = new VegaTutorial(this, { + data: this.tutorialData, + vars: { species }, + onFinish: () => { this.tutorial = null; }, + }); + this.tutorial.start(); + } + + /** The tutorial's fleet callout needs the untouched starting fleet in orbit + * at the homeworld — once it has moved or split there is nothing to point + * at, so the ☰ replay entry greys out. */ + canReplayTutorial() { + if (!this.tutorialData || !this.map || !this.panel || !this.state) return false; + const emp = this.state.empires[this.state.humanIndex]; + if (!emp) return false; + return this.state.fleets.some((f) => f.empireIdx === this.state.humanIndex + && f.starIdx === emp.homeStar && f.toStar < 0); } // ------------------------------------------------------------------ HUD @@ -768,6 +823,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { ['Return to Main Menu', () => this.returnToMainMenu()], ['Save', () => this.openSaveMenu()], ['Load', () => this.openLoadMenu(), !this.hasAnySaveSlot()], + ['Replay tutorial', () => this.startTutorial({ replay: true }), !this.canReplayTutorial()], ['Quit to Arcade', () => this.quitToArcade()], ]; @@ -1513,6 +1569,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { update(time, delta) { this.map?.update(time, delta); + this.tutorial?.update?.(time, delta); } // ------------------------------------------------------------ save/load diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index 18587b3..19fced6 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -570,6 +570,7 @@ export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`; /** Non-species speech clips, addressed the same way. */ export const UI_SPEECH = { chooseSpecies: 'vega/ui-choose-start', + tutorialIntro: 'vega/tutorial-intro-01', }; export function hasSpeciesVideo(scene, speciesId) { diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 16c9124..9d778c5 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -30,9 +30,13 @@ export const FONT = '"Julius Sans One"'; // actually matters in practice; it sits next to gnn as the other full-screen // takeover. `intro` is the colony-founding vignette, which opens over the // system view it was triggered from and must cover everything except the -// end-of-game overlay. +// end-of-game overlay. `tutorial` is the guided-tutorial darken overlay — it +// sits above the HUD, side panel and modals, but DELIBERATELY just below +// `detail` so a ship-detail pop-over the tutorial itself invites the player +// to open layers cleanly on top of it; it only runs on a fresh turn-0 game, +// so nothing from `detail` up ever competes with it in practice. export const D = { - map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, detail: 76, intro: 78, toast: 80, + map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, tutorial: 75, detail: 76, intro: 78, toast: 80, }; /** diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index b68094d..ecf7182 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -323,6 +323,9 @@ export default class VegaSidePanel { // The pool survives the wipe; claim what this pass actually uses and let // endFrame() hide and pause the rest. this.pool.beginFrame(); + // Per-stack local-coord rects the guided tutorial points at (ship + // profiles vs. the −/+/✕ cluster). Rebuilt every pass like the body. + this._tutorRows = []; this.y = 90; if (this.mode === 'star') this.buildStar(); else if (this.mode === 'fleet') this.buildFleet(); @@ -761,9 +764,57 @@ export default class VegaSidePanel { }).setOrigin(0.5); this.body.add(count); + // Tutorial anchors, in body-local coords (body sits at 0,0 in root). + const ctrlLeft = right - boxSize * 4.3 - boxSize / 2; + this._tutorRows.push({ + profiles: { + x: PAD - 4, + y: rowY - 4, + w: Math.min(name.x + name.width, ctrlLeft - 6) - (PAD - 4), + h: rowH + 8, + }, + counts: { + x: ctrlLeft - 4, + y: ctrlY - boxSize / 2 - 5, + w: (right + 4) - (ctrlLeft - 4), + h: boxSize + 10, + }, + }); + this.y = rowY + rowH + 10; } + /** + * Screen-space bounding box of a named region of the panel, for the guided + * tutorial's spotlight. `name` is 'shipProfiles' or 'countControls' (union + * across every task-force stack in the fleet view), or 'panel' (the whole + * docked column, whatever mode — star/fleet/order — it is currently + * showing). Uses the panel's resting position (this.x0/this.y0), not + * this.root.x, so it is correct even while the panel is still sliding in. + */ + tutorialRegion(name) { + if (!this.root?.visible) return null; + if (name === 'panel') return { x: this.x0, y: this.y0, w: W, h: this.h }; + if (this.mode !== 'fleet') return null; + const rows = this._tutorRows; + if (!rows || !rows.length) return null; + const key = name === 'shipProfiles' ? 'profiles' + : name === 'countControls' ? 'counts' : null; + if (!key) return null; + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const r of rows) { + const b = r[key]; + x0 = Math.min(x0, b.x); + y0 = Math.min(y0, b.y); + x1 = Math.max(x1, b.x + b.w); + y1 = Math.max(y1, b.y + b.h); + } + return { x: this.x0 + x0, y: this.y0 + y0, w: x1 - x0, h: y1 - y0 }; + } + selectionSummary() { const { rules, state } = this; const ships = this.selectedShips(); diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index f3203bf..facc263 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -21,7 +21,7 @@ import { coloniesAt, empireColonies, fleetEta, habitableForEmpire, } from './VegaLogic.js'; import { starFrame } from './VegaArt.js'; -import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js'; +import { buildZoomLadder, DEFAULT_ZOOM_INDEX, pickFitZoomIndex } from './VegaZoom.js'; import { delaunayTriangulate, triangulationEdges } from './VegaDelaunay.js'; import { describeStarTooltip } from './VegaTooltips.js'; import { ORBIT } from './VegaScreens.js'; @@ -868,6 +868,34 @@ export default class VegaStarMap { this.clampPan(); } + /** + * Pick the ladder rung that frames a world-space box (`minX/minY/maxX/ + * maxY`, plus `padding` on every side) without cropping it — via + * VegaZoom.pickFitZoomIndex, same math VegaCombatCamera.js's "frame the + * whole fleet" opening shot uses — then pan so the box's center lands at + * `(focusX, focusY)` in screen space. `focusX`/`focusY` default to the + * viewport's own center, same as `viewW`/`viewH` default to the full + * screen; the guided tutorial narrows all four so the frame is biased left + * of the docked command panel instead of packing stars in behind it. + */ + fitToWorldBounds(minX, minY, maxX, maxY, { + padding = 0, viewW = GAME_WIDTH, viewH = GAME_HEIGHT, + focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2, + } = {}) { + const idx = pickFitZoomIndex(this.zooms, maxX - minX, maxY - minY, padding, { viewW, viewH }); + this.zoomIndex = idx; + this.zoom = this.zooms[idx]; + this.root.setScale(this.zoom); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + this.root.x = focusX - cx * this.zoom; + this.root.y = focusY - cy * this.zoom; + this.clampPan(); + this.refreshLabels(); + this.refreshStars(); + this.refreshColonyInfo(); + } + panToStar(starIdx, duration = 420) { const star = this.state.galaxy.stars[starIdx]; if (!star) return; diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index 09cc18f..2b3aa8e 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -29,7 +29,14 @@ import { } from './VegaLogic.js'; export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { - const { viewerIdx = state.humanIndex, onChanged = null, onClose = null } = opts; + const { + viewerIdx = state.humanIndex, onChanged = null, onClose = null, + // Fired right when the "View Colony" button opens the colony screen over + // this one — nothing else in the game needs to know that + // (MasterOfVegaGame.js's openSystemViewFor never passes it), but the + // guided tutorial does, since it has no other hook into that click. + onColonyOpen = null, + } = opts; const star = state.galaxy.stars[starIdx]; const shell = modalShell(scene, star.name, onClose, { width: 1620, height: 900 }); @@ -357,6 +364,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { rebuild(); }, }); + onColonyOpen?.(colony); }, { width: panelW, height: 50, fontSize: 21 })); } diff --git a/src/games/mastervega/VegaTutorial.js b/src/games/mastervega/VegaTutorial.js new file mode 100644 index 0000000..ae5bdf7 --- /dev/null +++ b/src/games/mastervega/VegaTutorial.js @@ -0,0 +1,1043 @@ +// Master of Vega — the in-game guided tutorial: overlay renderer + step driver. +// +// The tutorial SCRIPT is data (data/mastervega-tutorial.json, validated and +// interpolated by the headless VegaTutorialData.js). This file is the Phaser +// half: it darkens the screen except for a few highlighted things, shows a +// centred modal or an anchored callout window per step, plays the step's +// voice-over, and advances on a button or a click on the highlighted thing. +// +// While the tutorial is up it holds `scene.modalOpen = true` — the same flag +// the real modals use, which freezes star-map pan/zoom, every map click +// handler and every HUD button (all of them early-return on modalOpen). The +// side panel's OWN controls (ship rows, −/+/✕) do not check modalOpen, so a +// step whose hole sits over the panel still lets the player click those — that +// is deliberate (see the fleet-ship steps). +// +// The darken effect is four opaque strips framing one rectangular hole (the +// union of the step's highlight targets), NOT a mask cutout — trivial to lay +// out, and everything worth highlighting here is rectangular anyway. On top, +// each star-map target gets an accent ring and each side-panel region a +// yellow box. Targets that move (the panel rebuilds on every −/+ click) are +// tracked by a per-frame fingerprint in update() and re-laid-out on change. +// D.tutorial sits just below D.detail so a ship-detail pop-over the tutorial +// invites the player to open layers cleanly on top of the overlay. +// +// System View / Colony View are opened by calling VegaSystemView.js's +// openSystemView() DIRECTLY, bypassing MasterOfVegaGame.openSystemViewFor()/ +// openModal() — the same reasoning VegaSystemView.js already uses to bypass +// openModal for its OWN "View Colony" button ("this one has to stay up, +// because it is what is keeping the star map behind it inert"): openModal() +// refuses a second modal while one is already up, and the tutorial has had +// scene.modalOpen pinned true since start(). Calling openSystemView directly +// sidesteps that gate entirely, and its own onClose/onColonyOpen opts are +// wired straight to VegaTutorial's own advance() — no polling needed for +// those steps (see 'external' in ADVANCE_MODES). The one place +// modalOpen genuinely has to move is the End Turn hotspot: onEndTurn() is +// gated by the SAME modalOpen check (`if (this.busy || this.modalOpen) +// return;`), and there is no direct-call bypass available for something this +// stateful, so that one handler flips modalOpen false just long enough to +// pass the gate — see _onHotspot's 'endTurn' case. + +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from './VegaButton.js'; +import { + FONT, D, uiClick, openDiplomacyScreen, openLeaderScreen, +} from './VegaScreens.js'; +import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js'; +import { resolveSteps } from './VegaTutorialData.js'; +import { reachableStars } from './VegaLogic.js'; +import { openSystemView } from './VegaSystemView.js'; +import { openResearchScreen } from './VegaResearchScreen.js'; +import { openColoniesScreen } from './VegaColoniesScreen.js'; + +const ACCENT = 0x6fc4ff; +const HILITE_YELLOW = 0xffd400; +const PANEL = 0x0b1220; +const DIM_COLOR = 0x00060e; +const DIM_ALPHA = 0.72; +const HOLE_PAD = 18; + +// Keep windows clear of the docked right-hand command panel (400px wide, 16px +// gutter — see VegaSidePanel.js) even though it is hidden at tutorial time. +const SAFE_RIGHT = GAME_WIDTH - 440; + +// Fixed placements for a `kind: 'modal'` step's window, used only by steps +// with `noDim: true` (System View / Colony View are open underneath and must +// stay visible/clickable — see the header note on `noDim`). Both dock to an +// edge rather than the screen's true center, which the live content behind +// them is using: `topBanner` sits in System View's own empty margin above +// its shell (VegaSystemView.js's modalShell starts at y=90); `colonyCenter` +// sits in the empty stretch of world backdrop between Colony View's top-left +// masthead and its right-hand panel (VegaColonyView.js: masthead text ends +// well above y=260, the panel starts at x=1416). +const WINDOW_POS = Object.freeze({ + topBanner: { + cx: GAME_WIDTH / 2, top: 8, w: 1300, minH: 130, + }, + colonyCenter: { + cx: 1020, top: 260, w: 640, minH: 160, + }, +}); + +// The Empire dropdown's four rows, top to bottom — fixed order +// MasterOfVegaGame.openEmpireMenu() builds them in. Index into this array is +// what _empireMenuItemTarget positions. +const EMPIRE_MENU_ITEMS = Object.freeze([ + 'empireMenuResearch', 'empireMenuDiplomacy', 'empireMenuLeaders', 'empireMenuColonies', +]); + +function cornerTicks(scene, x, y, w, h, len = 22) { + const g = scene.add.graphics(); + g.lineStyle(2.5, ACCENT, 0.9); + for (const [cx, cy, dx, dy] of [ + [x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1], + ]) { + g.lineBetween(cx, cy, cx + dx * len, cy); + g.lineBetween(cx, cy, cx, cy + dy * len); + } + return g; +} + +export class VegaTutorial { + /** + * @param {Phaser.Scene} scene MasterOfVegaGame + * @param {object} opts + * @param {object} opts.data parsed + validated tutorial JSON + * @param {object} opts.vars { species: 'Humans', ... } for {token} substitution + * @param {Function} [opts.onFinish] + */ + constructor(scene, opts = {}) { + this.scene = scene; + this.opts = opts; + this.data = opts.data; + this.steps = []; + this.stepIndex = 0; + this.layer = null; + this._stepLayer = null; + this._skipLayer = null; + this._ringTween = null; + this._sawDetail = false; + this._voicedStep = -1; + this._dead = false; + this._started = false; + // The directly-opened System View shell (see the header note above), if + // one is currently up — destroyed defensively in destroy() in case the + // scene tears down mid-step. + this._systemView = null; + // Whichever Empire-menu screen (Research/Diplomacy/Leaders/Colonies) the + // tutorial opened directly is currently up, reduced to a single no-arg + // closer — the four screens don't share a return shape (modalShell's + // `{ destroy }` vs. openColoniesScreen's `{ close }`), so _openEmpireScreen + // normalises it here rather than every caller caring which is which. + this._empireScreenClose = null; + // Set true the instant the End Turn hotspot fires; update() then polls + // scene.busy (cleared only once the WHOLE turn-resolution pipeline — + // battles, audiences, turn report, GNN — has drained) before continuing + // into the Empire-menu tour. + this._awaitingEndTurn = false; + this._shutdown = () => this.destroy(); + } + + // --------------------------------------------------------------- lifecycle + + start() { + if (this._started || this._dead) return; + this._started = true; + + this.steps = resolveSteps(this.data, this.opts.vars || {}); + if (!this.steps.length) { this.finish(); return; } + + this.scene.modalOpen = true; + + const emp = this._humanEmpire(); + if (emp && this.scene.map?.centerOn) this.scene.map.centerOn(emp.homeStar); + + this.layer = this.scene.add.container(0, 0).setDepth(D.tutorial); + this.scene.events.once('shutdown', this._shutdown); + + this.stepIndex = 0; + this._renderStep(0); + } + + /** + * Per-frame housekeeping: advance a `shipDetailClosed` step once the player + * has opened and closed a ship detail window, and re-lay-out the current + * step if a highlighted target moved (the side panel rebuilds itself on + * every −/+ click; the map is frozen but this covers it too). + */ + update() { + if (this._dead || !this.layer || !this.steps.length || this._skipLayer) return; + + // End Turn is the one action with no direct-call bypass and no button + // of the tutorial's own to hook — onEndTurn() runs the whole turn- + // resolution pipeline (battles, audiences, turn report, GNN) and only + // clears scene.busy once every last bit of it has drained, so that is + // the one reliable "fully done" signal. Nothing else in update() should + // run meanwhile — the star map/panel underneath may be showing any of + // those screens right now. + if (this._awaitingEndTurn) { + if (!this.scene.busy) { + this._awaitingEndTurn = false; + // onEndTurn()'s own pipeline manages modalOpen for whatever it + // showed along the way and may have left it false (a plain turn with + // nothing notable never touches it past the initial unlock either) — + // re-freeze it before continuing into the Empire-menu tour. + this.scene.modalOpen = true; + this.advance(); + } + return; + } + + const step = this.steps[this.stepIndex]; + if (!step) return; + + if (step.advanceOn === 'shipDetailClosed') { + const open = !!this.scene.panel?.detailOpen; + if (open) { + this._sawDetail = true; + } else if (this._sawDetail) { + this._sawDetail = false; + this.advance(); + return; + } + } + + // The move-fleet step's Accept button is the REAL side-panel button + // (VegaSidePanel.buildOrder → MasterOfVegaGame.confirmOrder), not + // anything the tutorial overlay owns, so there is no click to hook — + // polling for the fleet actually departing is the only way to know. + if (step.advanceOn === 'fleetInFlight') { + const st = this.scene.state; + const human = st?.humanIndex; + if (st?.fleets?.some((f) => f.empireIdx === human && f.toStar >= 0)) { + this.advance(); + return; + } + } + + const sig = this._targetsSig(step); + if (this._lastTargetsSig !== undefined && sig !== this._lastTargetsSig) { + this._lastTargetsSig = sig; + this._renderStep(this.stepIndex); + } + } + + /** A cheap fingerprint of where this step's targets currently sit, so a + * panel rebuild (or map move) triggers a re-layout. */ + _targetsSig(step) { + const ids = [...new Set([...(step.highlights || []), step.anchor].filter(Boolean))]; + return ids.map((id) => { + const t = this._resolveTarget(id); + if (Array.isArray(t)) { + return `${id}:${t.map((x) => `${Math.round(x.cx)},${Math.round(x.cy)}`).join(';')}`; + } + return t + ? `${id}:${Math.round(t.cx)},${Math.round(t.cy)},${Math.round(t.rx)},${Math.round(t.ry)}` + : `${id}:none`; + }).join('|'); + } + + destroy() { + if (this._dead) return; + this._dead = true; + resetSpeechQueue(); + if (this._ringTween) { this._ringTween.stop(); this._ringTween = null; } + this.scene.events.off('shutdown', this._shutdown); + this.scene.tutorialFreePan = false; + // Scene teardown tears this down regardless, but a mid-flow destroy from + // anywhere else (there's no Skip button once this stretch starts, but + // belt-and-braces) shouldn't leave a directly-opened System View orphaned + // on screen with nothing left to close it. + if (this._systemView) { this._systemView.destroy(); this._systemView = null; } + if (this._empireScreenClose) { this._empireScreenClose(); this._empireScreenClose = null; } + this.layer?.destroy(); + this.layer = null; + this._stepLayer = null; + this._skipLayer = null; + } + + // ------------------------------------------------------------ state machine + + advance() { + if (this.stepIndex + 1 >= this.steps.length) { this.finish(); return; } + this._sawDetail = false; + this.stepIndex += 1; + this._renderStep(this.stepIndex); + } + + back() { + if (this.stepIndex === 0) return; + this._sawDetail = false; + this.stepIndex -= 1; + this._renderStep(this.stepIndex); + } + + skip() { + if (this._skipLayer) return; + this._renderSkipConfirm(); + } + + finish() { + const onFinish = this.opts.onFinish; + this.destroy(); + this.scene.modalOpen = false; + this.scene.refreshAll?.(); + onFinish?.(); + } + + _onButton(action) { + switch (action) { + case 'next': + // A step's own `advanceAction` (VegaTutorialData.js's ADVANCE_ACTIONS) + // runs BEFORE advancing — closing whichever Empire-menu screen this + // step opened directly, and possibly reopening the dropdown behind + // the next one. See _runAdvanceAction. + this._runAdvanceAction(this.steps[this.stepIndex]?.advanceAction); + this.advance(); + break; + case 'back': this.back(); break; + case 'skip': this.skip(); break; + case 'finish': this.finish(); break; + default: break; + } + } + + _runAdvanceAction(action) { + if (action === 'closeEmpireScreenReopenMenu' || action === 'closeEmpireScreenAndMenu') { + this._empireScreenClose?.(); + this._empireScreenClose = null; + if (action === 'closeEmpireScreenReopenMenu') this.scene.openEmpireMenu?.(); + else this.scene.closeEmpireMenu?.(); + } + } + + // -------------------------------------------------------------- resolution + + _humanEmpire() { + const st = this.scene.state; + return st?.empires?.[st.humanIndex] ?? null; + } + + _homeFleet() { + const st = this.scene.state; + const emp = this._humanEmpire(); + if (!st || !emp) return null; + return st.fleets.find((f) => f.empireIdx === st.humanIndex + && f.starIdx === emp.homeStar && f.toStar < 0) ?? null; + } + + /** Whichever human fleet is currently en route somewhere (toStar >= 0). */ + _travelingFleet() { + const st = this.scene.state; + if (!st) return null; + return st.fleets.find((f) => f.empireIdx === st.humanIndex && f.toStar >= 0) ?? null; + } + + /** + * Zoom/pan so home plus every reachable star is inside the visible map + * area, biased left of the docked command panel (visible throughout this + * stretch of the tutorial) rather than centered on the raw screen — called + * once, on fresh entry into the step right after the free-pan/zoom lesson + * (step.fitReachableStars), regardless of wherever the player left the + * camera while trying it out. Reuses VegaStarMap.fitToWorldBounds, the + * same ladder-fit math VegaCombatCamera.js's "frame the whole fleet" + * opening shot uses. + */ + _fitReachableStarsView() { + const { map, state } = this.scene; + const emp = this._humanEmpire(); + if (!map || !state || !emp) return; + const reach = reachableStars(this.scene.rules, state, state.humanIndex); + const idxs = new Set([emp.homeStar, ...Object.keys(reach).map(Number)]); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const idx of idxs) { + const star = state.galaxy.stars[idx]; + if (!star) continue; + minX = Math.min(minX, star.x); + minY = Math.min(minY, star.y); + maxX = Math.max(maxX, star.x); + maxY = Math.max(maxY, star.y); + } + if (!Number.isFinite(minX)) return; + map.fitToWorldBounds(minX, minY, maxX, maxY, { + padding: 180, viewW: SAFE_RIGHT, viewH: GAME_HEIGHT, focusX: SAFE_RIGHT / 2, focusY: GAME_HEIGHT / 2, + }); + } + + /** + * Screen rect of item `index` (0-based) in the Empire dropdown, or null + * while it isn't open. Mirrors MasterOfVegaGame.openEmpireMenu()'s own + * geometry exactly (BTN_W/BTN_H/GAP/PAD, and reading empireBtn's live + * x/y/width the same way that method does) — ITEMS is hardcoded to 4 + * (Research/Diplomacy/Leaders/Colonies) because Council only ever joins + * the menu once state.council.lastResult is set, which that method's own + * comment notes can't happen this early in a fresh game. + */ + _empireMenuItemTarget(index) { + const { empireBtn, empireMenuLayer } = this.scene; + if (!empireBtn || !empireMenuLayer) return null; + const BTN_W = 200; + const BTN_H = 42; + const GAP = 8; + const PAD = 14; + const ITEMS = 4; + const panelCx = empireBtn.x - (empireBtn.options?.width ?? 190) / 2 + BTN_W / 2; + const panelTopY = empireBtn.y + 21 + 12; + const panelH = PAD * 2 + ITEMS * BTN_H + (ITEMS - 1) * GAP; + const panelCy = panelTopY + panelH / 2; + const firstY = panelCy - panelH / 2 + PAD + BTN_H / 2; + return { + cx: panelCx, cy: firstY + index * (BTN_H + GAP), rx: BTN_W / 2, ry: BTN_H / 2, shape: 'rect', + }; + } + + /** + * Open one of the Empire dropdown's four screens DIRECTLY (same + * openModal-bypass reasoning as _openSystemView — see the header note), + * and normalise its return shape (modalShell's `{ destroy }` for Research/ + * Diplomacy/Leaders vs. openColoniesScreen's own `{ close }`) into a + * single no-arg `this._empireScreenClose`, run by _runAdvanceAction on the + * matching '-info' step's Next button. None of the four need a real + * onChanged/onClose hook back into the tutorial — unlike System View, the + * player is never asked to click anything inside them, only to read and + * press Next, so a plain refresh callback (or none) is enough. + */ + _openEmpireScreen(kind) { + const scene = this.scene; + const { + rules, state, art, + } = scene; + const human = state.humanIndex; + if (kind === 'research') { + const shell = openResearchScreen(scene, rules, state, human, art, () => {}); + this._empireScreenClose = () => shell.destroy(); + } else if (kind === 'diplomacy') { + const shell = openDiplomacyScreen(scene, rules, state, human, art, () => {}, () => scene.refreshAll()); + this._empireScreenClose = () => shell.destroy(); + } else if (kind === 'leaders') { + const shell = openLeaderScreen(scene, rules, state, human, art, () => {}, () => scene.refreshHud()); + this._empireScreenClose = () => shell.destroy(); + } else if (kind === 'colonies') { + const screen = openColoniesScreen(scene, rules, state, human, art, { onChanged: () => scene.refreshAll() }); + this._empireScreenClose = () => screen.close(); + } + } + + /** + * A fleet's marker on the map, reduced to the same { cx, cy, rx, ry, shape } + * shape every other target uses. Falls back to a fixed offset from the + * fleet's anchor star (its current position if docked, its destination if + * mid-flight — the only real star anchor available then) when the marker + * itself hasn't been laid out yet this frame. + */ + _fleetMarkerTarget(fleet) { + const { map, state } = this.scene; + if (!map || !fleet) return null; + const zoom = map.zoom ?? 1; + const toScreen = (wx, wy) => ({ x: map.root.x + wx * zoom, y: map.root.y + wy * zoom }); + const marker = map.fleetMarkers?.find((m) => m.fleet === fleet); + let wx; + let wy; + if (marker?.container) { + wx = marker.container.x; + wy = marker.container.y; + } else { + const star = state.galaxy.stars[fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar]; + if (!star) return null; + wx = star.x + 26; + wy = star.y - 22; + } + const p = toScreen(wx, wy); + const r = 20 * zoom + 8; + return { cx: p.x, cy: p.y, rx: r, ry: r, shape: 'circle' }; + } + + /** + * Target id -> { cx, cy, rx, ry, shape } in screen space, or null if it + * can't be found — EXCEPT 'nearbyStars', which resolves to an ARRAY of + * such objects (one per reachable star), each also carrying `starIdx`. + * `shape` is 'circle' (star-map things, drawn as an accent ring), 'rect' + * (side-panel regions, drawn as a yellow box) or 'invisible' (carves a hole + * with no ring — 'mapArea', which just needs to uncover the map). + */ + _resolveTarget(id) { + const { map, state, panel } = this.scene; + const emp = this._humanEmpire(); + if (!state || !emp) return null; + + if (id === 'homeStar') { + if (!map) return null; + const zoom = map.zoom ?? 1; + const star = state.galaxy.stars[emp.homeStar]; + if (!star) return null; + const p = { x: map.root.x + star.x * zoom, y: map.root.y + star.y * zoom }; + const clsR = map.starSprites?.[emp.homeStar]?.cls?.radius ?? 12; + const r = (clsR * 2.2 + 14) * zoom + 6; + return { cx: p.x, cy: p.y, rx: r, ry: r, shape: 'circle' }; + } + + if (id === 'homeFleet') return this._fleetMarkerTarget(this._homeFleet()); + if (id === 'travelingFleet') return this._fleetMarkerTarget(this._travelingFleet()); + + if (id === 'nearbyStars') { + if (!map) return null; + const zoom = map.zoom ?? 1; + const reach = reachableStars(this.scene.rules, state, state.humanIndex); + const out = []; + for (const key of Object.keys(reach)) { + const idx = Number(key); + if (idx === emp.homeStar) continue; + const star = state.galaxy.stars[idx]; + if (!star) continue; + const p = { x: map.root.x + star.x * zoom, y: map.root.y + star.y * zoom }; + // Off the current view entirely — not worth a hotspot or a hole that + // would just balloon out past the screen edge. + if (p.x < -40 || p.x > GAME_WIDTH + 40 || p.y < -40 || p.y > GAME_HEIGHT + 40) continue; + const clsR = map.starSprites?.[idx]?.cls?.radius ?? 12; + const r = (clsR * 2.2 + 14) * zoom + 6; + out.push({ + cx: p.x, cy: p.y, rx: r, ry: r, shape: 'circle', starIdx: idx, + }); + } + return out; + } + + if (id === 'mapArea') { + // A big hole with no ring — just uncovers the map area (left of the + // docked panel) so a pan/zoom the player tries is actually visible. + return { + cx: SAFE_RIGHT / 2, cy: GAME_HEIGHT / 2, rx: SAFE_RIGHT / 2, ry: GAME_HEIGHT / 2, shape: 'invisible', + }; + } + + if (id === 'panel') { + const rect = panel?.tutorialRegion?.('panel'); + if (!rect) return null; + return { + cx: rect.x + rect.w / 2, cy: rect.y + rect.h / 2, rx: rect.w / 2, ry: rect.h / 2, shape: 'rect', + }; + } + + if (id === 'endTurnButton') { + const btn = this.scene.endTurnBtn; + if (!btn) return null; + // Fixed footprint (MasterOfVegaGame.js: width 190, height 46) — the + // Button's own container position is read live rather than hardcoded + // too, so this stays correct if the HUD is ever rearranged. + return { + cx: btn.x, cy: btn.y, rx: 95, ry: 23, shape: 'rect', + }; + } + + if (id === 'empireButton') { + const btn = this.scene.empireBtn; + if (!btn) return null; + // Fixed footprint (MasterOfVegaGame.js: width 190, height 42). + return { + cx: btn.x, cy: btn.y, rx: (btn.options?.width ?? 190) / 2, ry: 21, shape: 'rect', + }; + } + + if (id.startsWith('empireMenu')) { + const item = EMPIRE_MENU_ITEMS.indexOf(id); + return item < 0 ? null : this._empireMenuItemTarget(item); + } + + if (id === 'fleetShipProfiles' || id === 'fleetCountControls') { + const region = id === 'fleetShipProfiles' ? 'shipProfiles' : 'countControls'; + const rect = panel?.tutorialRegion?.(region); + if (!rect) return null; + return { + cx: rect.x + rect.w / 2, + cy: rect.y + rect.h / 2, + rx: rect.w / 2, + ry: rect.h / 2, + shape: 'rect', + }; + } + + return null; + } + + /** A resolved target reduced to the point + radius the callout aims at. */ + static _anchorPoint(t) { + return { x: t.cx, y: t.cy, r: Math.max(t.rx, t.ry) }; + } + + // ----------------------------------------------------------------- render + + _clearStep() { + if (this._ringTween) { this._ringTween.stop(); this._ringTween = null; } + this._skipLayer?.destroy(); + this._skipLayer = null; + this._stepLayer?.destroy(); + this._stepLayer = null; + this._hotspot = null; + this._hotspots = null; + } + + _renderStep(i) { + if (this._dead || !this.layer) return; + // A pure re-layout (update() saw a target move) must not restart the + // voice — only a genuine step change does. + const freshStep = this._voicedStep !== i; + if (freshStep) resetSpeechQueue(); + this._clearStep(); + const step = this.steps[i]; + if (!step) { this.finish(); return; } + + // Pan/zoom are frozen with the rest of the map by default (modalOpen); + // only the step that teaches them punches through, and only for those. + this.scene.tutorialFreePan = !!step.freePan; + + if (freshStep && step.fitReachableStars) this._fitReachableStarsView(); + // Re-arm the fleet selection a prior step's star-info click may have + // cleared (that click mirrors onStarClick's read-only branch, which + // always drops scene.selectedFleet) so this step's own star clicks quote + // a move order instead of just showing star info again. + if (freshStep && step.selectHomeFleet) { + const fleet = this._homeFleet(); + if (fleet) { + this.scene.selectedFleet = fleet; + this.scene.map?.setSelectedFleet?.(fleet); + this.scene.panel?.showFleet?.(fleet); + } + } + + const sl = this.scene.add.container(0, 0); + this._stepLayer = sl; + this.layer.add(sl); + + // --- darken, with a hole around the highlighted targets. 'nearbyStars' + // resolves to an array rather than a single target, so flatten. Skipped + // entirely for a step with `noDim` — one where System View or Colony + // View (opened directly, below the tutorial layer but not gated by it — + // see the header note) is already showing and must stay fully visible + // and undimmed; those steps have nothing on the star map worth spotlighting + // anyway, since the map itself is hidden behind that screen right now. + if (!step.noDim) { + const targets = (step.highlights || []).flatMap((id) => { + const t = this._resolveTarget(id); + return Array.isArray(t) ? t : (t ? [t] : []); + }); + this._drawDim(sl, targets); + this._drawRings(sl, targets); + } + + // --- the step's window + if (step.kind === 'callout') this._renderCallout(sl, step); + else this._renderModal(sl, step); + + // --- voice (once per step, not on a re-layout) + if (freshStep) { + this._voicedStep = i; + if (step.voice) enqueueSpeech(step.voice, null, { force: true }); + } + + this._lastTargetsSig = this._targetsSig(step); + } + + _drawDim(sl, targets) { + const strip = (x, y, w, h) => { + if (w <= 0 || h <= 0) return; + sl.add(this.scene.add.rectangle(x, y, w, h, DIM_COLOR, DIM_ALPHA) + .setOrigin(0, 0).setInteractive()); + }; + + if (!targets.length) { + strip(0, 0, GAME_WIDTH, GAME_HEIGHT); + return; + } + + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const t of targets) { + x0 = Math.min(x0, t.cx - t.rx); + y0 = Math.min(y0, t.cy - t.ry); + x1 = Math.max(x1, t.cx + t.rx); + y1 = Math.max(y1, t.cy + t.ry); + } + x0 = Math.max(0, x0 - HOLE_PAD); + y0 = Math.max(0, y0 - HOLE_PAD); + x1 = Math.min(GAME_WIDTH, x1 + HOLE_PAD); + y1 = Math.min(GAME_HEIGHT, y1 + HOLE_PAD); + + strip(0, 0, GAME_WIDTH, y0); // top + strip(0, y1, GAME_WIDTH, GAME_HEIGHT - y1); // bottom + strip(0, y0, x0, y1 - y0); // left + strip(x1, y0, GAME_WIDTH - x1, y1 - y0); // right + } + + _drawRings(sl, targets) { + if (!targets.some((t) => t.shape !== 'invisible')) return; + const g = this.scene.add.graphics(); + sl.add(g); + const paint = (alpha) => { + g.clear(); + for (const t of targets) { + if (t.shape === 'invisible') { + continue; + } else if (t.shape === 'rect') { + g.lineStyle(3, HILITE_YELLOW, alpha); + g.strokeRect(t.cx - t.rx, t.cy - t.ry, t.rx * 2, t.ry * 2); + } else { + g.lineStyle(2.5, ACCENT, alpha); + g.strokeCircle(t.cx, t.cy, Math.max(t.rx, t.ry)); + } + } + }; + paint(0.9); + const pulse = { a: 0.9 }; + this._ringTween = this.scene.tweens.add({ + targets: pulse, + a: 0.35, + duration: 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + onUpdate: () => paint(pulse.a), + }); + } + + /** Shared window chrome: a panel container centred on (cx, cy). */ + _window(sl, w, h, cx, cy) { + const x = Math.round(cx - w / 2); + const y = Math.round(cy - h / 2); + const c = this.scene.add.container(0, 0); + sl.add(c); + c.add(this.scene.add.rectangle(x, y, w, h, PANEL, 0.97).setOrigin(0, 0) + .setStrokeStyle(1.5, ACCENT, 0.65).setInteractive()); + c.add(cornerTicks(this.scene, x, y, w, h, 22)); + return { c, x, y, w, h }; + } + + _addButtons(container, step, baseX, baseY) { + const gap = 22; + const bw = 220; + const bh = 54; + const total = step.buttons.length * bw + (step.buttons.length - 1) * gap; + let bx = baseX - total / 2 + bw / 2; + for (const b of step.buttons) { + const variant = b.action === 'skip' ? 'ghost' : 'solid'; + const btn = new Button(this.scene, bx, baseY, b.label, + uiClick(this.scene, () => this._onButton(b.action)), + { width: bw, height: bh, fontSize: 20, variant }); + container.add(btn); + bx += bw + gap; + } + } + + _renderModal(sl, step) { + // A step with `noDim` (System View / Colony View showing underneath — + // see the header note) docks its window to WINDOW_POS instead of true + // screen-center, which the live content behind it is using. + const pos = step.noDim ? WINDOW_POS[step.windowPos] : null; + const w = pos?.w ?? 980; + const bodyText = this.scene.add.text(0, 0, step.body ?? '', { + fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', + lineSpacing: 6, align: 'left', wordWrap: { width: w - 96 }, + }); + const hasTitle = !!(step.title && step.title.trim()); + const titleH = hasTitle ? 46 : 0; + const h = Math.max(pos?.minH ?? 240, titleH + 64 + bodyText.height + 110); + const cx = pos?.cx ?? GAME_WIDTH / 2; + const cy = pos?.top !== undefined ? pos.top + h / 2 : (pos?.cy ?? GAME_HEIGHT / 2); + const win = this._window(sl, w, h, cx, cy); + + let ty = win.y + 34; + if (hasTitle) { + win.c.add(this.scene.add.text(win.x + 48, ty, step.title.toUpperCase(), { + fontFamily: FONT, fontSize: '26px', color: '#ffd88a', + })); + ty += titleH; + } + bodyText.setPosition(win.x + 48, ty); + win.c.add(bodyText); + + this._addButtons(win.c, step, cx, win.y + h - 44); + } + + _renderCallout(sl, step) { + // 'nearbyStars' resolves to an array (see _resolveTarget) — the first + // reachable star stands in as the single point the connector line aims + // at; the per-star click targets below cover the rest of them. + const rawTarget = this._resolveTarget(step.anchor); + const target = Array.isArray(rawTarget) ? rawTarget[0] : rawTarget; + const anchor = target ? VegaTutorial._anchorPoint(target) : null; + const w = 460; + + const textObj = this.scene.add.text(0, 0, step.calloutText ?? '', { + fontFamily: FONT, fontSize: '19px', color: '#e8f4ff', + lineSpacing: 5, wordWrap: { width: w - 64 }, + }); + const btnRow = step.buttons.length ? 74 : 24; + const h = Math.max(120, 40 + textObj.height + btnRow); + + // Default below-right of the anchor; flip/clamp to stay on screen and + // clear of the right-hand command panel (SAFE_RIGHT), so a callout + // anchored to a panel region lands to its left. + let cx = (anchor?.x ?? GAME_WIDTH / 2) + 40 + w / 2; + let cy = (anchor?.y ?? GAME_HEIGHT / 2) + 60 + h / 2; + if (cx + w / 2 > SAFE_RIGHT) cx = (anchor?.x ?? GAME_WIDTH / 2) - 40 - w / 2; + cx = Phaser.Math.Clamp(cx, w / 2 + 20, SAFE_RIGHT - w / 2); + cy = Phaser.Math.Clamp(cy, h / 2 + 20, GAME_HEIGHT - h / 2 - 20); + + const win = this._window(sl, w, h, cx, cy); + + // connector line + arrowhead from the anchor to the nearest window edge + if (anchor) this._drawConnector(win.c, anchor, win); + + textObj.setPosition(win.x + 32, win.y + 26); + win.c.add(textObj); + + if (step.buttons.length) this._addButtons(win.c, step, cx, win.y + h - 40); + + // click-the-thing hotspot. A 'rect' target (e.g. 'panel') gets a hotspot + // matching its actual w/h rather than a square built off anchor.r — that + // radius is the LONGER of a rect's two half-extents, and squaring it off + // a wide-but-short panel would overshoot past its edges into whatever's + // beside it (the star map, for 'panel'). + if (step.advanceOn === 'hotspot' && anchor && target) { + const hw = target.shape === 'rect' ? target.rx * 2 : Math.max(44, anchor.r * 2); + const hh = target.shape === 'rect' ? target.ry * 2 : Math.max(44, anchor.r * 2); + const hs = this.scene.add.rectangle(anchor.x, anchor.y, hw, hh, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }); + hs.on('pointerup', () => this._onHotspot(step)); + sl.add(hs); + this._hotspot = hs; + } + + // One click target per reachable star. Independent of advanceOn: 'info' + // both performs the click and advances (see _onStarPick); 'order' only + // quotes the move order — this step's advanceOn (should be + // 'fleetInFlight') is what actually advances it, once the player presses + // the panel's own real Accept button. + if (step.starPick) { + const stars = this._resolveTarget('nearbyStars') || []; + this._hotspots = stars.map((t) => { + const size = Math.max(44, Math.max(t.rx, t.ry) * 2); + const hs = this.scene.add.rectangle(t.cx, t.cy, size, size, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }); + hs.on('pointerup', () => this._onStarPick(step, t.starIdx)); + sl.add(hs); + return hs; + }); + } + } + + /** + * A click on one of the 'nearbyStars' hotspots. Mirrors the two branches + * MasterOfVegaGame.onStarClick itself takes — 'info' is the read-only + * "look at the place" branch (clears the selected fleet, shows the star + * panel, then advances the tutorial itself); 'order' is the "quote a + * route" branch (sets the map's route preview and opens the panel's order + * view) and deliberately does NOT advance — the player still has to press + * that panel's real Accept button, which VegaTutorial has no hook into + * except by polling (see update()'s 'fleetInFlight' check). + */ + _onStarPick(step, starIdx) { + const scene = this.scene; + if (step.starPick === 'order') { + const fleet = this._homeFleet() ?? scene.selectedFleet; + if (!fleet) return; + const originIdx = fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar; + scene.map?.setRoutePreview(originIdx, starIdx); + scene.panel?.showOrder(starIdx); + return; + } + scene.map?.setSelectedStar(starIdx); + scene.selectedFleet = null; + scene.panel?.showStar(starIdx); + for (const hs of this._hotspots || []) hs.disableInteractive(); + this._hotspots = null; + scene.time.delayedCall(200, () => { if (!this._dead) this.advance(); }); + } + + _drawConnector(container, anchor, win) { + // Aim at the window-edge point closest to the anchor. + const tx = Phaser.Math.Clamp(anchor.x, win.x, win.x + win.w); + const ty = Phaser.Math.Clamp(anchor.y, win.y, win.y + win.h); + const ax = anchor.x; + const ay = anchor.y; + const dx = tx - ax; + const dy = ty - ay; + const len = Math.hypot(dx, dy); + const g = this.scene.add.graphics(); + container.add(g); + if (len < 2) return; + const ux = dx / len; + const uy = dy / len; + + const dash = 18; + const gap = 12; + const period = dash + gap; + g.lineStyle(3, 0x9fd8ff, 0.9); + // start a little outside the anchor ring so the line doesn't cross it + for (let d = anchor.r + 4; d < len; d += period) { + const s = d; + const e = Math.min(d + dash, len); + if (e <= s) continue; + g.beginPath(); + g.moveTo(ax + ux * s, ay + uy * s); + g.lineTo(ax + ux * e, ay + uy * e); + g.strokePath(); + } + // arrowhead at the window edge, pointing at it + const ang = Math.atan2(dy, dx); + const ah = 12; + const tip = { x: tx, y: ty }; + g.fillStyle(0x9fd8ff, 0.95); + g.beginPath(); + g.moveTo(tip.x, tip.y); + g.lineTo(tip.x - Math.cos(ang - 2.6) * ah, tip.y - Math.sin(ang - 2.6) * ah); + g.lineTo(tip.x - Math.cos(ang + 2.6) * ah, tip.y - Math.sin(ang + 2.6) * ah); + g.closePath(); + g.fillPath(); + } + + _retireHotspot() { + if (this._hotspot) { this._hotspot.disableInteractive(); this._hotspot = null; } + } + + /** + * A click on the single `advanceOn: 'hotspot'` target. Which real action it + * performs is the step's own `hotspotAction` (VegaTutorialData.js's + * HOTSPOT_ACTIONS) rather than a match on `step.anchor`, so more than one + * step can point at the same anchor (both 'home-star' and 'view-system' + * point at the home star / panel) without the two colliding. + */ + _onHotspot(step) { + const scene = this.scene; + switch (step.hotspotAction) { + case 'selectHomeFleet': { + const fleet = this._homeFleet(); + if (fleet) { + scene.selectedFleet = fleet; + scene.map?.setSelectedFleet?.(fleet); + scene.panel?.showFleet?.(fleet); + } + // Let the panel finish sliding in before the next step lays its + // spotlight over it. + this._retireHotspot(); + scene.time.delayedCall(240, () => { if (!this._dead) this.advance(); }); + return; + } + case 'showHomeStar': { + const emp = this._humanEmpire(); + if (emp) { + scene.map?.setSelectedStar(emp.homeStar); + scene.selectedFleet = null; + scene.panel?.showStar(emp.homeStar); + } + this._retireHotspot(); + scene.time.delayedCall(200, () => { if (!this._dead) this.advance(); }); + return; + } + case 'openSystemView': { + const emp = this._humanEmpire(); + this._retireHotspot(); + if (emp) this._openSystemView(emp.homeStar); + this.advance(); + return; + } + case 'endTurn': { + this._retireHotspot(); + // The one place modalOpen genuinely has to move — see the header + // note. onEndTurn()'s own gate (`if (this.busy || this.modalOpen) + // return;`) is the only thing standing between this click and a + // silent no-op; everything after that gate manages modalOpen (and + // this.busy) itself exactly as it would for a normal, non-tutorial + // End Turn click, so there is nothing further to bypass. + scene.modalOpen = false; + scene.onEndTurn(); + this._awaitingEndTurn = true; + return; + } + case 'openEmpireMenu': { + // toggleEmpireMenu() is what carries the modalOpen gate; the popover + // builder itself has none, so calling it directly bypasses that gate + // entirely without needing to touch modalOpen at all. + this._retireHotspot(); + scene.openEmpireMenu?.(); + this.advance(); + return; + } + case 'openResearch': case 'openDiplomacy': case 'openLeaders': case 'openColonies': { + // Real dropdown item buttons all route through the gated openModal() + // (see the header note), so this hotspot intercepts the click and + // opens the screen directly instead of letting the real one fire. + const kind = step.hotspotAction.slice('open'.length).toLowerCase(); + this._retireHotspot(); + scene.closeEmpireMenu?.(); + this._openEmpireScreen(kind); + this.advance(); + return; + } + default: + this.advance(); + } + } + + /** + * Open System View directly for `starIdx` (bypasses MasterOfVegaGame's + * gated openSystemViewFor/openModal — see the header note) with the + * tutorial's own onClose/onColonyOpen wired straight to advance() (each + * guarded by step id, so a close/open from a step other than the one that + * expects it doesn't skip the player ahead). + */ + _openSystemView(starIdx) { + const scene = this.scene; + const stepAt = (id) => this.steps[this.stepIndex]?.id === id; + this._systemView = openSystemView(scene, scene.rules, scene.state, starIdx, scene.art, { + viewerIdx: scene.state.humanIndex, + onChanged: () => scene.refreshAll(), + onClose: () => { + this._systemView = null; + if (!this._dead && stepAt('colony-close')) this.advance(); + }, + onColonyOpen: () => { + if (!this._dead && stepAt('system-view-colony')) this.advance(); + }, + }); + } + + // ------------------------------------------------------------- skip prompt + + _renderSkipConfirm() { + const cfg = this.data.confirmSkip || {}; + const sk = this.scene.add.container(0, 0); + this._skipLayer = sk; + this.layer.add(sk); + + sk.add(this.scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, DIM_COLOR, 0.55) + .setOrigin(0, 0).setInteractive()); + + const w = 640; + const h = 260; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const x = cx - w / 2; + const y = cy - h / 2; + sk.add(this.scene.add.rectangle(x, y, w, h, PANEL, 0.98).setOrigin(0, 0) + .setStrokeStyle(1.5, ACCENT, 0.65).setInteractive()); + sk.add(cornerTicks(this.scene, x, y, w, h, 22)); + + sk.add(this.scene.add.text(cx, y + 72, cfg.body ?? 'Skip the tutorial?', { + fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', + align: 'center', wordWrap: { width: w - 80 }, + }).setOrigin(0.5)); + + sk.add(new Button(this.scene, cx - 130, y + h - 60, cfg.confirmLabel ?? 'Skip tutorial', + uiClick(this.scene, () => this.finish()), + { width: 220, height: 52, fontSize: 19, variant: 'ghost' })); + sk.add(new Button(this.scene, cx + 130, y + h - 60, cfg.cancelLabel ?? 'Keep learning', + uiClick(this.scene, () => { this._skipLayer?.destroy(); this._skipLayer = null; }), + { width: 220, height: 52, fontSize: 19 })); + } +} diff --git a/src/games/mastervega/VegaTutorialData.js b/src/games/mastervega/VegaTutorialData.js new file mode 100644 index 0000000..38cdb74 --- /dev/null +++ b/src/games/mastervega/VegaTutorialData.js @@ -0,0 +1,236 @@ +// Master of Vega — tutorial data: schema validation, text interpolation, and +// the highlight-target id registry. Headless, no Phaser imports, so it runs in +// Node (tools/verifyMasterOfVega.js) exactly like VegaTurnReport.js. +// +// The tutorial SCRIPT lives in data/mastervega-tutorial.json — an ordered list +// of steps, each with body/callout text, button labels, a voice clip, and a +// list of highlight-target ids. This file knows nothing about Phaser or the +// scene: it validates the JSON shape, substitutes {token} placeholders, and +// pins the set of target ids the renderer (VegaTutorial.js) knows how to +// resolve to on-screen positions. Anything the renderer can point at has to be +// listed in TUTORIAL_TARGET_IDS here AND have a resolver case in VegaTutorial. + +// Every id a step may name in `highlights` or `anchor`. VegaTutorial.js has a +// _resolveTarget() case for each. Adding a new highlightable thing later means +// adding its id here and a resolver there. `homeStar`/`homeFleet` are on the +// star map; `fleetShipProfiles`/`fleetCountControls` are regions of the side +// panel's fleet view, resolved through VegaSidePanel.tutorialRegion(). +// `nearbyStars` resolves to an ARRAY of targets (every star in fuel range of +// home, one per star) rather than a single one — VegaTutorial.js flattens it +// when building the highlight/hole list. `mapArea` is a big invisible hole +// (no ring drawn) used only to uncover the map for the pan/zoom lesson. +// `panel` is the docked command panel's whole footprint, regardless of what +// mode (star/fleet/order) it is currently showing. `travelingFleet` is +// whichever of the human player's fleets is currently under way. +// `endTurnButton` is the fixed HUD button (MasterOfVegaGame's scene.endTurnBtn). +// `empireButton` is the fixed HUD "Empire" dropdown toggle (scene.empireBtn). +// The four `empireMenu*` ids are that dropdown's own item rows (Research/ +// Diplomacy/Leaders/Colonies, in that fixed order) — each resolves to null +// unless the dropdown (scene.empireMenuLayer) is actually open. +export const TUTORIAL_TARGET_IDS = Object.freeze([ + 'homeStar', 'homeFleet', 'fleetShipProfiles', 'fleetCountControls', + 'nearbyStars', 'mapArea', 'panel', 'travelingFleet', 'endTurnButton', + 'empireButton', 'empireMenuResearch', 'empireMenuDiplomacy', 'empireMenuLeaders', 'empireMenuColonies', +]); + +const STEP_KINDS = Object.freeze(['modal', 'callout']); +const BUTTON_ACTIONS = Object.freeze(['next', 'back', 'skip', 'finish']); +// Ways a step can progress WITHOUT a button. `hotspot` = a click on the lit +// anchor (what that click actually DOES is the step's own `hotspotAction` +// field, not this one — see HOTSPOT_ACTIONS); `shipDetailClosed` = the player +// opened and then closed a ship detail window; `starPick` = the player +// clicked one of the `nearbyStars` hotspots (see STAR_PICK_MODES — the +// step's own `starPick` field decides what that click does); `fleetInFlight` +// = the player accepted a REAL fleet order through the side panel's own +// Accept button (VegaTutorial polls state.fleets for one under way, since +// that button is not part of the tutorial overlay); `external` = something +// outside the polling/hotspot system calls advance() directly — a callback +// VegaTutorial wired into a real screen it opened itself (System View / +// Colony View), the same reason `starPick`/`hotspot` need no poll either. A +// step with no buttons must declare one of these or it is a dead end. +export const ADVANCE_MODES = Object.freeze([ + 'hotspot', 'shipDetailClosed', 'starPick', 'fleetInFlight', 'external', +]); +// What clicking a `nearbyStars` hotspot does, independent of `advanceOn`: +// `info` opens that star's read-only panel and immediately advances (paired +// with advanceOn: 'starPick'); `order` quotes a move order for the already- +// selected fleet to that star and does NOT advance — the player still has to +// press the panel's real Accept button (paired with advanceOn: 'fleetInFlight'). +export const STAR_PICK_MODES = Object.freeze(['info', 'order']); +// What clicking the single `advanceOn: 'hotspot'` target does — VegaTutorial's +// _onHotspot() switches on this instead of pattern-matching the step's +// `anchor` id, so more than one step can point at the same anchor (e.g. two +// different steps both pointing at 'panel') without colliding. +export const HOTSPOT_ACTIONS = Object.freeze([ + 'selectHomeFleet', // ring+select the docked home fleet, open its panel + 'showHomeStar', // ring+select the home star, open its read-only panel + 'openSystemView', // open System View directly for the home star + 'endTurn', // unfreeze modalOpen just long enough to fire the real End Turn + 'openEmpireMenu', // open the Empire dropdown directly (bypasses its own modalOpen gate) + 'openResearch', 'openDiplomacy', 'openLeaders', 'openColonies', // close the dropdown, open that screen directly +]); +// What a `kind:'modal'` step's Next button does before advancing, in +// addition to the button's own `action:'next'` — VegaTutorial's own state +// (which real screen it opened directly, and whether the Empire dropdown +// should reappear behind the next one), not anything the step's JSON needs +// to know the mechanics of. +export const ADVANCE_ACTIONS = Object.freeze([ + 'closeEmpireScreenReopenMenu', // close whichever Empire-menu screen is open, reopen the dropdown + 'closeEmpireScreenAndMenu', // close it, and make sure the dropdown is closed too (the tour is over) +]); + +const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g; + +/** + * Replace every `{token}` in `str` with `vars[token]`. Tokens with no matching + * key are left untouched (the verifier separately flags any token not declared + * in the file's top-level `vars` allow-list, so an unresolved placeholder is a + * build error, not a silent runtime gap). + */ +export function interpolate(str, vars = {}) { + if (typeof str !== 'string') return str; + return str.replace(PLACEHOLDER_RE, (whole, token) => + (Object.prototype.hasOwnProperty.call(vars, token) ? String(vars[token]) : whole)); +} + +/** Every distinct `{token}` appearing anywhere in `str`. */ +export function placeholdersIn(str) { + if (typeof str !== 'string') return []; + const out = new Set(); + let m; + PLACEHOLDER_RE.lastIndex = 0; + // eslint-disable-next-line no-cond-assign + while ((m = PLACEHOLDER_RE.exec(str))) out.add(m[1]); + return [...out]; +} + +const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0; + +/** + * Validate a parsed tutorial JSON. Returns `{ ok, errors }` — `errors` is a + * list of human-readable strings, empty when `ok` is true. Deliberately + * permissive about unknown extra fields (forward-compatible) but strict about + * every field the renderer actually reads. + */ +export function validateTutorialData(json) { + const errors = []; + const fail = (msg) => errors.push(msg); + + if (!json || typeof json !== 'object') { + return { ok: false, errors: ['tutorial JSON is not an object'] }; + } + + if (json.version !== 1) fail(`version must be 1 (got ${JSON.stringify(json.version)})`); + + const vars = Array.isArray(json.vars) ? json.vars : []; + if (!Array.isArray(json.vars)) fail('`vars` must be an array of allowed placeholder names'); + + const cs = json.confirmSkip; + if (!cs || typeof cs !== 'object') { + fail('`confirmSkip` must be an object'); + } else { + if (!isNonEmptyString(cs.body)) fail('`confirmSkip.body` must be a non-empty string'); + if (!isNonEmptyString(cs.confirmLabel)) fail('`confirmSkip.confirmLabel` must be a non-empty string'); + if (!isNonEmptyString(cs.cancelLabel)) fail('`confirmSkip.cancelLabel` must be a non-empty string'); + } + + if (!Array.isArray(json.steps) || json.steps.length === 0) { + fail('`steps` must be a non-empty array'); + return { ok: errors.length === 0, errors }; + } + + const seenIds = new Set(); + json.steps.forEach((step, i) => { + const at = `steps[${i}]`; + if (!step || typeof step !== 'object') { fail(`${at} is not an object`); return; } + + if (!isNonEmptyString(step.id)) fail(`${at}.id must be a non-empty string`); + else if (seenIds.has(step.id)) fail(`${at}.id "${step.id}" is duplicated`); + else seenIds.add(step.id); + + if (!STEP_KINDS.includes(step.kind)) { + fail(`${at}.kind must be one of ${STEP_KINDS.join('/')} (got ${JSON.stringify(step.kind)})`); + } + + if (step.voice !== null && step.voice !== undefined && !isNonEmptyString(step.voice)) { + fail(`${at}.voice must be null or a non-empty string`); + } + + if (step.kind === 'modal' && !isNonEmptyString(step.body)) { + fail(`${at} (modal) must have a non-empty body`); + } + if (step.kind === 'callout') { + if (!isNonEmptyString(step.calloutText)) fail(`${at} (callout) must have a non-empty calloutText`); + if (!TUTORIAL_TARGET_IDS.includes(step.anchor)) { + fail(`${at} (callout) anchor must be one of ${TUTORIAL_TARGET_IDS.join('/')} (got ${JSON.stringify(step.anchor)})`); + } + } + + const highlights = step.highlights ?? []; + if (!Array.isArray(highlights)) { + fail(`${at}.highlights must be an array`); + } else { + highlights.forEach((h) => { + if (!TUTORIAL_TARGET_IDS.includes(h)) fail(`${at}.highlights has unknown target id ${JSON.stringify(h)}`); + }); + } + + if (step.advanceOn !== undefined && !ADVANCE_MODES.includes(step.advanceOn)) { + fail(`${at}.advanceOn, when set, must be one of ${ADVANCE_MODES.join('/')} (got ${JSON.stringify(step.advanceOn)})`); + } + if (step.advanceOn === 'hotspot' && !TUTORIAL_TARGET_IDS.includes(step.anchor)) { + fail(`${at}.advanceOn "hotspot" needs a valid anchor`); + } + if (step.advanceOn === 'hotspot' && !HOTSPOT_ACTIONS.includes(step.hotspotAction)) { + fail(`${at}.advanceOn "hotspot" needs a valid hotspotAction (one of ${HOTSPOT_ACTIONS.join('/')}, got ${JSON.stringify(step.hotspotAction)})`); + } + + if (step.advanceAction !== undefined && !ADVANCE_ACTIONS.includes(step.advanceAction)) { + fail(`${at}.advanceAction, when set, must be one of ${ADVANCE_ACTIONS.join('/')} (got ${JSON.stringify(step.advanceAction)})`); + } + + if (step.starPick !== undefined && !STAR_PICK_MODES.includes(step.starPick)) { + fail(`${at}.starPick, when set, must be one of ${STAR_PICK_MODES.join('/')} (got ${JSON.stringify(step.starPick)})`); + } + + if (!Array.isArray(step.buttons)) { + fail(`${at}.buttons must be an array`); + } else if (step.buttons.length === 0 && !ADVANCE_MODES.includes(step.advanceOn)) { + // An empty button row is only legal when the step can be advanced some + // other way — otherwise the player is stuck with nowhere to click. + fail(`${at}.buttons is empty but the step has no advanceOn (${ADVANCE_MODES.join('/')}) to progress`); + } else { + step.buttons.forEach((b, bi) => { + if (!b || typeof b !== 'object') { fail(`${at}.buttons[${bi}] is not an object`); return; } + if (!BUTTON_ACTIONS.includes(b.action)) { + fail(`${at}.buttons[${bi}].action must be one of ${BUTTON_ACTIONS.join('/')} (got ${JSON.stringify(b.action)})`); + } + if (!isNonEmptyString(b.label)) fail(`${at}.buttons[${bi}].label must be a non-empty string`); + }); + } + + // Every {token} used in visible text must be declared in `vars`. + for (const field of ['title', 'body', 'calloutText']) { + for (const token of placeholdersIn(step[field])) { + if (!vars.includes(token)) fail(`${at}.${field} uses undeclared placeholder {${token}} (add it to top-level "vars")`); + } + } + }); + + return { ok: errors.length === 0, errors }; +} + +/** + * Produce the runtime step list: a shallow copy of `json.steps` with `title`, + * `body` and `calloutText` interpolated against `vars`, and `highlights` + * defaulted to `[]`. Assumes the data already passed validateTutorialData. + */ +export function resolveSteps(json, vars = {}) { + return (json.steps ?? []).map((step) => ({ + ...step, + title: interpolate(step.title, vars), + body: interpolate(step.body, vars), + calloutText: interpolate(step.calloutText, vars), + highlights: step.highlights ?? [], + })); +} diff --git a/src/games/mastervega/VegaZoom.js b/src/games/mastervega/VegaZoom.js index 0cd6d8b..214fc4c 100644 --- a/src/games/mastervega/VegaZoom.js +++ b/src/games/mastervega/VegaZoom.js @@ -47,10 +47,14 @@ export function buildZoomLadder(worldW, worldH, { steps = ZOOM_STEPS, maxZoom = * than the area needs — VegaCombatCamera.js's "frame the whole fleet" * feature is built on this. */ -export function pickFitZoomIndex(zooms, boundsW, boundsH, padding = 0) { +// `viewW`/`viewH` default to the full screen but can be narrowed to whatever +// area is actually free of docked chrome (the guided tutorial's fit-the- +// reachable-stars step passes the width left of the command panel, so the +// picked rung doesn't pack stars in behind it). +export function pickFitZoomIndex(zooms, boundsW, boundsH, padding = 0, { viewW = GAME_WIDTH, viewH = GAME_HEIGHT } = {}) { const w = Math.max(1, boundsW + padding * 2); const h = Math.max(1, boundsH + padding * 2); - const ideal = Math.min(GAME_WIDTH / w, GAME_HEIGHT / h); + const ideal = Math.min(viewW / w, viewH / h); let idx = 0; for (let i = 0; i < zooms.length; i += 1) { if (zooms[i] <= ideal) idx = i; diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 491a66b..93a7b73 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -56,12 +56,19 @@ import * as Gnn from '../src/games/mastervega/VegaGnn.js'; import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js'; // Dependency-free, so what the game room eagerly pulls is checkable here. import { resolveGameAssets } from '../src/data/assetManifest.js'; +// Guided-tutorial data: schema/interpolation/target-id registry are Phaser-free +// (VegaTutorial.js is the Phaser half), so the script itself is checkable here. +import { + validateTutorialData, resolveSteps, interpolate, placeholdersIn, + TUTORIAL_TARGET_IDS, ADVANCE_MODES, +} from '../src/games/mastervega/VegaTutorialData.js'; const QUICK = process.argv.includes('--quick'); const gamesArg = process.argv.find((a) => a.startsWith('--games=')); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const rulesJson = JSON.parse(readFileSync(join(root, 'data/mastervega-rules.json'), 'utf8')); const artJson = JSON.parse(readFileSync(join(root, 'data/mastervega-artwork.json'), 'utf8')); +const tutorialJson = JSON.parse(readFileSync(join(root, 'data/mastervega-tutorial.json'), 'utf8')); let failures = 0; let passes = 0; @@ -5925,6 +5932,82 @@ section('11. Combat V2 (per-ship prototype)'); } } +// --------------------------------------------------------------------------- +section('12. Tutorial data'); +// --------------------------------------------------------------------------- +{ + const { ok, errors } = validateTutorialData(tutorialJson); + check('mastervega-tutorial.json passes schema validation', ok, errors.join('; ')); + check('tutorial version is 1', tutorialJson.version === 1, `${tutorialJson.version}`); + + const steps = Array.isArray(tutorialJson.steps) ? tutorialJson.steps : []; + check('tutorial has at least one step', steps.length > 0); + check('tutorial step ids are unique and non-empty', + new Set(steps.map((s) => s.id)).size === steps.length && steps.every((s) => typeof s.id === 'string' && s.id.trim())); + check('every tutorial step kind is modal or callout', + steps.every((s) => s.kind === 'modal' || s.kind === 'callout')); + // A modal can advance without a button of its own — 'external' steps (a + // System View / Colony View the tutorial opened directly wires its own + // onClose/onColonyOpen straight to advance()) have none by design; the + // general "every buttonless step can still be advanced" check below still + // catches a modal that has neither. + check('every modal step has a non-empty body', + steps.filter((s) => s.kind === 'modal').every((s) => typeof s.body === 'string' && s.body.trim())); + check('every callout step has calloutText and a valid anchor', + steps.filter((s) => s.kind === 'callout').every((s) => + typeof s.calloutText === 'string' && s.calloutText.trim() && TUTORIAL_TARGET_IDS.includes(s.anchor))); + check('every highlight id is a known tutorial target', + steps.every((s) => (s.highlights ?? []).every((h) => TUTORIAL_TARGET_IDS.includes(h)))); + check('every button action is next/back/skip/finish', + steps.every((s) => (s.buttons ?? []).every((b) => ['next', 'back', 'skip', 'finish'].includes(b.action)))); + check('every advanceOn (when set) is a known mode', + steps.every((s) => s.advanceOn === undefined || ADVANCE_MODES.includes(s.advanceOn))); + check('every buttonless step can still be advanced (advanceOn set)', + steps.every((s) => (s.buttons ?? []).length > 0 || ADVANCE_MODES.includes(s.advanceOn)), + steps.filter((s) => !(s.buttons ?? []).length && !ADVANCE_MODES.includes(s.advanceOn)).map((s) => s.id).join(',')); + + // Every non-null voice clip resolves on disk (same streamed-from-assets/speech + // contract as the species clips checked in section 2). + for (const s of steps) { + if (!s.voice) continue; + check(`tutorial step "${s.id}" voice clip exists`, + existsSync(join(root, 'assets/speech', `${s.voice}.mp3`)), `${s.voice}.mp3`); + } + check('a tutorial step uses the shipped intro clip vega/tutorial-intro-01', + steps.some((s) => s.voice === 'vega/tutorial-intro-01')); + + // Every {token} in visible text is declared in the top-level vars allow-list. + const vars = tutorialJson.vars ?? []; + for (const s of steps) { + for (const field of ['title', 'body', 'calloutText']) { + for (const tok of placeholdersIn(s[field])) { + check(`tutorial step "${s.id}" ${field} placeholder {${tok}} is declared in vars`, vars.includes(tok)); + } + } + } + + // interpolate substitutes and leaves nothing behind. + const sample = interpolate('the {species} thrive', { species: 'Humans' }); + check('interpolate substitutes a declared token', sample === 'the Humans thrive', sample); + const resolved = resolveSteps(tutorialJson, { species: 'Humans' }); + check('resolveSteps leaves no {token} in any visible field', + resolved.every((s) => !['title', 'body', 'calloutText'] + .some((f) => typeof s[f] === 'string' && /\{[a-zA-Z0-9_]+\}/.test(s[f])))); + + // The lazy manifest exposes the file so scene.cache.json.get('mastervega-tutorial') works. + { + const stub = { + cache: { json: { get: (k) => (k === 'mastervega-tutorial' ? tutorialJson : null) } }, + textures: { exists: () => false }, + }; + const eager = resolveGameAssets(stub, 'mastervega'); + check('mastervega-tutorial.json is in the lazy asset manifest', + eager.some((d) => d.type === 'json' && d.key === 'mastervega-tutorial')); + } + + check('UI_SPEECH declares the tutorial intro clip', UI_SPEECH.tutorialIntro === 'vega/tutorial-intro-01'); +} + // --------------------------------------------------------------------------- console.log(`\n${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);