diff --git a/docs/game-design/player-manual.md b/docs/game-design/player-manual.md index 6224ffb..b282dc7 100644 --- a/docs/game-design/player-manual.md +++ b/docs/game-design/player-manual.md @@ -73,6 +73,9 @@ Current shipped behavior for interactive mode. Concept and future-design notes l live drag-banana physics. Winning still grants the Circuit. - Ridge conversation uses `advance` / `choose`. `pnpm ridge:console` is an alias that starts already inside Ridge. +- Ridge first-playable secret route: Bridge → Concert → Dance Festival → Relay + farewell (Sit and Play, dedication card), then return to the Overworld street. + Progress resets so the next CRT entry starts Bridge again. - One-shot scripts: `pnpm game:console --script "look; go right 3; interact" --json`. ## Runtime Notes For Playtesting diff --git a/docs/game-design/ridge/ridge-snapshot.md b/docs/game-design/ridge/ridge-snapshot.md index c83d3d2..5ac4e12 100644 --- a/docs/game-design/ridge/ridge-snapshot.md +++ b/docs/game-design/ridge/ridge-snapshot.md @@ -69,7 +69,8 @@ Current runtime characteristics: ## Active Runtime Route Read ```text -Nature / hill entry +Bridge + Nature / hill entry -> Cicka + toy car play spot (optional Persona-style choice) -> blocked bridge + unfinished blueprint -> Bridge Draftsperson @@ -77,10 +78,37 @@ Nature / hill entry -> toy-car bridge test -> completed crossing -> Bridge-to-Concert handoff + +Concert + -> crowd / traffic crossing blocker + night facades + -> musician-side nook (hidden Cicka + Injured Guitarist) + -> forgiving practice riff + -> auto-success concert (crowd clears, guitar received) + -> Dance transition exit + +Dance Festival + -> Last-Stop Plaza setup + service-road blocker + -> traveler wayfinding + triangulated resident reads + -> Operations Handoff Check + One-Step Practice + -> Folded Song Request + -> Setup Clearance montage + -> last daylight shuttle -> Relay + +Relay Ending + -> tiny sunset overlook linger + -> Sit and Play beside Cicka + -> Route Memory Montage (3 flashes) + Let the song end + -> Cicka Threshold Farewell + Dedication Card + -> return to Overworld (CRT story ends; Circuit kept) ``` -Concert, Dance Festival, Relay, and the ending sequence remain future -implementation slices. Copy the Bridge console-content + stick-visual pattern. +After the dedication, v0 returns to the Overworld street rather than an in-Ridge +Bridge replay. Route progress resets so the next CRT entry starts Bridge fresh. +Long-term Open Ridge Return / post-game can replace this eject later. + +Full route is playable in `pnpm ridge:console` and covered by +`src/game/core/ridge/session.test.ts`. Stick visuals swap backgrounds/cast per +area via `StickVisualProvider`. ## Console Contract (for AI agents) @@ -94,8 +122,12 @@ Useful commands: | `advance` | Continue halted conversation | | `choose ` | Persona-style reply | | `leave` | Exit conversation early | +| `skip` / `next` | Playtest: jump to next area start | +| `warp bridge\|concert\|dance\|relay` | Playtest: warp to an area | | `help` | Command list | +Browser DEV only: `]` skips to the next area; `1`–`4` warp Bridge / Concert / Dance / Relay. + Observation JSON (`--json`) includes `nearby[].distance`, actors, inventory, conversation state, and hints. diff --git a/scripts/game-console.ts b/scripts/game-console.ts index 02dc52b..d6af454 100644 --- a/scripts/game-console.ts +++ b/scripts/game-console.ts @@ -23,19 +23,14 @@ import { type GameSceneId, formatGameObservation } from '../src/game/core/console/index'; -import type { BridgeDialogueCatalog } from '../src/game/core/ridge/index'; +import { + loadRouteCatalogFromMessages, + type RidgeRouteDialogueCatalog +} from '../src/game/core/ridge/index'; import { enMessages } from '../src/shared/i18n/messages/en'; -function loadCatalog(): BridgeDialogueCatalog { - const bridge = enMessages.scenes.ridge.bridge; - return { - speakers: { - prompt: bridge.speakers.prompt, - cicka: bridge.speakers.cicka, - bridgeDraftsperson: bridge.speakers.bridgeDraftsperson - }, - lines: { ...bridge.dialogue } - }; +function loadCatalog(): RidgeRouteDialogueCatalog { + return loadRouteCatalogFromMessages(enMessages.scenes.ridge); } function parseScene(args: string[]): GameSceneId { diff --git a/src/game/bridge/store.test.ts b/src/game/bridge/store.test.ts index 75123df..ca272e0 100644 --- a/src/game/bridge/store.test.ts +++ b/src/game/bridge/store.test.ts @@ -174,7 +174,7 @@ describe('bridgeStore', () => { shortcutIds: [], firstPlayableRoute: { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' } }); expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]); @@ -204,7 +204,7 @@ describe('bridgeStore', () => { shortcutIds: [], firstPlayableRoute: { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' } }); expect(bridgeStore.getState().inventory.ownedItemIds).toEqual([]); @@ -272,25 +272,25 @@ describe('bridgeStore', () => { it('tracks the first playable Ridge route handoff separately from rewards', () => { expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({ activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' }); - bridgeActions.setRidgeBridgeBeat('needs_toy_car'); + bridgeActions.setRidgeRouteBeat('needs_toy_car'); expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({ activeAreaId: 'bridge', - bridgeBeat: 'needs_toy_car' + beat: 'needs_toy_car' }); - bridgeActions.triggerRidgeConcertHandoff(); + bridgeActions.setRidgeAreaHandoff('concert', 'concert_arrival'); expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({ activeAreaId: 'concert', - bridgeBeat: 'concert_handoff' + beat: 'concert_arrival' }); - bridgeActions.resetProgress(); + bridgeActions.resetRidgeFirstPlayableRoute(); expect(bridgeStore.getState().progress.ridge.firstPlayableRoute).toEqual({ activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' }); }); diff --git a/src/game/bridge/store.ts b/src/game/bridge/store.ts index a880f26..324dcb2 100644 --- a/src/game/bridge/store.ts +++ b/src/game/bridge/store.ts @@ -48,24 +48,33 @@ export type RidgeStampId = string; export type RidgeManualPageId = string; export type RidgeShortcutId = string; export type RidgeFirstPlayableAreaId = 'bridge' | 'concert' | 'danceFestival' | 'relay'; -export type RidgeBridgeBeatState = +export type RidgeRouteBeatState = | 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' - | 'concert_handoff'; -export type RidgeBridgeAreaBeatState = Exclude; + | 'concert_arrival' + | 'concert_practiced' + | 'concert_cleared' + | 'dance_arrival' + | 'dance_ready' + | 'dance_cleared' + | 'relay_linger' + | 'relay_farewell' + | 'relay_complete'; +/** @deprecated Prefer RidgeRouteBeatState */ +export type RidgeBridgeBeatState = RidgeRouteBeatState; +/** @deprecated Prefer RidgeRouteBeatState */ +export type RidgeBridgeAreaBeatState = Extract< + RidgeRouteBeatState, + 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' +>; export type RidgePostBridgeAreaId = Exclude; -export type RidgeFirstPlayableRouteState = - | { - activeAreaId: 'bridge'; - bridgeBeat: RidgeBridgeAreaBeatState; - } - | { - activeAreaId: RidgePostBridgeAreaId; - bridgeBeat: 'concert_handoff'; - }; +export type RidgeFirstPlayableRouteState = { + activeAreaId: RidgeFirstPlayableAreaId; + beat: RidgeRouteBeatState; +}; export interface BridgeRidgeProgressState { stampIds: RidgeStampId[]; @@ -146,7 +155,7 @@ function createInitialRidgeProgress(): BridgeRidgeProgressState { function createInitialRidgeFirstPlayableRoute(): RidgeFirstPlayableRouteState { return { activeAreaId: 'bridge', - bridgeBeat: 'intro' + beat: 'intro' }; } @@ -302,8 +311,8 @@ function isBridgeProgressUnchanged(previous: BridgeState, candidate: BridgeState arraysEqual(previous.progress.ridge.shortcutIds, candidate.progress.ridge.shortcutIds) && previous.progress.ridge.firstPlayableRoute.activeAreaId === candidate.progress.ridge.firstPlayableRoute.activeAreaId && - previous.progress.ridge.firstPlayableRoute.bridgeBeat === - candidate.progress.ridge.firstPlayableRoute.bridgeBeat + previous.progress.ridge.firstPlayableRoute.beat === + candidate.progress.ridge.firstPlayableRoute.beat ); } @@ -523,7 +532,7 @@ export const bridgeActions = { }; }); }, - setRidgeBridgeBeat(bridgeBeat: RidgeBridgeAreaBeatState): void { + setRidgeRouteBeat(beat: RidgeRouteBeatState): void { setState((current) => ({ ...current, progress: { @@ -532,14 +541,17 @@ export const bridgeActions = { ...current.progress.ridge, firstPlayableRoute: { ...current.progress.ridge.firstPlayableRoute, - activeAreaId: 'bridge', - bridgeBeat + beat } } } })); }, - triggerRidgeConcertHandoff(): void { + /** @deprecated Prefer setRidgeRouteBeat */ + setRidgeBridgeBeat(bridgeBeat: RidgeBridgeAreaBeatState): void { + bridgeActions.setRidgeRouteBeat(bridgeBeat); + }, + setRidgeAreaHandoff(areaId: RidgeFirstPlayableAreaId, beat: RidgeRouteBeatState): void { setState((current) => ({ ...current, progress: { @@ -547,13 +559,29 @@ export const bridgeActions = { ridge: { ...current.progress.ridge, firstPlayableRoute: { - activeAreaId: 'concert', - bridgeBeat: 'concert_handoff' + activeAreaId: areaId, + beat } } } })); }, + resetRidgeFirstPlayableRoute(): void { + setState((current) => ({ + ...current, + progress: { + ...current.progress, + ridge: { + ...current.progress.ridge, + firstPlayableRoute: createInitialRidgeFirstPlayableRoute() + } + } + })); + }, + /** @deprecated Prefer setRidgeAreaHandoff('concert', 'concert_arrival') */ + triggerRidgeConcertHandoff(): void { + bridgeActions.setRidgeAreaHandoff('concert', 'concert_arrival'); + }, resetProgress(): void { setState((current) => ({ ...current, diff --git a/src/game/core/console/commands.ts b/src/game/core/console/commands.ts index 270a0dd..8a9661e 100644 --- a/src/game/core/console/commands.ts +++ b/src/game/core/console/commands.ts @@ -19,7 +19,9 @@ const SIMPLE_COMMANDS: Record = { continue: { type: 'advance' }, start: { type: 'start' }, fight: { type: 'fight' }, - clear: { type: 'fight' } + clear: { type: 'fight' }, + skip: { type: 'skip' }, + next: { type: 'skip' } }; const GO_ALIASES = new Set(['go', 'walk', 'move']); @@ -41,6 +43,10 @@ export function getGameHelpText(): string { 'Ridge conversation:', ' advance | choose ', '', + 'Ridge playtest skips:', + ' skip next area', + ' warp bridge|concert|dance|relay', + '', 'Potassium (discrete campaign):', ' start | fight | draft ', '', @@ -76,6 +82,11 @@ export function parseGameCommand(raw: string): GameCommand { if (head === 'draft') return parseChoiceCommand('draft', parts, trimmed); if (head === 'equip' || head === 'unequip') return parseEquipCommand(head, parts, trimmed); if (head === 'cheat') return parseCheatCommand(parts, trimmed); + if (head === 'warp' || head === 'tp' || head === 'goto' || head === 'jump') { + const areaId = parts[1]; + if (!areaId) return { type: 'unknown', raw: trimmed }; + return { type: 'warp', areaId }; + } if (INTERACT_ALIASES.has(head)) { return { type: 'interact', target: parts.slice(1).join(' ') || undefined }; } diff --git a/src/game/core/console/session.test.ts b/src/game/core/console/session.test.ts index 43a8f7a..26f8040 100644 --- a/src/game/core/console/session.test.ts +++ b/src/game/core/console/session.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { TEST_BRIDGE_DIALOGUE_CATALOG } from '../ridge/content/testBridgeCatalog'; +import { TEST_ROUTE_DIALOGUE_CATALOG } from '../ridge/content/testRouteCatalog'; import { parseGameCommand, parseGameScript } from './commands'; import { GameConsoleSession } from './session'; -function createSession() { - return new GameConsoleSession({ dialogue: TEST_BRIDGE_DIALOGUE_CATALOG }); +function createSession( + options: Omit[0], 'dialogue'> = {} +) { + return new GameConsoleSession({ dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ...options }); } describe('parseGameCommand', () => { @@ -81,7 +83,7 @@ describe('GameConsoleSession full secret route', () => { it('syncs Phaser position and cancels a pending banana peel when walking away', () => { const session = new GameConsoleSession({ - dialogue: TEST_BRIDGE_DIALOGUE_CATALOG, + dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ownedItemIds: ['glasses'], equippedItemIds: ['glasses'] }); @@ -96,7 +98,7 @@ describe('GameConsoleSession full secret route', () => { it('inserts Circuit at the CRT after hydrate', () => { const session = new GameConsoleSession({ - dialogue: TEST_BRIDGE_DIALOGUE_CATALOG, + dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ownedItemIds: ['circuit'], equippedItemIds: [] }); @@ -106,9 +108,32 @@ describe('GameConsoleSession full secret route', () => { expect(session.observe().mode).toBe('ridge'); }); + it('returns to Overworld after Ridge dedication / route reset', () => { + const session = new GameConsoleSession({ + dialogue: TEST_ROUTE_DIALOGUE_CATALOG, + ownedItemIds: ['circuit'], + sceneId: 'ridge' + }); + expect(session.exec('warp relay').ok).toBe(true); + session.exec('go right 10'); + expect(session.exec('interact').ok).toBe(true); + for (let i = 0; i < 20; i += 1) { + if (session.observe().ridge?.conversation?.awaitingChoice) break; + if (session.observe().mode !== 'ridge') break; + session.exec('advance'); + } + expect(session.exec('choose let-song-end').ok).toBe(true); + for (let i = 0; i < 20; i += 1) { + if (session.observe().mode !== 'ridge') break; + session.exec('advance'); + } + expect(session.observe().mode).toBe('overworld'); + expect(session.observe().ownedItemIds).toContain('circuit'); + }); + it('commits banana peel warp for the live Overworld typewriter path', () => { const session = new GameConsoleSession({ - dialogue: TEST_BRIDGE_DIALOGUE_CATALOG, + dialogue: TEST_ROUTE_DIALOGUE_CATALOG, ownedItemIds: ['glasses'], equippedItemIds: ['glasses'], discoveredSecretIds: ['banana-peel-clue'] diff --git a/src/game/core/console/session.ts b/src/game/core/console/session.ts index 0ba7ba9..fbfa236 100644 --- a/src/game/core/console/session.ts +++ b/src/game/core/console/session.ts @@ -1,9 +1,9 @@ // Session dispatch mirrors many console verbs; complexity is the command surface. // fallow-ignore-file complexity import { - createBridgeStage, + createRidgeRouteStages, RidgeConsoleSession, - type BridgeDialogueCatalog, + type RidgeRouteDialogueCatalog, type RidgeSessionEvent } from '../ridge/index'; import { @@ -38,7 +38,7 @@ import type { } from './types'; export interface GameConsoleSessionOptions { - dialogue: BridgeDialogueCatalog; + dialogue: RidgeRouteDialogueCatalog; /** Start scene (default overworld). */ sceneId?: GameSceneId; ownedItemIds?: readonly GameItemId[]; @@ -49,7 +49,7 @@ export interface GameConsoleSessionOptions { export class GameConsoleSession { private state: GameWorldState; private ridge: RidgeConsoleSession | null = null; - private readonly dialogue: BridgeDialogueCatalog; + private readonly dialogue: RidgeRouteDialogueCatalog; constructor(options: GameConsoleSessionOptions) { this.dialogue = options.dialogue; @@ -190,6 +190,10 @@ export class GameConsoleSession { case 'advance': case 'choose': return this.handleRidgeTalk(command); + case 'skip': + return this.handleRidgeSkipWarp('skip'); + case 'warp': + return this.handleRidgeSkipWarp(`warp ${command.areaId}`); case 'start': return this.wrapPotassium(startPotassium(this.state)); case 'fight': @@ -205,6 +209,13 @@ export class GameConsoleSession { } } + private handleRidgeSkipWarp(raw: string): GameCommandResult { + if (this.state.mode !== 'ridge') { + return this.fail('skip / warp only work inside Ridge.'); + } + return this.forwardRidge(raw); + } + private handleGo(direction: 'left' | 'right', steps: number): GameCommandResult { if (this.state.mode === 'overlay') { return this.fail('Close the overlay first (close).'); @@ -347,6 +358,24 @@ export class GameConsoleSession { const ridge = this.ensureRidge(); const result = ridge.exec(raw); const events = mapRidgeEvents(result.events); + + if (events.some((event) => event.type === 'ridge_route_reset')) { + // Dedication card finished — eject to Overworld until post-game exists. + this.ridge = null; + this.state.mode = 'overworld'; + this.state.sceneId = 'overworld'; + this.state.ridgeBeat = null; + const message = + 'For Cicka. The CRT softens — you are back on the street. Circuit stays with you.'; + this.state.lastMessage = message; + return { + ok: result.ok, + message, + observation: this.observe(), + events: [...events, { type: 'scene_returned', sceneId: 'overworld' }] + }; + } + this.state.ridgeBeat = ridge.observe().beat; this.state.lastMessage = result.message; return { @@ -360,7 +389,7 @@ export class GameConsoleSession { private ensureRidge(): RidgeConsoleSession { if (!this.ridge) { this.ridge = new RidgeConsoleSession({ - stage: createBridgeStage(this.dialogue) + stages: createRidgeRouteStages(this.dialogue) }); } return this.ridge; @@ -404,8 +433,10 @@ function mapRidgeEvents(events: readonly RidgeSessionEvent[]): GameSessionEvent[ for (const event of events) { if (event.type === 'beat_changed') { out.push({ type: 'ridge_beat_changed', beat: event.beat }); - } else if (event.type === 'concert_handoff') { - out.push({ type: 'ridge_concert_handoff' }); + } else if (event.type === 'area_handoff') { + out.push({ type: 'ridge_area_handoff', areaId: event.areaId }); + } else if (event.type === 'route_reset') { + out.push({ type: 'ridge_route_reset' }); } } return out; diff --git a/src/game/core/console/types.ts b/src/game/core/console/types.ts index 6fa5c1c..73c8915 100644 --- a/src/game/core/console/types.ts +++ b/src/game/core/console/types.ts @@ -2,7 +2,7 @@ * Pure full-game console types. No Phaser, React, DOM, or bridge store. */ -import type { RidgeBridgeBeat, RidgeObservation } from '../ridge/types'; +import type { RidgeObservation, RidgeRouteBeat } from '../ridge/types'; import type { GameSceneId, PortfolioOverlayId } from './content/overworldSpots'; export type GameItemId = 'glasses' | 'circuit'; @@ -76,7 +76,7 @@ export interface GameWorldState { potassium: PotassiumSlice; lastMessage: string | null; /** Ridge is hosted separately; beat mirrored for observation. */ - ridgeBeat: RidgeBridgeBeat | null; + ridgeBeat: RidgeRouteBeat | null; } export interface GameObservation { @@ -114,6 +114,8 @@ export type GameCommand = | { type: 'fight' } | { type: 'draft'; choiceIdOrIndex: string } | { type: 'cheat'; action: 'give'; itemId: GameItemId } + | { type: 'skip' } + | { type: 'warp'; areaId: string } | { type: 'unknown'; raw: string }; export type GameSessionEvent = @@ -128,8 +130,9 @@ export type GameSessionEvent = | { type: 'banana_peel_cancelled' } | { type: 'thought'; id: 'basement_cannot_see' } | { type: 'potassium_won' } - | { type: 'ridge_beat_changed'; beat: RidgeBridgeBeat } - | { type: 'ridge_concert_handoff' }; + | { type: 'ridge_beat_changed'; beat: RidgeRouteBeat } + | { type: 'ridge_area_handoff'; areaId: string } + | { type: 'ridge_route_reset' }; export interface GameCommandResult { ok: boolean; diff --git a/src/game/core/ridge/commands.ts b/src/game/core/ridge/commands.ts index 1fc7240..c8fce93 100644 --- a/src/game/core/ridge/commands.ts +++ b/src/game/core/ridge/commands.ts @@ -13,6 +13,10 @@ const HELP_TEXT = [ ' choose pick a conversation reply', ' leave exit conversation without finishing', '', + 'Playtest skips:', + ' skip jump to the next area start', + ' warp bridge|concert|dance|relay', + '', 'Script mode: separate commands with `;`', 'Example: look; go right 3; interact; advance; advance' ].join('\n'); @@ -29,13 +33,14 @@ const SIMPLE_COMMANDS: Record = { inv: { type: 'inventory' }, i: { type: 'inventory' }, advance: { type: 'advance' }, - next: { type: 'advance' }, continue: { type: 'advance' }, n: { type: 'advance' }, z: { type: 'advance' }, leave: { type: 'leave' }, back: { type: 'leave' }, - close: { type: 'leave' } + close: { type: 'leave' }, + skip: { type: 'skip' }, + next: { type: 'skip' } }; const GO_ALIASES = new Set(['go', 'walk', 'move']); @@ -43,6 +48,16 @@ const LEFT_ALIASES = new Set(['left', 'west', 'a']); const RIGHT_ALIASES = new Set(['right', 'east', 'd']); const INTERACT_ALIASES = new Set(['interact', 'talk', 'use', 'e', 'x']); const CHOOSE_ALIASES = new Set(['choose', 'pick', 'select']); +const WARP_ALIASES = new Set(['warp', 'tp', 'goto', 'jump']); +const AREA_ALIASES: Record = { + bridge: 'bridge', + concert: 'concert', + dance: 'danceFestival', + dancefestival: 'danceFestival', + festival: 'danceFestival', + relay: 'relay', + ending: 'relay' +}; export function getRidgeHelpText(): string { return HELP_TEXT; @@ -72,6 +87,11 @@ export function parseRidgeCommand(rawInput: string): RidgeCommand { if (!args[0]) return { type: 'unknown', raw }; return { type: 'choose', choiceIdOrIndex: args[0] }; } + if (WARP_ALIASES.has(head)) { + const areaId = args[0] ? AREA_ALIASES[args[0].replace(/[_-]/g, '')] : undefined; + if (!areaId) return { type: 'unknown', raw }; + return { type: 'warp', areaId }; + } return { type: 'unknown', raw }; } diff --git a/src/game/core/ridge/content/bridgeStage.ts b/src/game/core/ridge/content/bridgeStage.ts index d4bb18e..5cc9fee 100644 --- a/src/game/core/ridge/content/bridgeStage.ts +++ b/src/game/core/ridge/content/bridgeStage.ts @@ -8,8 +8,8 @@ import type { RidgeStageDefinition, RidgeWorldState } from '../types'; +import { RIDGE_TOY_CAR_ITEM } from '../types'; -const BRIDGE_TOY_CAR_ITEM = 'toy-car'; const BRIDGE_BLOCKED_PROGRESS = 0.62; export interface BridgeDialogueCatalog { @@ -89,6 +89,9 @@ export function createBridgeStage(catalog: BridgeDialogueCatalog): RidgeStageDef lengthLabel: 'farm river sketch', spots: SPOTS, blockedProgress: BRIDGE_BLOCKED_PROGRESS, + blockedMessage: + 'The unfinished bridge blocks the way. Talk to the draftsperson or finish the crossing.', + isCrossingOpen: (state) => state.beat === 'bridge_complete', resolveInteractables: (state) => resolveBridgeInteractables(state, catalog), resolveActors: (state) => resolveBridgeActors(state), resolveConversation: (conversationId, state) => @@ -101,8 +104,6 @@ function resolveBridgeInteractables( state: RidgeWorldState, catalog: BridgeDialogueCatalog ): RidgeInteractable[] { - if (state.beat === 'concert_handoff') return []; - const plans = interactPlansForBeat(state.beat); const result: RidgeInteractable[] = []; @@ -175,18 +176,10 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan } function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { - const crossingOpen = - state.beat === 'bridge_complete' || state.beat === 'concert_handoff'; - const cickaProgress = - state.beat === 'bridge_complete' || state.beat === 'concert_handoff' - ? 0.68 - : 0.22; + const crossingOpen = state.beat === 'bridge_complete'; + const cickaProgress = crossingOpen ? 0.68 : 0.22; const toyCarProgress = - state.beat === 'toy_car_shared' - ? state.progress - : state.beat === 'bridge_complete' || state.beat === 'concert_handoff' - ? 0.7 - : 0.24; + state.beat === 'toy_car_shared' ? state.progress : crossingOpen ? 0.7 : 0.24; return [ { @@ -200,7 +193,7 @@ function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { id: 'cicka', label: 'Cicka', progress: cickaProgress, - visible: state.beat !== 'concert_handoff', + visible: true, facing: 'right' }, { @@ -217,7 +210,7 @@ function resolveBridgeActors(state: RidgeWorldState): RidgeActorPresence[] { visible: state.beat === 'intro' || state.beat === 'needs_toy_car' || - state.inventory.includes(BRIDGE_TOY_CAR_ITEM) || + state.inventory.includes(RIDGE_TOY_CAR_ITEM) || crossingOpen, facing: 'right' } @@ -291,7 +284,7 @@ function resolveBridgeConversation( ], outcome: { setBeat: 'toy_car_shared', - addItem: BRIDGE_TOY_CAR_ITEM, + addItem: RIDGE_TOY_CAR_ITEM, setFlag: 'toy_car_shared' } }; @@ -318,7 +311,7 @@ function resolveBridgeConversation( line('bridge.exit.opened_crossing.03', 'prompt') ], outcome: { - concertHandoff: true + handoffToArea: 'concert' } }; default: @@ -340,9 +333,6 @@ function makeLine( } function describeBridgeAmbience(state: RidgeWorldState): string { - if (state.beat === 'concert_handoff') { - return 'The finished bridge holds. Evening music leans in from the next page.'; - } if (state.beat === 'bridge_complete') { return 'Ink settles into a real crossing. The concert exit is open to the east.'; } diff --git a/src/game/core/ridge/content/concertStage.ts b/src/game/core/ridge/content/concertStage.ts new file mode 100644 index 0000000..6a4d179 --- /dev/null +++ b/src/game/core/ridge/content/concertStage.ts @@ -0,0 +1,306 @@ +// Concert beat branching is route policy encoded as content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { RIDGE_GUITAR_ITEM } from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +const CONCERT_BLOCKED_PROGRESS = 0.55; + +export interface ConcertDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + injuredGuitarist: string; + crowd: string; + }; + lines: Record; +} + +const SPOTS = [ + { + id: 'entry', + label: 'Bridge night entry', + kind: 'landmark' as const, + progress: 0.05, + interactRadius: 0.05, + description: 'Evening ink. Small-town storefronts lean toward a delayed show.' + }, + { + id: 'guitarist', + label: 'Injured Guitarist', + kind: 'npc' as const, + progress: 0.28, + interactRadius: 0.08, + actorId: 'guitarist' as const, + description: 'A musician-side nook. Skateboard regret and a quiet guitar case.' + }, + { + id: 'cicka-nook', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.32, + interactRadius: 0.07, + actorId: 'cicka' as const, + description: 'Cicka watches from behind stage props, crowd-shy.' + }, + { + id: 'crowd', + label: 'Concert crowd', + kind: 'npc' as const, + progress: 0.55, + interactRadius: 0.08, + actorId: 'crowd' as const, + description: 'A patient-impatient line waiting for a late local concert.' + }, + { + id: 'stage', + label: 'Tiny stage', + kind: 'prop' as const, + progress: 0.42, + interactRadius: 0.07, + description: 'A forgiving practice spot that can become the show.' + }, + { + id: 'dance-exit', + label: 'Dance exit', + kind: 'exit' as const, + progress: 0.9, + interactRadius: 0.08, + description: 'The opened crossing leans toward Last-Stop Plaza.' + } +] as const; + +export function createConcertStage(catalog: ConcertDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'concert', + title: 'Concert Area / Concert Crossing', + lengthLabel: 'small-town night block', + spots: SPOTS, + blockedProgress: CONCERT_BLOCKED_PROGRESS, + blockedMessage: + 'The crowd fills the crossing. Find the musician-side nook or wait for the show.', + isCrossingOpen: (state) => state.beat === 'concert_cleared', + resolveInteractables: (state) => resolveConcertInteractables(state, catalog), + resolveActors: (state) => resolveConcertActors(state), + resolveConversation: (conversationId, state) => + resolveConcertConversation(conversationId, state, catalog), + describeAmbience: (state) => describeConcertAmbience(state) + }; +} + +function resolveConcertInteractables( + state: RidgeWorldState, + catalog: ConcertDialogueCatalog +): RidgeInteractable[] { + const plans: { spotId: string; conversationId: string; prompt: string }[] = []; + + if (state.beat === 'concert_arrival' || state.beat === 'concert_practiced') { + plans.push({ + spotId: 'crowd', + conversationId: 'concert.crowd.delay_barks', + prompt: 'concert.crowd.delay_barks.01' + }); + plans.push({ + spotId: 'guitarist', + conversationId: + state.beat === 'concert_practiced' + ? 'concert.performance.auto_success' + : state.flags.has('met_guitarist') + ? 'concert.guitarist.practice_riff' + : 'concert.guitarist.injury', + prompt: + state.beat === 'concert_practiced' + ? 'concert.performance.auto_success.01' + : state.flags.has('met_guitarist') + ? 'concert.guitarist.practice_riff.01' + : 'concert.guitarist.injury.01' + }); + plans.push({ + spotId: 'cicka-nook', + conversationId: 'concert.cicka.band_resting_spot', + prompt: 'concert.cicka.band_resting_spot.01' + }); + if (state.beat === 'concert_practiced') { + plans.push({ + spotId: 'stage', + conversationId: 'concert.performance.auto_success', + prompt: 'concert.performance.auto_success.01' + }); + } + } + + if (state.beat === 'concert_cleared') { + plans.push({ + spotId: 'guitarist', + conversationId: 'concert.guitarist.guitar_handoff', + prompt: 'concert.guitarist.guitar_handoff.01' + }); + plans.push({ + spotId: 'cicka-nook', + conversationId: 'concert.cicka.band_resting_spot', + prompt: 'concert.cicka.band_resting_spot.03' + }); + plans.push({ + spotId: 'dance-exit', + conversationId: 'concert.exit.dance_transition', + prompt: 'concert.exit.dance_transition.01' + }); + } + + return collectNearbyFromPlans( + state.progress, + SPOTS, + plans, + (key) => catalog.lines[key] ?? key + ); +} + +function resolveConcertActors(state: RidgeWorldState): RidgeActorPresence[] { + const cleared = state.beat === 'concert_cleared'; + return [ + { + id: 'player', + label: 'You', + progress: state.progress, + visible: true, + facing: state.facing + }, + { + id: 'crowd', + label: 'Concert crowd', + progress: cleared ? 0.72 : 0.55, + visible: !cleared, + facing: 'left' + }, + { + id: 'guitarist', + label: 'Injured Guitarist', + progress: cleared ? 0.78 : 0.28, + visible: true, + facing: cleared ? 'left' : 'right' + }, + { + id: 'cicka', + label: 'Cicka', + progress: cleared ? 0.8 : 0.32, + visible: true, + facing: 'right' + }, + { + id: 'guitar', + label: 'Guitar', + progress: cleared ? state.progress : 0.3, + visible: cleared && state.inventory.includes(RIDGE_GUITAR_ITEM), + facing: 'right' + } + ]; +} + +function resolveConcertConversation( + conversationId: string, + state: RidgeWorldState, + catalog: ConcertDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof ConcertDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'concert.crowd.delay_barks': + return { + id: conversationId, + lines: [ + line('concert.crowd.delay_barks.01', 'crowd'), + line('concert.crowd.delay_barks.02', 'crowd'), + line('concert.crowd.delay_barks.03', 'prompt') + ] + }; + case 'concert.guitarist.injury': + return { + id: conversationId, + lines: [ + line('concert.guitarist.injury.01', 'injuredGuitarist'), + line('concert.guitarist.injury.02', 'injuredGuitarist'), + line('concert.guitarist.injury.03', 'prompt') + ], + outcome: { setFlag: 'met_guitarist' } + }; + case 'concert.guitarist.practice_riff': + return { + id: conversationId, + lines: [ + line('concert.guitarist.practice_riff.01', 'injuredGuitarist'), + line('concert.guitarist.practice_riff.02', 'prompt'), + line('concert.guitarist.practice_riff.03', 'injuredGuitarist') + ], + outcome: { setBeat: 'concert_practiced' } + }; + case 'concert.performance.auto_success': + return { + id: conversationId, + lines: [ + line('concert.performance.auto_success.01', 'prompt'), + line('concert.performance.auto_success.02', 'prompt'), + line('concert.performance.auto_success.03', 'crowd'), + line('concert.performance.auto_success.04', 'injuredGuitarist') + ], + outcome: { + setBeat: 'concert_cleared', + addItem: RIDGE_GUITAR_ITEM + } + }; + case 'concert.guitarist.guitar_handoff': + return { + id: conversationId, + lines: [ + line('concert.guitarist.guitar_handoff.01', 'injuredGuitarist'), + line('concert.guitarist.guitar_handoff.02', 'injuredGuitarist'), + line('concert.guitarist.guitar_handoff.03', 'prompt') + ] + }; + case 'concert.cicka.band_resting_spot': + return { + id: conversationId, + lines: + state.beat === 'concert_cleared' + ? [ + line('concert.cicka.band_resting_spot.03', 'prompt'), + line('concert.cicka.band_resting_spot.04', 'cicka') + ] + : [ + line('concert.cicka.band_resting_spot.01', 'prompt'), + line('concert.cicka.band_resting_spot.02', 'cicka') + ] + }; + case 'concert.exit.dance_transition': + return { + id: conversationId, + lines: [ + line('concert.exit.dance_transition.01', 'prompt'), + line('concert.exit.dance_transition.02', 'injuredGuitarist'), + line('concert.exit.dance_transition.03', 'prompt') + ], + outcome: { handoffToArea: 'danceFestival' } + }; + default: + return null; + } +} + +function describeConcertAmbience(state: RidgeWorldState): string { + if (state.beat === 'concert_cleared') { + return 'The crossing breathes again. Cicka loafs with the band near the opened exit.'; + } + if (state.beat === 'concert_practiced') { + return 'You know the phrase. The tiny stage waits for a forgiving first note.'; + } + if (state.flags.has('met_guitarist')) { + return 'Night storefronts. A late crowd. One careful practice could clear the street.'; + } + return 'A compact night block. The crossing is full of people waiting for a concert that will not start.'; +} diff --git a/src/game/core/ridge/content/danceStage.ts b/src/game/core/ridge/content/danceStage.ts new file mode 100644 index 0000000..47ac2f9 --- /dev/null +++ b/src/game/core/ridge/content/danceStage.ts @@ -0,0 +1,449 @@ +// Dance Festival branching is route policy encoded as content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +const DANCE_BLOCKED_PROGRESS = 0.68; +const FLAG_OPS = 'ops_ready'; +const FLAG_DRIVER = 'driver_ready'; + +export interface DanceDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + traveler: string; + hillShuttleDriver: string; + operationsHelper: string; + danceTeacher: string; + festivalSteward: string; + }; + lines: Record; +} + +/** + * Spread plaza cast left→right so silhouettes and prompts stay readable. + * Soft wall / service gate sits at ~0.68; shuttle beyond after clearance. + */ +const SPOTS = [ + { + id: 'entry', + label: 'Plaza entry', + kind: 'landmark' as const, + progress: 0.05, + interactRadius: 0.05, + description: 'Daytime setup for a night dance that has not begun.' + }, + { + id: 'traveler', + label: 'Traveler', + kind: 'npc' as const, + progress: 0.14, + interactRadius: 0.08, + actorId: 'traveler' as const, + description: 'Someone asking the same question you are: how to reach Relay.' + }, + { + id: 'driver', + label: 'Hill-Shuttle Driver', + kind: 'npc' as const, + progress: 0.28, + interactRadius: 0.09, + actorId: 'driver' as const, + description: 'Stuck on a route clipboard beside the shuttle sign.' + }, + { + id: 'teacher', + label: 'Dance Teacher', + kind: 'npc' as const, + progress: 0.4, + interactRadius: 0.09, + actorId: 'dance-teacher' as const, + description: 'Keeps watch without forcing anyone onto the floor.' + }, + { + id: 'cicka', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.48, + interactRadius: 0.07, + actorId: 'cicka' as const, + description: 'Loafs near the operations table like a soft paperweight.' + }, + { + id: 'operations', + label: 'Last-Stop Operations Helper', + kind: 'npc' as const, + progress: 0.56, + interactRadius: 0.09, + actorId: 'operations-helper' as const, + description: 'Perfecting lanterns and chair stacks near the dance floor edge.' + }, + { + id: 'steward', + label: 'Festival Steward', + kind: 'npc' as const, + progress: 0.64, + interactRadius: 0.08, + actorId: 'steward' as const, + description: 'Holds the service-gate key until setup is safe.' + }, + { + id: 'gate', + label: 'Service gate', + kind: 'prop' as const, + progress: 0.68, + interactRadius: 0.1, + description: 'Festival barriers and cable tape block the hill road.' + }, + { + id: 'shuttle', + label: 'Last daylight shuttle', + kind: 'exit' as const, + progress: 0.9, + interactRadius: 0.1, + actorId: 'shuttle' as const, + description: 'The threshold ride to Relay under warming sky.' + } +] as const; + +export function createDanceStage(catalog: DanceDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'danceFestival', + title: 'Dance Festival / Opening Dance Shuttle', + lengthLabel: 'Last-Stop Plaza', + spots: SPOTS, + blockedProgress: DANCE_BLOCKED_PROGRESS, + blockedMessage: (state) => describeDanceBlock(state), + isCrossingOpen: (state) => state.beat === 'dance_cleared', + resolveInteractables: (state) => resolveDanceInteractables(state, catalog), + resolveActors: (state) => resolveDanceActors(state), + resolveConversation: (conversationId, state) => + resolveDanceConversation(conversationId, state, catalog), + describeAmbience: (state) => describeDanceAmbience(state) + }; +} + +function bothReady(state: RidgeWorldState): boolean { + return state.flags.has(FLAG_OPS) && state.flags.has(FLAG_DRIVER); +} + +function describeDanceBlock(state: RidgeWorldState): string { + if (state.beat === 'dance_ready' || bothReady(state)) { + return 'Service gate is ready. Press interact on the gate to clear the last setup.'; + } + if (state.flags.has(FLAG_OPS) && !state.flags.has(FLAG_DRIVER)) { + return 'Operations is ready. Ask the Dance Teacher for one private practice step.'; + } + if (state.flags.has(FLAG_DRIVER) && !state.flags.has(FLAG_OPS)) { + return 'The driver has one step. Help the Operations Helper finish her handoff check.'; + } + return 'Service road closed for festival setup. Help Operations and the Dance Teacher first.'; +} + +function resolveDanceInteractables( + state: RidgeWorldState, + catalog: DanceDialogueCatalog +): RidgeInteractable[] { + const plans: { spotId: string; conversationId: string; prompt: string }[] = []; + const line = (key: string) => catalog.lines[key] ?? key; + + if (state.beat === 'dance_arrival' || state.beat === 'dance_ready') { + plans.push({ + spotId: 'traveler', + conversationId: 'dance.traveler.relay_wayfinding', + prompt: 'dance.traveler.relay_wayfinding.01' + }); + plans.push({ + spotId: 'steward', + conversationId: 'dance.locals.triangulated_read', + prompt: 'dance.locals.triangulated_read.01' + }); + plans.push({ + spotId: 'teacher', + conversationId: 'dance.driver.one_step_practice', + prompt: state.flags.has(FLAG_DRIVER) + ? 'dance.driver.one_step_practice.done.01' + : 'dance.driver.one_step_practice.01' + }); + plans.push({ + spotId: 'operations', + conversationId: 'dance.operations_helper.handoff_check', + prompt: state.flags.has(FLAG_OPS) + ? 'dance.operations_helper.handoff_check.done.01' + : 'dance.operations_helper.handoff_check.01' + }); + plans.push({ + spotId: 'driver', + conversationId: bothReady(state) + ? 'dance.driver.folded_song_request' + : 'dance.driver.shuttle_delay', + prompt: bothReady(state) + ? 'dance.driver.folded_song_request.01' + : 'dance.driver.shuttle_delay.01' + }); + plans.push({ + spotId: 'cicka', + conversationId: 'dance.cicka.resting_spot', + prompt: 'dance.cicka.resting_spot.01' + }); + } + + if (state.beat === 'dance_ready' || bothReady(state)) { + plans.push({ + spotId: 'gate', + conversationId: 'dance.setup_clearance', + prompt: 'dance.setup_clearance.01' + }); + } + + if (state.beat === 'dance_cleared') { + plans.push({ + spotId: 'shuttle', + conversationId: 'dance.shuttle.last_daylight_ride', + prompt: 'dance.shuttle.last_daylight_ride.01' + }); + plans.push({ + spotId: 'cicka', + conversationId: 'dance.cicka.resting_spot', + prompt: 'dance.cicka.resting_spot.02' + }); + } + + return collectNearbyFromPlans(state.progress, SPOTS, plans, line); +} + +function resolveDanceActors(state: RidgeWorldState): RidgeActorPresence[] { + const cleared = state.beat === 'dance_cleared'; + return [ + { + id: 'player', + label: 'You', + progress: state.progress, + visible: true, + facing: state.facing + }, + { + id: 'traveler', + label: 'Traveler', + progress: 0.14, + visible: !cleared, + facing: 'right' + }, + { + id: 'driver', + label: 'Driver', + progress: cleared ? 0.88 : 0.28, + visible: true, + facing: cleared ? 'right' : 'left' + }, + { + id: 'dance-teacher', + label: 'Teacher', + progress: 0.4, + visible: true, + facing: 'right' + }, + { + id: 'cicka', + label: 'Cicka', + progress: cleared ? 0.72 : 0.48, + visible: true, + facing: 'right' + }, + { + id: 'operations-helper', + label: 'Operations', + progress: cleared ? 0.74 : 0.56, + visible: true, + facing: 'left' + }, + { + id: 'steward', + label: 'Steward', + progress: cleared ? 0.7 : 0.64, + visible: true, + facing: 'left' + }, + { + id: 'counterpart-cat', + label: 'Quiet cat', + progress: 0.76, + visible: cleared, + facing: 'left' + }, + { + id: 'shuttle', + label: 'Shuttle', + progress: 0.9, + visible: true, + facing: 'right' + } + ]; +} + +function resolveDanceConversation( + conversationId: string, + state: RidgeWorldState, + catalog: DanceDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof DanceDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'dance.traveler.relay_wayfinding': + return { + id: conversationId, + lines: [ + line('dance.traveler.relay_wayfinding.01', 'traveler'), + line('dance.traveler.relay_wayfinding.02', 'traveler'), + line('dance.traveler.relay_wayfinding.03', 'prompt') + ] + }; + case 'dance.driver.shuttle_delay': + return { + id: conversationId, + lines: [ + line('dance.driver.shuttle_delay.01', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.02', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.03', 'prompt') + ], + choices: state.flags.has(FLAG_DRIVER) + ? undefined + : [ + { + id: 'offer-help', + label: 'Find the Dance Teacher for him', + lines: [ + line('dance.driver.shuttle_delay.choice.help', 'hillShuttleDriver'), + line('dance.driver.shuttle_delay.choice.help_hint', 'prompt') + ] + }, + { + id: 'wait', + label: 'Ask around the plaza first', + lines: [line('dance.driver.shuttle_delay.choice.wait', 'prompt')] + } + ] + }; + case 'dance.operations_helper.handoff_check': + return { + id: conversationId, + lines: state.flags.has(FLAG_OPS) + ? [ + line('dance.operations_helper.handoff_check.done.01', 'operationsHelper'), + line('dance.operations_helper.handoff_check.done.02', 'prompt') + ] + : [ + line('dance.operations_helper.handoff_check.01', 'operationsHelper'), + line('dance.operations_helper.handoff_check.02', 'operationsHelper'), + line('dance.operations_helper.handoff_check.03', 'prompt') + ], + outcome: state.flags.has(FLAG_OPS) + ? undefined + : { + setFlag: FLAG_OPS, + setBeat: state.flags.has(FLAG_DRIVER) ? 'dance_ready' : undefined + } + }; + case 'dance.locals.triangulated_read': + return { + id: conversationId, + lines: [ + line('dance.locals.triangulated_read.01', 'festivalSteward'), + line('dance.locals.triangulated_read.02', 'festivalSteward'), + line('dance.locals.triangulated_read.03', 'danceTeacher') + ] + }; + case 'dance.driver.one_step_practice': + return { + id: conversationId, + lines: state.flags.has(FLAG_DRIVER) + ? [ + line('dance.driver.one_step_practice.done.01', 'danceTeacher'), + line('dance.driver.one_step_practice.done.02', 'prompt') + ] + : [ + line('dance.driver.one_step_practice.01', 'danceTeacher'), + line('dance.driver.one_step_practice.02', 'prompt'), + line('dance.driver.one_step_practice.03', 'hillShuttleDriver') + ], + outcome: state.flags.has(FLAG_DRIVER) + ? undefined + : { + setFlag: FLAG_DRIVER, + setBeat: state.flags.has(FLAG_OPS) ? 'dance_ready' : undefined + } + }; + case 'dance.driver.folded_song_request': + return { + id: conversationId, + lines: [ + line('dance.driver.folded_song_request.01', 'hillShuttleDriver'), + line('dance.driver.folded_song_request.02', 'prompt'), + line('dance.driver.folded_song_request.03', 'operationsHelper'), + line('dance.driver.folded_song_request.04', 'prompt') + ], + outcome: { setBeat: 'dance_ready', setFlag: 'song_folded' } + }; + case 'dance.setup_clearance': + return { + id: conversationId, + lines: [ + line('dance.setup_clearance.01', 'prompt'), + line('dance.setup_clearance.02', 'prompt'), + line('dance.setup_clearance.03', 'prompt'), + line('dance.setup_clearance.04', 'festivalSteward'), + line('dance.setup_clearance.05', 'prompt') + ], + outcome: { setBeat: 'dance_cleared' } + }; + case 'dance.shuttle.last_daylight_ride': + return { + id: conversationId, + lines: [ + line('dance.shuttle.last_daylight_ride.01', 'hillShuttleDriver'), + line('dance.shuttle.last_daylight_ride.02', 'prompt'), + line('dance.shuttle.last_daylight_ride.03', 'prompt') + ], + outcome: { handoffToArea: 'relay' } + }; + case 'dance.cicka.resting_spot': + return { + id: conversationId, + lines: + state.beat === 'dance_cleared' + ? [ + line('dance.cicka.resting_spot.02', 'prompt'), + line('dance.cicka.resting_spot.03', 'cicka') + ] + : [ + line('dance.cicka.resting_spot.01', 'prompt'), + line('dance.cicka.resting_spot.03', 'cicka') + ] + }; + default: + return null; + } +} + +function describeDanceAmbience(state: RidgeWorldState): string { + if (state.beat === 'dance_cleared') { + return 'Lane clear. Board the last daylight shuttle on the right.'; + } + if (state.beat === 'dance_ready' || bothReady(state)) { + return 'Both helpers are ready. Clear the service gate, then take the shuttle.'; + } + if (state.flags.has(FLAG_OPS) && !state.flags.has(FLAG_DRIVER)) { + return 'Operations handoff done. The Dance Teacher can teach one private step.'; + } + if (state.flags.has(FLAG_DRIVER) && !state.flags.has(FLAG_OPS)) { + return 'Driver practiced one step. The Operations Helper still needs a handoff check.'; + } + return 'Last-Stop Plaza setup. Talk to Operations (lantern) and the Dance Teacher (skirt pose).'; +} diff --git a/src/game/core/ridge/content/dialogueHelpers.ts b/src/game/core/ridge/content/dialogueHelpers.ts new file mode 100644 index 0000000..f030095 --- /dev/null +++ b/src/game/core/ridge/content/dialogueHelpers.ts @@ -0,0 +1,38 @@ +import type { RidgeDialogueLine, RidgeInteractable, RidgeSpotDefinition } from '../types'; + +export function makeCatalogLine( + catalog: { speakers: Record; lines: Record }, + id: string, + speakerId: string +): RidgeDialogueLine { + return { + id, + speakerId, + speaker: catalog.speakers[speakerId] ?? speakerId, + text: catalog.lines[id] ?? id + }; +} + +export function collectNearbyFromPlans( + stateProgress: number, + spots: readonly RidgeSpotDefinition[], + plans: readonly { spotId: string; conversationId: string; prompt: string }[], + resolvePrompt: (promptKey: string) => string +): RidgeInteractable[] { + const result: RidgeInteractable[] = []; + for (const plan of plans) { + const spot = spots.find((candidate) => candidate.id === plan.spotId); + if (!spot) continue; + const distance = Math.abs(stateProgress - spot.progress); + if (distance > spot.interactRadius) continue; + result.push({ + spotId: spot.id, + label: spot.label, + kind: spot.kind, + distance, + prompt: resolvePrompt(plan.prompt), + conversationId: plan.conversationId + }); + } + return result; +} diff --git a/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts b/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts new file mode 100644 index 0000000..d6f6686 --- /dev/null +++ b/src/game/core/ridge/content/loadRouteCatalogFromMessages.ts @@ -0,0 +1,48 @@ +import type { BridgeDialogueCatalog } from './bridgeStage'; +import type { ConcertDialogueCatalog } from './concertStage'; +import type { DanceDialogueCatalog } from './danceStage'; +import type { RelayDialogueCatalog } from './relayStage'; +import type { RidgeRouteDialogueCatalog } from './routeStages'; + +/** Plain message shape shared by i18n and the Node CLI. */ +export interface RidgeRouteMessageBundle { + bridge: { + speakers: BridgeDialogueCatalog['speakers']; + dialogue: Record; + }; + concert: { + speakers: ConcertDialogueCatalog['speakers']; + dialogue: Record; + }; + dance: { + speakers: DanceDialogueCatalog['speakers']; + dialogue: Record; + }; + relay: { + speakers: RelayDialogueCatalog['speakers']; + dialogue: Record; + }; +} + +export function loadRouteCatalogFromMessages( + ridge: RidgeRouteMessageBundle +): RidgeRouteDialogueCatalog { + return { + bridge: { + speakers: { ...ridge.bridge.speakers }, + lines: { ...ridge.bridge.dialogue } + }, + concert: { + speakers: { ...ridge.concert.speakers }, + lines: { ...ridge.concert.dialogue } + }, + dance: { + speakers: { ...ridge.dance.speakers }, + lines: { ...ridge.dance.dialogue } + }, + relay: { + speakers: { ...ridge.relay.speakers }, + lines: { ...ridge.relay.dialogue } + } + }; +} diff --git a/src/game/core/ridge/content/relayStage.ts b/src/game/core/ridge/content/relayStage.ts new file mode 100644 index 0000000..d7dea45 --- /dev/null +++ b/src/game/core/ridge/content/relayStage.ts @@ -0,0 +1,200 @@ +// Relay ending sequence is intentionally linear authored content. +// fallow-ignore-file complexity +import type { + RidgeActorPresence, + RidgeConversationDefinition, + RidgeInteractable, + RidgeStageDefinition, + RidgeWorldState +} from '../types'; +import { RIDGE_GUITAR_ITEM } from '../types'; +import { collectNearbyFromPlans, makeCatalogLine } from './dialogueHelpers'; + +export interface RelayDialogueCatalog { + speakers: { + prompt: string; + cicka: string; + dedication: string; + }; + lines: Record; +} + +const SPOTS = [ + { + id: 'arrival', + label: 'Relay overlook', + kind: 'landmark' as const, + progress: 0.2, + interactRadius: 0.07, + description: 'A tiny sunset overlook above the finished route.' + }, + { + id: 'cicka', + label: 'Cicka', + kind: 'npc' as const, + progress: 0.55, + interactRadius: 0.1, + actorId: 'cicka' as const, + description: 'Cicka waits in her final field-presence spot, calm and familiar.' + }, + { + id: 'sit', + label: 'Sit and Play', + kind: 'prop' as const, + progress: 0.58, + interactRadius: 0.1, + description: 'A quiet place to sit beside Cicka with the concert guitar.' + }, + { + id: 'threshold', + label: 'Warm threshold', + kind: 'landmark' as const, + progress: 0.85, + interactRadius: 0.05, + description: 'A warm sketchbook seam beyond the player path.' + } +] as const; + +export function createRelayStage(catalog: RelayDialogueCatalog): RidgeStageDefinition { + return { + areaId: 'relay', + title: 'Relay Spire / Guitar Farewell', + lengthLabel: 'sunset overlook', + spots: SPOTS, + resolveInteractables: (state) => resolveRelayInteractables(state, catalog), + resolveActors: (state) => resolveRelayActors(state), + resolveConversation: (conversationId, state) => + resolveRelayConversation(conversationId, state, catalog), + describeAmbience: (state) => describeRelayAmbience(state) + }; +} + +function resolveRelayInteractables( + state: RidgeWorldState, + catalog: RelayDialogueCatalog +): RidgeInteractable[] { + if (state.beat !== 'relay_linger') return []; + + const plans = [ + { + spotId: 'arrival', + conversationId: 'relay.overlook.inspect', + prompt: 'relay.overlook.inspect.01' + }, + { + spotId: 'cicka', + conversationId: 'relay.sit_and_play.prompt', + prompt: 'relay.sit_and_play.prompt.01' + }, + { + spotId: 'sit', + conversationId: 'relay.sit_and_play.prompt', + prompt: 'relay.sit_and_play.prompt.01' + } + ]; + + return collectNearbyFromPlans( + state.progress, + SPOTS, + plans, + (key) => catalog.lines[key] ?? key + ); +} + +function resolveRelayActors(state: RidgeWorldState): RidgeActorPresence[] { + const farewell = + state.beat === 'relay_farewell' || + state.beat === 'relay_complete' || + state.mode === 'conversation'; + const cickaGone = state.beat === 'relay_complete' || state.flags.has('cicka_crossed'); + + return [ + { + id: 'player', + label: 'You', + progress: farewell ? 0.58 : state.progress, + visible: true, + facing: state.facing + }, + { + id: 'cicka', + label: 'Cicka', + progress: cickaGone ? 0.85 : 0.55, + visible: !cickaGone, + facing: 'left' + }, + { + id: 'guitar', + label: 'Guitar', + progress: farewell ? 0.58 : state.progress, + visible: state.inventory.includes(RIDGE_GUITAR_ITEM), + facing: 'right' + } + ]; +} + +function resolveRelayConversation( + conversationId: string, + _state: RidgeWorldState, + catalog: RelayDialogueCatalog +): RidgeConversationDefinition | null { + const line = (id: string, speakerId: keyof RelayDialogueCatalog['speakers']) => + makeCatalogLine(catalog, id, speakerId); + + switch (conversationId) { + case 'relay.overlook.inspect': + return { + id: conversationId, + lines: [ + line('relay.overlook.inspect.01', 'prompt'), + line('relay.overlook.inspect.02', 'prompt') + ] + }; + case 'relay.sit_and_play.prompt': + return { + id: conversationId, + lines: [ + line('relay.sit_and_play.prompt.01', 'prompt'), + line('relay.sit_and_play.prompt.02', 'prompt'), + line('relay.sit_and_play.prompt.03', 'prompt'), + // Route Memory Montage — three soft flashes, no captions in presentation; + // console keeps short readable echoes for AI playtests. + line('relay.montage.bridge.01', 'prompt'), + line('relay.montage.concert.01', 'prompt'), + line('relay.montage.dance.01', 'prompt'), + line('relay.guitar.sunset.01', 'prompt') + ], + choices: [ + { + id: 'let-song-end', + label: catalog.lines['relay.guitar.let_song_end.01'] ?? 'Let the song end', + lines: [ + line('relay.guitar.let_song_end.02', 'prompt'), + line('relay.cicka.threshold_meow.01', 'cicka'), + line('relay.cicka.threshold_meow.02', 'prompt'), + line('relay.cicka.threshold_meow.03', 'prompt'), + line('relay.dedication.card.01', 'dedication'), + line('relay.dedication.card.02', 'dedication') + ], + outcome: { + setBeat: 'relay_complete', + setFlag: 'cicka_crossed', + routeReset: true + } + } + ] + }; + default: + return null; + } +} + +function describeRelayAmbience(state: RidgeWorldState): string { + if (state.beat === 'relay_complete') { + return 'Empty sunset paper. A quiet dedication holds, then the page resets.'; + } + if (state.beat === 'relay_farewell') { + return 'The familiar concert phrase softens the overlook. The sun lowers.'; + } + return 'Relay Spire under warm threshold light. Cicka waits. The guitar feels ready.'; +} diff --git a/src/game/core/ridge/content/routeStages.ts b/src/game/core/ridge/content/routeStages.ts new file mode 100644 index 0000000..82692d8 --- /dev/null +++ b/src/game/core/ridge/content/routeStages.ts @@ -0,0 +1,24 @@ +import type { RidgeStageRegistry } from '../types'; +import { createBridgeStage, type BridgeDialogueCatalog } from './bridgeStage'; +import { createConcertStage, type ConcertDialogueCatalog } from './concertStage'; +import { createDanceStage, type DanceDialogueCatalog } from './danceStage'; +import { createRelayStage, type RelayDialogueCatalog } from './relayStage'; + +export interface RidgeRouteDialogueCatalog { + bridge: BridgeDialogueCatalog; + concert: ConcertDialogueCatalog; + dance: DanceDialogueCatalog; + relay: RelayDialogueCatalog; +} + +/** Build the full first-playable Compact Ridge Stage registry. */ +export function createRidgeRouteStages( + catalog: RidgeRouteDialogueCatalog +): RidgeStageRegistry { + return { + bridge: createBridgeStage(catalog.bridge), + concert: createConcertStage(catalog.concert), + danceFestival: createDanceStage(catalog.dance), + relay: createRelayStage(catalog.relay) + }; +} diff --git a/src/game/core/ridge/content/testRouteCatalog.ts b/src/game/core/ridge/content/testRouteCatalog.ts new file mode 100644 index 0000000..be1a75e --- /dev/null +++ b/src/game/core/ridge/content/testRouteCatalog.ts @@ -0,0 +1,115 @@ +import type { RidgeRouteDialogueCatalog } from './routeStages'; +import { TEST_BRIDGE_DIALOGUE_CATALOG } from './testBridgeCatalog'; + +/** Shared dialogue stub for full-route Ridge console unit tests. */ +export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { + bridge: TEST_BRIDGE_DIALOGUE_CATALOG, + concert: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + injuredGuitarist: 'Injured Guitarist', + crowd: 'Crowd' + }, + lines: { + 'concert.crowd.delay_barks.01': 'Concert is late again.', + 'concert.crowd.delay_barks.02': 'Someone said the guitarist wiped out.', + 'concert.crowd.delay_barks.03': 'Maybe check behind the stage props.', + 'concert.guitarist.injury.01': 'I tried a one-leg skateboard solo.', + 'concert.guitarist.injury.02': 'Wrist says no. Pride says louder no.', + 'concert.guitarist.injury.03': 'Learn the phrase with me', + 'concert.guitarist.practice_riff.01': 'Practice the forgiving riff', + 'concert.guitarist.practice_riff.02': 'You find the phrase without failing.', + 'concert.guitarist.practice_riff.03': 'That is enough courage for a street.', + 'concert.performance.auto_success.01': 'Start the concert', + 'concert.performance.auto_success.02': 'The phrase lands. Soft. True.', + 'concert.performance.auto_success.03': 'Alright, move—show happened.', + 'concert.performance.auto_success.04': 'Take the guitar. Carry the comfort.', + 'concert.guitarist.guitar_handoff.01': 'Keep it for the road ahead.', + 'concert.guitarist.guitar_handoff.02': 'Play it when quiet needs company.', + 'concert.guitarist.guitar_handoff.03': 'The guitar rests against your side.', + 'concert.cicka.band_resting_spot.01': 'Sit near hidden Cicka', + 'concert.cicka.band_resting_spot.02': 'mrrp.', + 'concert.cicka.band_resting_spot.03': 'Cicka loafs with the band', + 'concert.cicka.band_resting_spot.04': 'purr.', + 'concert.exit.dance_transition.01': 'Follow the opened crossing', + 'concert.exit.dance_transition.02': 'Festival setup waits downhill.', + 'concert.exit.dance_transition.03': 'The page warms toward afternoon.' + } + }, + dance: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + traveler: 'Traveler', + hillShuttleDriver: 'Hill-Shuttle Driver', + operationsHelper: 'Last-Stop Operations Helper', + danceTeacher: 'Dance Teacher', + festivalSteward: 'Festival Steward' + }, + lines: { + 'dance.traveler.relay_wayfinding.01': 'Relay is up the hill shuttle.', + 'dance.traveler.relay_wayfinding.02': 'Last daylight ride, after setup clears.', + 'dance.traveler.relay_wayfinding.03': 'Help Operations, then the Dance Teacher.', + 'dance.driver.shuttle_delay.01': 'Cannot leave until the steward opens the gate.', + 'dance.driver.shuttle_delay.02': 'Clipboard says ready. Feet say otherwise.', + 'dance.driver.shuttle_delay.03': 'He keeps rereading the same line.', + 'dance.driver.shuttle_delay.choice.help': 'One step. Privately. Maybe the Dance Teacher…', + 'dance.driver.shuttle_delay.choice.help_hint': 'Find the Dance Teacher (skirt pose).', + 'dance.driver.shuttle_delay.choice.wait': 'You give him space and ask around.', + 'dance.operations_helper.handoff_check.01': 'Help check the operations handoff', + 'dance.operations_helper.handoff_check.02': 'If the lantern line fails, I fail.', + 'dance.operations_helper.handoff_check.03': 'You check the crates with her.', + 'dance.operations_helper.handoff_check.done.01': 'Handoff done — find the Dance Teacher next', + 'dance.operations_helper.handoff_check.done.02': 'She can almost leave the plaza alone.', + 'dance.locals.triangulated_read.01': 'Road opens after setup clears at the gate.', + 'dance.locals.triangulated_read.02': 'She waits on one perfect lantern. He waits on anything except asking.', + 'dance.locals.triangulated_read.03': 'Help Operations, then ask me for one private step.', + 'dance.driver.one_step_practice.01': 'Practice one private dance step', + 'dance.driver.one_step_practice.02': 'He learns exactly one shared rhythm.', + 'dance.driver.one_step_practice.03': 'Okay. I can offer that much later.', + 'dance.driver.one_step_practice.done.01': 'Step learned — clear the service gate next', + 'dance.driver.one_step_practice.done.02': 'Dignity intact. Clear the gate next.', + 'dance.driver.folded_song_request.01': 'Help fold a tiny song request', + 'dance.driver.folded_song_request.02': 'No confession. Just one dance later.', + 'dance.driver.folded_song_request.03': 'She reads it. Soft nod. No spotlight.', + 'dance.driver.folded_song_request.04': 'Now clear the service gate on the right.', + 'dance.setup_clearance.01': 'Clear the service gate for the shuttle', + 'dance.setup_clearance.02': 'Secure the lantern line.', + 'dance.setup_clearance.03': 'Tape the service-lane clear.', + 'dance.setup_clearance.04': 'Gate open. Last daylight window.', + 'dance.setup_clearance.05': 'Shuttle sign flips. Board the van to the right.', + 'dance.shuttle.last_daylight_ride.01': 'All aboard the last ride.', + 'dance.shuttle.last_daylight_ride.02': 'The hill lifts into sunset paper.', + 'dance.shuttle.last_daylight_ride.03': 'Relay waits quiet above.', + 'dance.cicka.resting_spot.01': 'Cicka loafs on the operations table', + 'dance.cicka.resting_spot.02': 'Cicka settles by the cleared gate', + 'dance.cicka.resting_spot.03': 'mrrp.' + } + }, + relay: { + speakers: { + prompt: 'Prompt', + cicka: 'Cicka', + dedication: 'Dedication' + }, + lines: { + 'relay.overlook.inspect.01': 'Look out over the finished route', + 'relay.overlook.inspect.02': 'The pages below still hold their changes.', + 'relay.sit_and_play.prompt.01': 'Sit and play beside Cicka', + 'relay.sit_and_play.prompt.02': 'You settle. The guitar finds the concert phrase.', + 'relay.sit_and_play.prompt.03': 'The overlook softens.', + 'relay.montage.bridge.01': '(memory) The finished bridge holds.', + 'relay.montage.concert.01': '(memory) The crossing clears; the guitar changes hands.', + 'relay.montage.dance.01': '(memory) Night lanterns wake after you leave.', + 'relay.guitar.sunset.01': 'Sunset lowers while the phrase keeps breathing.', + 'relay.guitar.let_song_end.01': 'Let the song end', + 'relay.guitar.let_song_end.02': 'The phrase resolves into quiet.', + 'relay.cicka.threshold_meow.01': 'mrrp.', + 'relay.cicka.threshold_meow.02': 'Cicka turns back once, then slips into warm paper light.', + 'relay.cicka.threshold_meow.03': 'The overlook holds empty for a breath.', + 'relay.dedication.card.01': 'For Cicka.', + 'relay.dedication.card.02': 'Thank you for playing.' + } + } +}; diff --git a/src/game/core/ridge/conversation.ts b/src/game/core/ridge/conversation.ts index 3e8010f..64b7e49 100644 --- a/src/game/core/ridge/conversation.ts +++ b/src/game/core/ridge/conversation.ts @@ -1,3 +1,5 @@ +// Outcome application branches across inventory/flags/handoffs by design. +// fallow-ignore-file complexity import type { RidgeConversationDefinition, RidgeConversationOutcome, @@ -174,7 +176,6 @@ function applyOutcome( state: RidgeWorldState, outcome: RidgeConversationOutcome ): { state: RidgeWorldState; events: RidgeSessionEvent[] } { - let next = state; const events: RidgeSessionEvent[] = []; const flags = new Set(state.flags); @@ -192,6 +193,10 @@ function applyOutcome( inventory = [...inventory, outcome.addItem]; events.push({ type: 'item_added', itemId: outcome.addItem }); } + if (outcome.removeItem && inventory.includes(outcome.removeItem)) { + inventory = inventory.filter((item) => item !== outcome.removeItem); + events.push({ type: 'item_removed', itemId: outcome.removeItem }); + } let beat = state.beat; if (outcome.setBeat) { @@ -199,28 +204,33 @@ function applyOutcome( events.push({ type: 'beat_changed', beat: outcome.setBeat }); } - if (outcome.concertHandoff) { - beat = 'concert_handoff'; - events.push({ type: 'concert_handoff' }); + if (outcome.handoffToArea) { + events.push({ type: 'area_handoff', areaId: outcome.handoffToArea }); } - next = { - ...state, - flags, - inventory, - beat, - areaId: outcome.concertHandoff ? 'concert' : state.areaId, - lastMessage: describeOutcome(outcome) - }; + if (outcome.routeReset) { + events.push({ type: 'route_reset' }); + } - return { state: next, events }; + return { + state: { + ...state, + flags, + inventory, + beat, + lastMessage: describeOutcome(outcome) + }, + events + }; } function describeOutcome(outcome: RidgeConversationOutcome): string { const parts: string[] = []; if (outcome.addItem) parts.push(`Got: ${outcome.addItem}`); + if (outcome.removeItem) parts.push(`Left: ${outcome.removeItem}`); if (outcome.setBeat) parts.push(`Beat -> ${outcome.setBeat}`); - if (outcome.concertHandoff) parts.push('Handoff -> concert'); + if (outcome.handoffToArea) parts.push(`Handoff -> ${outcome.handoffToArea}`); + if (outcome.routeReset) parts.push('Route resets to Bridge'); return parts.length > 0 ? parts.join(' | ') : 'Conversation ended.'; } diff --git a/src/game/core/ridge/index.ts b/src/game/core/ridge/index.ts index 56ee248..f24da8f 100644 --- a/src/game/core/ridge/index.ts +++ b/src/game/core/ridge/index.ts @@ -4,3 +4,10 @@ export * from './observe'; export * from './conversation'; export * from './session'; export * from './content/bridgeStage'; +export * from './content/concertStage'; +export * from './content/danceStage'; +export * from './content/relayStage'; +export * from './content/routeStages'; +export * from './content/loadRouteCatalogFromMessages'; +export * from './content/testBridgeCatalog'; +export * from './content/testRouteCatalog'; diff --git a/src/game/core/ridge/observe.ts b/src/game/core/ridge/observe.ts index 8164d0b..e3e5c61 100644 --- a/src/game/core/ridge/observe.ts +++ b/src/game/core/ridge/observe.ts @@ -14,11 +14,11 @@ export function observeRidgeWorld( (a, b) => a.distance - b.distance ); const actors = stage.resolveActors(state); + const crossingOpen = stage.isCrossingOpen?.(state) ?? true; const blockedAhead = state.facing === 'right' && stage.blockedProgress !== undefined && - state.beat !== 'bridge_complete' && - state.beat !== 'concert_handoff' && + !crossingOpen && state.progress >= stage.blockedProgress - 0.001; const conversation = state.conversation @@ -41,7 +41,7 @@ export function observeRidgeWorld( })() : null; - const hints = buildHints(state, nearby, blockedAhead); + const hints = buildHints(state, stage, nearby, blockedAhead); return { mode: state.mode, @@ -65,6 +65,7 @@ export function observeRidgeWorld( function buildHints( state: RidgeWorldState, + stage: RidgeStageDefinition, nearby: RidgeObservation['nearby'], blockedAhead: boolean ): string[] { @@ -85,10 +86,16 @@ function buildHints( hints.unshift(`interact — ${nearby[0].prompt} (${nearby[0].label})`); } if (blockedAhead) { - hints.unshift('The unfinished bridge blocks the path east until it is completed.'); + const blockedMessage = stage.blockedMessage; + hints.unshift( + (typeof blockedMessage === 'function' + ? blockedMessage(state) + : blockedMessage) ?? + 'The path east is blocked until the local problem clears.' + ); } - if (state.beat === 'concert_handoff') { - hints.unshift('Bridge handoff complete. Concert area is next (not implemented yet).'); + if (state.beat === 'relay_complete') { + hints.unshift('The dedication fades. The page will return to the Bridge.'); } return hints; @@ -98,7 +105,7 @@ export function formatObservation(observation: RidgeObservation): string { const lines: string[] = []; lines.push(`# ${observation.title}`); lines.push( - `mode=${observation.mode} beat=${observation.beat} progress=${observation.progressPercent}% facing=${observation.facing}` + `mode=${observation.mode} area=${observation.areaId} beat=${observation.beat} progress=${observation.progressPercent}% facing=${observation.facing}` ); lines.push(observation.ambience); diff --git a/src/game/core/ridge/session.test.ts b/src/game/core/ridge/session.test.ts index 02cde90..1d326be 100644 --- a/src/game/core/ridge/session.test.ts +++ b/src/game/core/ridge/session.test.ts @@ -1,11 +1,22 @@ import { describe, expect, it } from 'vitest'; import { createBridgeStage } from './content/bridgeStage'; +import { createRidgeRouteStages } from './content/routeStages'; import { TEST_BRIDGE_DIALOGUE_CATALOG } from './content/testBridgeCatalog'; +import { TEST_ROUTE_DIALOGUE_CATALOG } from './content/testRouteCatalog'; import { parseRidgeCommand, parseRidgeScript } from './commands'; import { RidgeConsoleSession } from './session'; +import type { RidgeSessionEvent } from './types'; -function createSession() { - return new RidgeConsoleSession({ stage: createBridgeStage(TEST_BRIDGE_DIALOGUE_CATALOG) }); +function createBridgeOnlySession() { + return new RidgeConsoleSession({ + stage: createBridgeStage(TEST_BRIDGE_DIALOGUE_CATALOG) + }); +} + +function createRouteSession() { + return new RidgeConsoleSession({ + stages: createRidgeRouteStages(TEST_ROUTE_DIALOGUE_CATALOG) + }); } describe('parseRidgeCommand', () => { @@ -30,58 +41,21 @@ describe('parseRidgeCommand', () => { describe('RidgeConsoleSession Bridge playthrough', () => { it('runs semicolon scripts', () => { - const session = createSession(); + const session = createBridgeOnlySession(); const results = session.execScript('look; go right 1'); expect(results.map((r) => r.ok)).toEqual([true, true]); }); it('walks the Bridge tracer end-to-end through console commands', () => { - const session = createSession(); - - // Reach Cicka and meet her (with Persona-style choice). - walkTo(session, 0.22); - expect(session.exec('interact cicka').ok).toBe(true); - drainConversation(session); - expect(session.observe().conversation?.awaitingChoice).toBe(true); - expect(session.exec('choose pet').ok).toBe(true); - drainConversation(session); - expect(session.observe().mode).toBe('explore'); - - // Learn about the missing span. - walkTo(session, 0.55); - expect(session.exec('interact draftsperson').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('needs_toy_car'); - - // Soft wall before the bridge opens. - const blocked = session.exec('go right 5'); - expect(blocked.ok).toBe(false); - expect(session.observe().progress).toBeLessThanOrEqual(0.62); - - // Retrieve toy car via parallel play. - walkTo(session, 0.22); - expect(session.exec('interact').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('toy_car_shared'); - expect(session.observe().inventory).toContain('toy-car'); - - // Auto-success toy car test opens the crossing. - walkTo(session, 0.55); - expect(session.exec('interact').ok).toBe(true); - drainConversation(session); - expect(session.observe().beat).toBe('bridge_complete'); - - // Exit handoff. - walkTo(session, 0.9); - expect(session.exec('interact').ok).toBe(true); - const events = drainConversation(session); - expect(events.some((event) => event.type === 'concert_handoff')).toBe(true); - expect(session.observe().beat).toBe('concert_handoff'); - expect(session.observe().areaId).toBe('concert'); + const session = createBridgeOnlySession(); + playBridge(session); + const observation = session.observe(); + expect(observation.areaId).toBe('concert'); + expect(observation.beat).toBe('concert_arrival'); }); it('exposes nearby distances useful for AI agents', () => { - const session = createSession(); + const session = createBridgeOnlySession(); walkTo(session, 0.22); const observation = session.observe(); expect(observation.nearby.some((item) => item.spotId === 'cicka')).toBe(true); @@ -90,6 +64,141 @@ describe('RidgeConsoleSession Bridge playthrough', () => { }); }); +describe('RidgeConsoleSession playtest skips', () => { + it('warps and skips across Compact Ridge Areas with guitar when needed', () => { + const session = createRouteSession(); + expect(session.exec('warp dance').ok).toBe(true); + expect(session.observe().areaId).toBe('danceFestival'); + expect(session.observe().inventory).toContain('guitar'); + + expect(session.exec('skip').ok).toBe(true); + expect(session.observe().areaId).toBe('relay'); + + expect(session.exec('skip').ok).toBe(false); + expect(session.exec('warp bridge').ok).toBe(true); + expect(session.observe().areaId).toBe('bridge'); + expect(session.observe().inventory).toEqual([]); + }); +}); + +describe('RidgeConsoleSession first-playable full route', () => { + it('plays Bridge → Concert → Dance → Relay farewell → Bridge reset', () => { + const session = createRouteSession(); + + playBridge(session); + expect(session.observe().areaId).toBe('concert'); + expect(session.observe().title).toContain('Concert'); + + playConcert(session); + expect(session.observe().areaId).toBe('danceFestival'); + expect(session.observe().inventory).toContain('guitar'); + + playDance(session); + expect(session.observe().areaId).toBe('relay'); + + const endingEvents = playRelay(session); + expect(endingEvents.some((event) => event.type === 'route_reset')).toBe(true); + expect(session.observe().areaId).toBe('bridge'); + expect(session.observe().beat).toBe('intro'); + expect(session.observe().inventory).toEqual([]); + expect(session.format()).toContain('Bridge Area'); + }); +}); + +function playBridge(session: RidgeConsoleSession): RidgeSessionEvent[] { + const events: RidgeSessionEvent[] = []; + + walkTo(session, 0.22); + expect(session.exec('interact cicka').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.exec('choose pet').ok).toBe(true); + events.push(...drainConversation(session)); + + walkTo(session, 0.55); + expect(session.exec('interact draftsperson').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().beat).toBe('needs_toy_car'); + + const blocked = session.exec('go right 5'); + expect(blocked.ok).toBe(false); + + walkTo(session, 0.22); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().inventory).toContain('toy-car'); + + walkTo(session, 0.55); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(session.observe().beat).toBe('bridge_complete'); + + walkTo(session, 0.9); + expect(session.exec('interact').ok).toBe(true); + events.push(...drainConversation(session)); + expect(events.some((event) => event.type === 'area_handoff')).toBe(true); + return events; +} + +function playConcert(session: RidgeConsoleSession): void { + walkTo(session, 0.55); + expect(session.exec('interact crowd').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.28); + expect(session.exec('interact guitarist').ok).toBe(true); + drainConversation(session); + + expect(session.exec('interact guitarist').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('concert_practiced'); + + walkTo(session, 0.42); + expect(session.exec('interact stage').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('concert_cleared'); + expect(session.observe().inventory).toContain('guitar'); + + walkTo(session, 0.9); + expect(session.exec('interact').ok).toBe(true); + drainConversation(session); +} + +function playDance(session: RidgeConsoleSession): void { + walkTo(session, 0.14); + expect(session.exec('interact traveler').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.56); + expect(session.exec('interact operations').ok).toBe(true); + drainConversation(session); + + walkTo(session, 0.4); + expect(session.exec('interact teacher').ok).toBe(true); + drainConversation(session); + expect(session.observe().beat).toBe('dance_ready'); + + // Nearest interact at the soft wall should prefer the gate over the steward. + walkTo(session, 0.68); + expect(session.exec('interact').ok).toBe(true); + expect(session.observe().conversation?.id).toBe('dance.setup_clearance'); + drainConversation(session); + expect(session.observe().beat).toBe('dance_cleared'); + + walkTo(session, 0.9); + expect(session.exec('interact shuttle').ok).toBe(true); + drainConversation(session); +} + +function playRelay(session: RidgeConsoleSession): RidgeSessionEvent[] { + walkTo(session, 0.55); + expect(session.exec('interact').ok).toBe(true); + const events = drainConversation(session); + expect(session.observe().conversation?.awaitingChoice).toBe(true); + expect(session.exec('choose let-song-end').ok).toBe(true); + events.push(...drainConversation(session)); + return events; +} + function walkTo(session: RidgeConsoleSession, target: number): void { for (let i = 0; i < 40; i += 1) { const progress = session.observe().progress; @@ -100,8 +209,8 @@ function walkTo(session: RidgeConsoleSession, target: number): void { } function drainConversation(session: RidgeConsoleSession) { - const events = []; - for (let i = 0; i < 12; i += 1) { + const events: RidgeSessionEvent[] = []; + for (let i = 0; i < 20; i += 1) { const observation = session.observe(); if (observation.mode !== 'conversation') break; if (observation.conversation?.awaitingChoice) break; diff --git a/src/game/core/ridge/session.ts b/src/game/core/ridge/session.ts index 1463fbd..bc344ce 100644 --- a/src/game/core/ridge/session.ts +++ b/src/game/core/ridge/session.ts @@ -9,20 +9,35 @@ import { import { getRidgeHelpText, parseRidgeCommand, parseRidgeScript } from './commands'; import { formatObservation, observeRidgeWorld } from './observe'; import type { - RidgeBridgeBeat, + RidgeAreaId, RidgeCommand, RidgeCommandResult, RidgeFacing, RidgeObservation, + RidgeRouteBeat, + RidgeSessionEvent, RidgeStageDefinition, + RidgeStageRegistry, RidgeWorldState } from './types'; +import { RIDGE_GUITAR_ITEM, RIDGE_INITIAL_BEAT } from './types'; const DEFAULT_STEP = 0.05; +const NEXT_AREA: Record = { + bridge: 'concert', + concert: 'danceFestival', + danceFestival: 'relay', + relay: null +}; + export interface RidgeSessionOptions { - stage: RidgeStageDefinition; - beat?: RidgeBridgeBeat; + /** Preferred: full multi-area registry for Compact Area Transitions. */ + stages?: RidgeStageRegistry; + /** Legacy single-stage constructor (Bridge-only tests). */ + stage?: RidgeStageDefinition; + areaId?: RidgeAreaId; + beat?: RidgeRouteBeat; progress?: number; facing?: RidgeFacing; inventory?: readonly string[]; @@ -31,16 +46,27 @@ export interface RidgeSessionOptions { export class RidgeConsoleSession { private state: RidgeWorldState; - private readonly stage: RidgeStageDefinition; + private stage: RidgeStageDefinition; + private readonly stages: RidgeStageRegistry | null; constructor(options: RidgeSessionOptions) { - this.stage = options.stage; + if (options.stages) { + this.stages = options.stages; + const areaId = options.areaId ?? 'bridge'; + this.stage = options.stages[areaId]; + } else if (options.stage) { + this.stages = null; + this.stage = options.stage; + } else { + throw new Error('RidgeConsoleSession requires stages or stage.'); + } + this.state = { - areaId: options.stage.areaId, - title: options.stage.title, + areaId: this.stage.areaId, + title: this.stage.title, progress: clamp01(options.progress ?? 0.05), facing: options.facing ?? 'right', - beat: options.beat ?? 'intro', + beat: options.beat ?? RIDGE_INITIAL_BEAT[this.stage.areaId], mode: 'explore', flags: new Set(options.flags ?? []), inventory: [...(options.inventory ?? [])], @@ -93,6 +119,10 @@ export class RidgeConsoleSession { return this.handleChoose(command.choiceIdOrIndex); case 'leave': return this.handleLeave(); + case 'skip': + return this.handleSkip(); + case 'warp': + return this.handleWarp(command.areaId); case 'unknown': return this.fail( command.raw @@ -113,16 +143,21 @@ export class RidgeConsoleSession { let blocked = false; const blockedAt = this.stage.blockedProgress; + const crossingOpen = this.stage.isCrossingOpen?.(this.state) ?? true; if ( blockedAt !== undefined && - this.state.beat !== 'bridge_complete' && - this.state.beat !== 'concert_handoff' && + !crossingOpen && direction === 'right' && nextProgress > blockedAt ) { nextProgress = blockedAt; blocked = true; - message = 'The unfinished bridge blocks the way. Talk to the draftsperson or finish the crossing.'; + const blockedMessage = this.stage.blockedMessage; + message = + (typeof blockedMessage === 'function' + ? blockedMessage(this.state) + : blockedMessage) ?? + 'Something blocks the way east. Talk to someone nearby.'; } this.state = { @@ -145,7 +180,9 @@ export class RidgeConsoleSession { return this.fail('Already in conversation. Use advance / choose / leave.'); } - const nearby = this.stage.resolveInteractables(this.state); + const nearby = [...this.stage.resolveInteractables(this.state)].sort( + (a, b) => a.distance - b.distance + ); if (nearby.length === 0) { return this.fail('Nothing in reach.'); } @@ -184,13 +221,7 @@ export class RidgeConsoleSession { return this.fail('No active conversation.'); } const result = advanceConversation(this.state); - this.state = result.state; - return { - ok: true, - message: result.message, - observation: this.observe(), - events: result.events - }; + return this.commitConversationResult(result); } private handleChoose(choiceIdOrIndex: string): RidgeCommandResult { @@ -198,23 +229,124 @@ export class RidgeConsoleSession { return this.fail('No active conversation.'); } const result = chooseInConversation(this.state, choiceIdOrIndex); + return this.commitConversationResult({ + ...result, + ok: !result.message.startsWith('Unknown') + }); + } + + private handleLeave(): RidgeCommandResult { + const result = leaveConversation(this.state); this.state = result.state; return { - ok: result.message.startsWith('Unknown') ? false : true, + ok: true, message: result.message, observation: this.observe(), events: result.events }; } - private handleLeave(): RidgeCommandResult { - const result = leaveConversation(this.state); - this.state = result.state; + private handleSkip(): RidgeCommandResult { + const next = NEXT_AREA[this.state.areaId]; + if (!next) { + return this.fail('Already at Relay. Use: warp bridge|concert|dance|relay'); + } + return this.handleWarp(next); + } + + private handleWarp(areaId: RidgeAreaId): RidgeCommandResult { + if (!this.stages && areaId !== this.stage.areaId) { + return this.fail('Warp needs the full route registry (use pnpm ridge:console).'); + } + + this.applyAreaHandoff(areaId); + this.state = { + ...this.state, + inventory: inventoryForArea(areaId), + flags: new Set(), + lastMessage: `DEV warp → ${this.stage.title}` + }; + return { ok: true, + message: this.state.lastMessage ?? `Warped to ${areaId}.`, + observation: this.observe(), + events: [ + { type: 'area_handoff', areaId }, + { type: 'beat_changed', beat: this.state.beat } + ] + }; + } + + private commitConversationResult(result: { + state: RidgeWorldState; + events: RidgeSessionEvent[]; + message: string; + ok?: boolean; + }): RidgeCommandResult { + this.state = result.state; + const events = [...result.events]; + + for (const event of result.events) { + if (event.type === 'area_handoff') { + this.applyAreaHandoff(event.areaId); + events.push({ type: 'beat_changed', beat: this.state.beat }); + } else if (event.type === 'route_reset') { + this.applyRouteReset(); + events.push({ type: 'beat_changed', beat: this.state.beat }); + events.push({ type: 'area_handoff', areaId: 'bridge' }); + } + } + + return { + ok: result.ok ?? true, message: result.message, observation: this.observe(), - events: result.events + events + }; + } + + private applyAreaHandoff(areaId: RidgeAreaId): void { + if (!this.stages) { + this.state = { + ...this.state, + areaId, + beat: RIDGE_INITIAL_BEAT[areaId], + progress: 0.05, + facing: 'right', + lastMessage: `The page turns toward ${areaId}.` + }; + return; + } + + this.stage = this.stages[areaId]; + this.state = { + ...this.state, + areaId, + title: this.stage.title, + beat: RIDGE_INITIAL_BEAT[areaId], + progress: 0.05, + facing: 'right', + mode: 'explore', + conversation: null, + lastMessage: `Arrived: ${this.stage.title}` + }; + } + + private applyRouteReset(): void { + const bridge = this.stages?.bridge ?? this.stage; + this.stage = bridge; + this.state = { + areaId: 'bridge', + title: bridge.title, + progress: 0.05, + facing: 'right', + beat: 'intro', + mode: 'explore', + flags: new Set(), + inventory: [], + conversation: null, + lastMessage: 'For Cicka. The page begins again at the Bridge.' }; } @@ -239,6 +371,13 @@ export class RidgeConsoleSession { } } +function inventoryForArea(areaId: RidgeAreaId): string[] { + if (areaId === 'danceFestival' || areaId === 'relay') { + return [RIDGE_GUITAR_ITEM]; + } + return []; +} + function clamp01(value: number): number { if (value < 0) return 0; if (value > 1) return 1; diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index 7bcd5a9..5666a4a 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -4,14 +4,32 @@ export type RidgeAreaId = 'bridge' | 'concert' | 'danceFestival' | 'relay'; -export type RidgeBridgeBeat = +/** Full first-playable route beat vocabulary across all areas. */ +export type RidgeRouteBeat = + // Bridge | 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' - | 'concert_handoff'; - -export type RidgeBridgeAreaBeat = Exclude; + // Concert + | 'concert_arrival' + | 'concert_practiced' + | 'concert_cleared' + // Dance Festival + | 'dance_arrival' + | 'dance_ready' + | 'dance_cleared' + // Relay Ending + | 'relay_linger' + | 'relay_farewell' + | 'relay_complete'; + +/** @deprecated Prefer RidgeRouteBeat — kept for Bridge-local call sites. */ +export type RidgeBridgeBeat = RidgeRouteBeat; +export type RidgeBridgeAreaBeat = Extract< + RidgeRouteBeat, + 'intro' | 'needs_toy_car' | 'toy_car_shared' | 'bridge_complete' +>; export type RidgeFacing = 'left' | 'right'; @@ -19,7 +37,21 @@ export type RidgeMode = 'explore' | 'conversation'; export type RidgeSpotKind = 'landmark' | 'npc' | 'prop' | 'exit'; -export type RidgeActorId = 'player' | 'cicka' | 'draftsperson' | 'toy-car'; +export type RidgeActorId = + | 'player' + | 'cicka' + | 'draftsperson' + | 'toy-car' + | 'guitarist' + | 'crowd' + | 'guitar' + | 'traveler' + | 'driver' + | 'operations-helper' + | 'dance-teacher' + | 'steward' + | 'counterpart-cat' + | 'shuttle'; export interface RidgeDialogueLine { id: string; @@ -37,9 +69,13 @@ export interface RidgeDialogueChoice { } export interface RidgeConversationOutcome { - setBeat?: RidgeBridgeAreaBeat; - concertHandoff?: boolean; + setBeat?: RidgeRouteBeat; + /** Swap into the next Compact Ridge Stage and reset progress. */ + handoffToArea?: RidgeAreaId; + /** First Playable Reset Return — clean Bridge restart. */ + routeReset?: boolean; addItem?: string; + removeItem?: string; setFlag?: string; clearFlag?: string; } @@ -96,6 +132,10 @@ export interface RidgeStageDefinition { spots: readonly RidgeSpotDefinition[]; /** Soft wall until the crossing opens. */ blockedProgress?: number; + /** Message when the soft wall stops the player. */ + blockedMessage?: string | ((state: RidgeWorldState) => string); + /** When true, blockedProgress no longer applies. */ + isCrossingOpen?: (state: RidgeWorldState) => boolean; resolveInteractables: (state: RidgeWorldState) => readonly RidgeInteractable[]; resolveActors: (state: RidgeWorldState) => readonly RidgeActorPresence[]; resolveConversation: ( @@ -105,12 +145,14 @@ export interface RidgeStageDefinition { describeAmbience: (state: RidgeWorldState) => string; } +export type RidgeStageRegistry = Record; + export interface RidgeWorldState { areaId: RidgeAreaId; title: string; progress: number; facing: RidgeFacing; - beat: RidgeBridgeBeat; + beat: RidgeRouteBeat; mode: RidgeMode; flags: ReadonlySet; inventory: readonly string[]; @@ -122,7 +164,7 @@ export interface RidgeObservation { mode: RidgeMode; areaId: RidgeAreaId; title: string; - beat: RidgeBridgeBeat; + beat: RidgeRouteBeat; progress: number; progressPercent: number; facing: RidgeFacing; @@ -157,14 +199,20 @@ export type RidgeCommand = | { type: 'advance' } | { type: 'choose'; choiceIdOrIndex: string } | { type: 'leave' } + /** DEV / AI: jump to the next Compact Ridge Area. */ + | { type: 'skip' } + /** DEV / AI: warp to a named area start. */ + | { type: 'warp'; areaId: RidgeAreaId } | { type: 'unknown'; raw: string }; export type RidgeSessionEvent = - | { type: 'beat_changed'; beat: RidgeBridgeAreaBeat } - | { type: 'concert_handoff' } + | { type: 'beat_changed'; beat: RidgeRouteBeat } + | { type: 'area_handoff'; areaId: RidgeAreaId } + | { type: 'route_reset' } | { type: 'conversation_started'; conversationId: string } | { type: 'conversation_ended'; conversationId: string } | { type: 'item_added'; itemId: string } + | { type: 'item_removed'; itemId: string } | { type: 'flag_changed'; flag: string; present: boolean }; export interface RidgeCommandResult { @@ -173,3 +221,13 @@ export interface RidgeCommandResult { observation: RidgeObservation; events: readonly RidgeSessionEvent[]; } + +export const RIDGE_INITIAL_BEAT: Record = { + bridge: 'intro', + concert: 'concert_arrival', + danceFestival: 'dance_arrival', + relay: 'relay_linger' +}; + +export const RIDGE_GUITAR_ITEM = 'guitar'; +export const RIDGE_TOY_CAR_ITEM = 'toy-car'; diff --git a/src/game/scenes/basement/runtime/BasementScene.ts b/src/game/scenes/basement/runtime/BasementScene.ts index 627aded..19d84e1 100644 --- a/src/game/scenes/basement/runtime/BasementScene.ts +++ b/src/game/scenes/basement/runtime/BasementScene.ts @@ -25,7 +25,7 @@ import { type OpenOverlayOptions } from '@/game/bridge/store'; import { GameConsoleSession, type GameCommandResult } from '@/game/core/console'; -import { loadBridgeDialogueCatalog } from '@/game/scenes/ridge/content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '@/game/scenes/ridge/content/routeCatalog'; import { applyGameConsoleEvents } from '@/game/scenes/shared/applyGameConsoleEvents'; import { createUiText } from '@/game/sharedSceneRuntime/text/createUiText'; import { PlayerThoughtText } from '@/game/sharedSceneRuntime/text/PlayerThoughtText'; @@ -78,7 +78,7 @@ export class BasementScene extends Phaser.Scene { const messages = getMessages(); const bridge = bridgeStore.getState(); this.session = new GameConsoleSession({ - dialogue: loadBridgeDialogueCatalog(), + dialogue: loadRidgeRouteDialogueCatalog(), sceneId: 'basement', ownedItemIds: bridge.inventory.ownedItemIds, equippedItemIds: bridge.equipment.equippedItemIds, diff --git a/src/game/scenes/overworld/runtime/OverworldScene.ts b/src/game/scenes/overworld/runtime/OverworldScene.ts index 6afef60..747b5b3 100644 --- a/src/game/scenes/overworld/runtime/OverworldScene.ts +++ b/src/game/scenes/overworld/runtime/OverworldScene.ts @@ -39,7 +39,7 @@ import { type GameCommandResult, type NearbyThing } from '@/game/core/console'; -import { loadBridgeDialogueCatalog } from '@/game/scenes/ridge/content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '@/game/scenes/ridge/content/routeCatalog'; import { applyGameConsoleEvents } from '@/game/scenes/shared/applyGameConsoleEvents'; import { buildStreetEnvironment, @@ -130,7 +130,7 @@ export class OverworldScene extends Phaser.Scene { const messages = getMessages(); const bridge = bridgeStore.getState(); this.session = new GameConsoleSession({ - dialogue: loadBridgeDialogueCatalog(), + dialogue: loadRidgeRouteDialogueCatalog(), sceneId: 'overworld', ownedItemIds: bridge.inventory.ownedItemIds, equippedItemIds: bridge.equipment.equippedItemIds, diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index bc16b18..85c71e9 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -1,12 +1,22 @@ // Stick sync redraws many actors/layers each frame; branching is presentation policy. // fallow-ignore-file complexity import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; import type { RidgeVisualProvider, RidgeVisualViewModel } from '../types'; import { drawStickCicka, + drawStickCrowd, + drawStickDanceTeacher, drawStickDraftsperson, + drawStickDriver, + drawStickGuitar, + drawStickGuitarist, + drawStickOperationsHelper, drawStickPlayer, - drawStickToyCar + drawStickShuttle, + drawStickSteward, + drawStickToyCar, + drawStickTraveler } from './stickFigures'; const PAPER = 0xfbfbf9; @@ -35,6 +45,7 @@ export class StickVisualProvider implements RidgeVisualProvider { private readonly actors: Phaser.GameObjects.Graphics; private readonly overlay: Phaser.GameObjects.Graphics; private promptText?: Phaser.GameObjects.Text; + private readonly nameLabels = new Map(); private destroyed = false; constructor(scene: Phaser.Scene, options: StickVisualProviderOptions = {}) { @@ -71,11 +82,13 @@ export class StickVisualProvider implements RidgeVisualProvider { sync(view: RidgeVisualViewModel): void { if (this.destroyed) return; - this.drawBackground(view.bridgeOpen); + this.drawBackground(view.areaId, view.crossingOpen, view.beat); this.actors.clear(); + const visibleIds = new Set(); for (const actor of view.actors) { if (!actor.visible) continue; + visibleIds.add(actor.id); const x = this.worldXForProgress(actor.progress); const y = GROUND_Y; switch (actor.id) { @@ -83,7 +96,8 @@ export class StickVisualProvider implements RidgeVisualProvider { drawStickPlayer(this.actors, x, y, actor.facing, 1.15); break; case 'cicka': - drawStickCicka(this.actors, x, y, 1.1); + case 'counterpart-cat': + drawStickCicka(this.actors, x, y, actor.id === 'counterpart-cat' ? 0.95 : 1.1); break; case 'draftsperson': drawStickDraftsperson(this.actors, x, y, actor.facing, 1.05); @@ -91,16 +105,51 @@ export class StickVisualProvider implements RidgeVisualProvider { case 'toy-car': drawStickToyCar(this.actors, x + 18, y + 4, 1.1); break; + case 'guitarist': + drawStickGuitarist(this.actors, x, y, actor.facing, 1.05); + break; + case 'crowd': + drawStickCrowd(this.actors, x, y, 1); + break; + case 'guitar': + drawStickGuitar(this.actors, x + 16, y + 2, 1.1); + break; + case 'traveler': + drawStickTraveler(this.actors, x, y, actor.facing, 1); + break; + case 'driver': + drawStickDriver(this.actors, x, y, actor.facing, 1.05); + break; + case 'operations-helper': + drawStickOperationsHelper(this.actors, x, y, actor.facing, 1.05); + break; + case 'dance-teacher': + drawStickDanceTeacher(this.actors, x, y, actor.facing, 1.05); + break; + case 'steward': + drawStickSteward(this.actors, x, y, actor.facing, 1); + break; + case 'shuttle': + drawStickShuttle(this.actors, x, y, 1.1); + break; + } + + if (actor.id !== 'player' && actor.id !== 'toy-car' && actor.id !== 'guitar') { + this.syncNameLabel(actor.id, actor.label, x, y); } } + for (const [id, label] of this.nameLabels) { + if (!visibleIds.has(id)) label.setVisible(false); + } + const player = view.actors.find((actor) => actor.id === 'player'); if (player && this.promptText) { if (view.nearbyPrompt && view.mode === 'explore') { const x = this.worldXForProgress(player.progress); this.promptText .setText(`[E] ${view.nearbyPrompt}`) - .setPosition(x, GROUND_Y - 110) + .setPosition(x, GROUND_Y - 130) .setVisible(true); } else { this.promptText.setVisible(false); @@ -117,29 +166,62 @@ export class StickVisualProvider implements RidgeVisualProvider { this.root.destroy(true); this.promptText?.destroy(); this.promptText = undefined; + for (const label of this.nameLabels.values()) label.destroy(); + this.nameLabels.clear(); + } + + private syncNameLabel(id: string, text: string, x: number, y: number): void { + let label = this.nameLabels.get(id); + if (!label) { + label = this.scene.add + .text(0, 0, text, { + fontFamily: 'Caveat, Comic Neue, cursive', + fontSize: '16px', + color: '#1a1a1a', + backgroundColor: '#fbfbf9aa', + padding: { x: 4, y: 1 } + }) + .setOrigin(0.5, 1) + .setDepth(35); + this.nameLabels.set(id, label); + } + label.setText(text).setPosition(x, y - 78).setVisible(true); } - private drawBackground(bridgeOpen: boolean): void { + private drawBackground( + areaId: RidgeAreaId, + crossingOpen: boolean, + beat: RidgeVisualViewModel['beat'] + ): void { const g = this.background; g.clear(); - - // paper wash g.fillStyle(PAPER, 1); g.fillRect(0, 0, this.stageWidth, this.stageHeight); - // faint ruled lines g.lineStyle(1, FAINT, 0.08); for (let y = 40; y < this.stageHeight; y += 36) { g.lineBetween(0, y, this.stageWidth, y); } - // sky hatch band + g.lineStyle(3, INK, 1); + g.lineBetween(0, GROUND_Y, this.stageWidth, GROUND_Y); + + if (areaId === 'bridge') { + this.drawBridgeSet(g, crossingOpen); + } else if (areaId === 'concert') { + this.drawConcertSet(g, crossingOpen); + } else if (areaId === 'danceFestival') { + this.drawDanceSet(g, crossingOpen); + } else { + this.drawRelaySet(g, beat); + } + } + + private drawBridgeSet(g: Phaser.GameObjects.Graphics, bridgeOpen: boolean): void { g.lineStyle(1, INK, 0.12); for (let x = 0; x < this.stageWidth; x += 28) { g.lineBetween(x, 40, x + 18, 90); } - - // far hills g.lineStyle(2, INK, 0.35); g.beginPath(); g.moveTo(0, 280); @@ -151,17 +233,14 @@ export class StickVisualProvider implements RidgeVisualProvider { g.lineTo(this.stageWidth, 240); g.strokePath(); - // cornfield sticks (left) g.lineStyle(2, INK, 0.7); for (let i = 0; i < 26; i += 1) { const x = 90 + i * 18; const h = 55 + ((i * 17) % 35); g.lineBetween(x, GROUND_Y, x, GROUND_Y - h); g.lineBetween(x, GROUND_Y - h, x + 8, GROUND_Y - h - 10); - g.lineBetween(x, GROUND_Y - h, x - 7, GROUND_Y - h - 8); } - // river const riverLeft = this.worldXForProgress(0.58); const riverRight = this.worldXForProgress(0.78); g.lineStyle(2, INK, 0.45); @@ -174,45 +253,99 @@ export class StickVisualProvider implements RidgeVisualProvider { g.strokePath(); } - // banks - g.lineStyle(3, INK, 1); - g.lineBetween(0, GROUND_Y, riverLeft, GROUND_Y); - g.lineBetween(riverRight, GROUND_Y, this.stageWidth, GROUND_Y); - g.lineBetween(riverLeft, GROUND_Y, riverLeft, GROUND_Y + 64); - g.lineBetween(riverRight, GROUND_Y, riverRight, GROUND_Y + 64); - - // bridge g.lineStyle(4, INK, 1); if (bridgeOpen) { g.lineBetween(riverLeft, GROUND_Y - 4, riverRight, GROUND_Y - 4); - g.lineBetween(riverLeft + 20, GROUND_Y - 4, riverLeft + 40, GROUND_Y - 28); - g.lineBetween(riverRight - 20, GROUND_Y - 4, riverRight - 40, GROUND_Y - 28); - g.lineBetween(riverLeft + 40, GROUND_Y - 28, riverRight - 40, GROUND_Y - 28); } else { - // unfinished: gap in the middle with dashed intent const mid = (riverLeft + riverRight) / 2; g.lineBetween(riverLeft, GROUND_Y - 4, mid - 36, GROUND_Y - 4); g.lineBetween(mid + 36, GROUND_Y - 4, riverRight, GROUND_Y - 4); g.lineStyle(2, INK, 0.4); g.lineBetween(mid - 30, GROUND_Y - 18, mid + 30, GROUND_Y - 18); - // blueprint board - g.lineStyle(2, INK, 0.9); g.strokeRect(mid - 42, GROUND_Y - 90, 84, 50); - g.lineBetween(mid - 28, GROUND_Y - 65, mid - 8, GROUND_Y - 65); - g.lineBetween(mid + 8, GROUND_Y - 65, mid + 28, GROUND_Y - 65); } - // sun scribble g.lineStyle(2, INK, 0.5); g.strokeCircle(140, 110, 28); + } + + private drawConcertSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { + // night wash hatch + g.lineStyle(1, INK, 0.18); + for (let x = 0; x < this.stageWidth; x += 22) { + g.lineBetween(x, 20, x + 10, 120); + } + // storefronts + g.lineStyle(2.5, INK, 0.85); + for (let i = 0; i < 6; i += 1) { + const x = 120 + i * 220; + g.strokeRect(x, GROUND_Y - 160, 140, 160); + g.strokeRect(x + 20, GROUND_Y - 100, 40, 50); + g.strokeRect(x + 80, GROUND_Y - 100, 40, 50); + } + // crossing / crowd lane mark + const gate = this.worldXForProgress(0.55); + g.lineStyle(3, INK, crossingOpen ? 0.25 : 0.9); + g.lineBetween(gate, GROUND_Y - 8, gate, GROUND_Y - 70); + if (!crossingOpen) { + g.lineBetween(gate - 40, GROUND_Y - 40, gate + 40, GROUND_Y - 40); + } + // moon + g.lineStyle(2, INK, 0.55); + g.strokeCircle(this.stageWidth - 160, 100, 26); + } + + private drawDanceSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { + // daytime warm hatch + g.lineStyle(1, INK, 0.1); + for (let x = 0; x < this.stageWidth; x += 30) { + g.lineBetween(x, 30, x + 16, 100); + } + // lantern posts + g.lineStyle(2, INK, 0.8); for (let i = 0; i < 8; i += 1) { - const a = (Math.PI * 2 * i) / 8; - g.lineBetween( - 140 + Math.cos(a) * 34, - 110 + Math.sin(a) * 34, - 140 + Math.cos(a) * 48, - 110 + Math.sin(a) * 48 - ); + const x = 160 + i * 170; + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); + g.strokeRect(x - 8, GROUND_Y - 110, 16, 20); + } + // service gate (matches soft-wall progress) + const gate = this.worldXForProgress(0.68); + g.lineStyle(3, INK, crossingOpen ? 0.25 : 1); + g.strokeRect(gate - 36, GROUND_Y - 90, 72, 90); + g.lineStyle(2, INK, crossingOpen ? 0.2 : 0.7); + g.lineBetween(gate - 20, GROUND_Y - 70, gate + 20, GROUND_Y - 70); + g.lineBetween(gate - 20, GROUND_Y - 50, gate + 20, GROUND_Y - 50); + if (crossingOpen) { + g.lineStyle(2, INK, 0.4); + g.lineBetween(gate + 36, GROUND_Y - 90, gate + 80, GROUND_Y - 40); + } + // dance floor edge near teacher + g.lineStyle(2, INK, 0.35); + g.strokeEllipse(this.worldXForProgress(0.4), GROUND_Y - 20, 100, 28); + } + + private drawRelaySet( + g: Phaser.GameObjects.Graphics, + beat: RidgeVisualViewModel['beat'] + ): void { + // sunset arcs + g.lineStyle(2, INK, 0.35); + for (let i = 0; i < 5; i += 1) { + g.strokeCircle(this.stageWidth * 0.7, 180, 40 + i * 28); } + // overlook ledge + g.lineStyle(3, INK, 1); + g.lineBetween(this.worldXForProgress(0.15), GROUND_Y, this.worldXForProgress(0.9), GROUND_Y); + g.lineBetween( + this.worldXForProgress(0.85), + GROUND_Y, + this.worldXForProgress(0.95), + GROUND_Y + 40 + ); + // warm threshold seam + const tx = this.worldXForProgress(0.85); + g.lineStyle(2, INK, beat === 'relay_complete' ? 0.2 : 0.7); + g.strokeCircle(tx, GROUND_Y - 70, 34); + g.lineBetween(tx - 20, GROUND_Y - 70, tx + 20, GROUND_Y - 70); } } diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 3524b5a..6fd0935 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -1,3 +1,5 @@ +// Distinct NPC silhouettes intentionally branch on role props. +// fallow-ignore-file complexity import type * as Phaser from 'phaser'; import type { RidgeFacing } from '@/game/core/ridge'; @@ -16,23 +18,17 @@ export function drawStickPlayer( g.lineStyle(3, INK, 1); g.fillStyle(PAPER, 1); - // head g.fillCircle(x, y - s * 1.7, s * 0.45); g.strokeCircle(x, y - s * 1.7, s * 0.45); - - // body g.lineBetween(x, y - s * 1.25, x, y - s * 0.2); - - // arms g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); g.lineBetween(x, y - s * 0.95, x - dir * s * 0.55, y - s * 0.5); - - // legs g.lineBetween(x, y - s * 0.2, x - s * 0.4, y + s * 0.55); g.lineBetween(x, y - s * 0.2, x + s * 0.4, y + s * 0.55); - - // tiny backpack mark - g.strokeRect(x - dir * s * 0.55, y - s * 1.05, s * 0.28, s * 0.45); + // backpack — player signature + g.fillStyle(INK, 0.12); + g.fillRect(x - dir * s * 0.55, y - s * 1.05, s * 0.32, s * 0.5); + g.strokeRect(x - dir * s * 0.55, y - s * 1.05, s * 0.32, s * 0.5); } export function drawStickCicka( @@ -44,22 +40,14 @@ export function drawStickCicka( const s = 12 * scale; g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); - - // body loaf g.fillEllipse(x, y - s * 0.35, s * 1.5, s * 0.9); g.strokeEllipse(x, y - s * 0.35, s * 1.5, s * 0.9); - - // head g.fillCircle(x + s * 0.7, y - s * 0.75, s * 0.45); g.strokeCircle(x + s * 0.7, y - s * 0.75, s * 0.45); - - // ears g.lineBetween(x + s * 0.45, y - s * 1.05, x + s * 0.35, y - s * 1.45); g.lineBetween(x + s * 0.35, y - s * 1.45, x + s * 0.6, y - s * 1.1); g.lineBetween(x + s * 0.85, y - s * 1.05, x + s * 0.95, y - s * 1.45); g.lineBetween(x + s * 0.95, y - s * 1.45, x + s * 0.7, y - s * 1.1); - - // tail g.beginPath(); g.moveTo(x - s * 0.75, y - s * 0.35); g.lineTo(x - s * 1.2, y - s * 0.9); @@ -75,25 +63,11 @@ export function drawStickDraftsperson( ): void { const s = 17 * scale; const dir = facing === 'left' ? -1 : 1; - g.lineStyle(3, INK, 1); - g.fillStyle(PAPER, 1); - - g.fillCircle(x, y - s * 1.65, s * 0.4); - g.strokeCircle(x, y - s * 1.65, s * 0.4); - g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); - g.lineBetween(x, y - s * 0.95, x + dir * s * 0.75, y - s * 0.7); - g.lineBetween(x, y - s * 0.95, x - dir * s * 0.45, y - s * 0.55); - g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); - g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); - - // blueprint board - g.strokeRect(x + dir * s * 0.55, y - s * 0.95, s * 0.7, s * 0.5); - g.lineBetween( - x + dir * s * 0.65, - y - s * 0.7, - x + dir * s * 1.1, - y - s * 0.7 - ); + drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + g.lineStyle(2.5, INK, 1); + g.strokeRect(x + dir * s * 0.5, y - s * 1.05, s * 0.85, s * 0.6); + g.lineBetween(x + dir * s * 0.6, y - s * 0.75, x + dir * s * 1.15, y - s * 0.75); + g.lineBetween(x + dir * s * 0.6, y - s * 0.6, x + dir * s * 1.0, y - s * 0.6); } export function drawStickToyCar( @@ -110,3 +84,221 @@ export function drawStickToyCar( g.strokeCircle(x - s * 0.55, y + s * 0.25, s * 0.28); g.strokeCircle(x + s * 0.55, y + s * 0.25, s * 0.28); } + +interface PersonStyle { + hair?: 'messy' | 'bun' | 'cap' | 'hat' | 'ponytail'; + skirt?: boolean; + apron?: boolean; + raisedArm?: boolean; + walkingStick?: boolean; +} + +function drawBasePerson( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale: number, + style: PersonStyle = {} +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + g.lineStyle(3, INK, 1); + g.fillStyle(PAPER, 1); + + g.fillCircle(x, y - s * 1.65, s * 0.4); + g.strokeCircle(x, y - s * 1.65, s * 0.4); + + if (style.hair === 'messy') { + g.lineBetween(x - s * 0.25, y - s * 1.95, x - s * 0.35, y - s * 2.2); + g.lineBetween(x, y - s * 2.0, x + s * 0.1, y - s * 2.25); + g.lineBetween(x + s * 0.25, y - s * 1.95, x + s * 0.4, y - s * 2.15); + } else if (style.hair === 'bun') { + g.fillCircle(x, y - s * 2.05, s * 0.22); + g.strokeCircle(x, y - s * 2.05, s * 0.22); + } else if (style.hair === 'cap') { + g.lineBetween(x - s * 0.45, y - s * 1.75, x + s * 0.45, y - s * 1.75); + g.strokeRect(x - s * 0.35, y - s * 2.05, s * 0.7, s * 0.3); + } else if (style.hair === 'hat') { + g.strokeRect(x - s * 0.28, y - s * 2.15, s * 0.56, s * 0.35); + g.lineBetween(x - s * 0.5, y - s * 1.8, x + s * 0.5, y - s * 1.8); + } else if (style.hair === 'ponytail') { + g.beginPath(); + g.moveTo(x - dir * s * 0.25, y - s * 1.7); + g.lineTo(x - dir * s * 0.7, y - s * 1.35); + g.strokePath(); + } + + g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); + + if (style.raisedArm) { + g.lineBetween(x, y - s * 0.95, x + dir * s * 0.55, y - s * 1.45); + g.lineBetween(x, y - s * 0.95, x - dir * s * 0.55, y - s * 0.5); + } else { + g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); + g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); + } + + if (style.skirt) { + g.lineBetween(x, y - s * 0.15, x - s * 0.55, y + s * 0.55); + g.lineBetween(x, y - s * 0.15, x + s * 0.55, y + s * 0.55); + g.lineBetween(x - s * 0.55, y + s * 0.55, x + s * 0.55, y + s * 0.55); + } else { + g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); + g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); + } + + if (style.apron) { + g.strokeRect(x - s * 0.28, y - s * 0.95, s * 0.56, s * 0.7); + } + + if (style.walkingStick) { + g.lineStyle(2.5, INK, 1); + g.lineBetween(x + dir * s * 0.7, y - s * 0.55, x + dir * s * 0.85, y + s * 0.55); + } +} + +export function drawStickGuitarist( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + g.lineStyle(2.5, INK, 1); + g.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.6, s * 0.95); + g.lineBetween(x + dir * s * 0.55, y - s * 1.0, x + dir * s * 0.55, y - s * 1.4); + // wrist wrap + g.lineStyle(3, INK, 0.7); + g.lineBetween(x + dir * s * 0.35, y - s * 0.7, x + dir * s * 0.55, y - s * 0.55); +} + +export function drawStickCrowd( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + drawBasePerson(g, x - 18 * scale, y, 'left', scale * 0.8, { hair: 'cap' }); + drawBasePerson(g, x, y, 'right', scale * 0.9, { hair: 'messy' }); + drawBasePerson(g, x + 20 * scale, y, 'left', scale * 0.75, { hair: 'ponytail' }); +} + +export function drawStickGuitar( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + const s = 10 * scale; + g.lineStyle(2.5, INK, 1); + g.strokeEllipse(x, y - s * 0.2, s * 0.7, s); + g.lineBetween(x, y - s * 0.8, x, y - s * 1.5); + g.strokeRect(x - s * 0.15, y - s * 1.65, s * 0.3, s * 0.25); +} + +export function drawStickTraveler( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', walkingStick: true }); + // travel pack + g.lineStyle(2.5, INK, 1); + g.strokeRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); +} + +export function drawStickDriver( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'cap' }); + g.lineStyle(2.5, INK, 1); + // big clipboard + g.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.4, y - s * 1.1, s * 0.55, s * 0.75); + g.strokeRect(x + dir * s * 0.4, y - s * 1.1, s * 0.55, s * 0.75); + g.lineBetween(x + dir * s * 0.5, y - s * 0.85, x + dir * s * 0.85, y - s * 0.85); + g.lineBetween(x + dir * s * 0.5, y - s * 0.65, x + dir * s * 0.8, y - s * 0.65); +} + +export function drawStickOperationsHelper( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', apron: true }); + // lantern held high + g.lineStyle(2.5, INK, 1); + g.lineBetween(x + dir * s * 0.55, y - s * 0.55, x + dir * s * 0.7, y - s * 1.15); + g.strokeRect(x + dir * s * 0.55, y - s * 1.45, s * 0.4, s * 0.4); + g.lineBetween(x + dir * s * 0.75, y - s * 1.45, x + dir * s * 0.75, y - s * 1.65); + // warm hatch inside lantern + g.lineStyle(1.5, INK, 0.45); + g.lineBetween(x + dir * s * 0.62, y - s * 1.35, x + dir * s * 0.88, y - s * 1.15); +} + +export function drawStickDanceTeacher( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + drawBasePerson(g, x, y, facing, scale * 1.08, { + hair: 'bun', + skirt: true, + raisedArm: true + }); +} + +export function drawStickSteward( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale = 1 +): void { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawBasePerson(g, x, y, facing, scale * 1.05, { hair: 'hat' }); + // key on belt + g.lineStyle(2, INK, 1); + g.strokeCircle(x + dir * s * 0.35, y - s * 0.2, s * 0.12); + g.lineBetween(x + dir * s * 0.35, y - s * 0.08, x + dir * s * 0.35, y + s * 0.15); +} + +export function drawStickShuttle( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1 +): void { + const s = 14 * scale; + g.lineStyle(2.5, INK, 1); + g.fillStyle(PAPER, 1); + g.fillRect(x - s * 1.6, y - s, s * 3.2, s * 0.9); + g.strokeRect(x - s * 1.6, y - s, s * 3.2, s * 0.9); + g.strokeCircle(x - s, y + s * 0.1, s * 0.28); + g.strokeCircle(x + s, y + s * 0.1, s * 0.28); + g.strokeRect(x - s * 1.3, y - s * 0.75, s * 0.7, s * 0.4); + // "LAST" mark on side + g.lineStyle(2, INK, 0.55); + g.lineBetween(x - s * 0.2, y - s * 0.55, x + s * 0.9, y - s * 0.55); +} diff --git a/src/game/scenes/ridge/art/types.ts b/src/game/scenes/ridge/art/types.ts index 81aa6ee..f3daec1 100644 --- a/src/game/scenes/ridge/art/types.ts +++ b/src/game/scenes/ridge/art/types.ts @@ -1,5 +1,6 @@ import type { RidgeActorPresence, + RidgeAreaId, RidgeMode, RidgeObservation } from '@/game/core/ridge'; @@ -10,13 +11,14 @@ import type { */ export interface RidgeVisualViewModel { mode: RidgeMode; + areaId: RidgeAreaId; progress: number; facing: RidgeObservation['facing']; beat: RidgeObservation['beat']; ambience: string; nearbyPrompt: string | null; actors: readonly RidgeActorPresence[]; - bridgeOpen: boolean; + crossingOpen: boolean; } export interface RidgeVisualProvider { @@ -28,16 +30,20 @@ export interface RidgeVisualProvider { export function toRidgeVisualViewModel( observation: RidgeObservation ): RidgeVisualViewModel { + const crossingOpen = + observation.beat === 'bridge_complete' || + observation.beat === 'concert_cleared' || + observation.beat === 'dance_cleared'; + return { mode: observation.mode, + areaId: observation.areaId, progress: observation.progress, facing: observation.facing, beat: observation.beat, ambience: observation.ambience, nearbyPrompt: observation.nearby[0]?.prompt ?? null, actors: observation.actors, - bridgeOpen: - observation.beat === 'bridge_complete' || - observation.beat === 'concert_handoff' + crossingOpen }; } diff --git a/src/game/scenes/ridge/content/bridgeCatalog.ts b/src/game/scenes/ridge/content/bridgeCatalog.ts deleted file mode 100644 index e8f4f19..0000000 --- a/src/game/scenes/ridge/content/bridgeCatalog.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BridgeDialogueCatalog } from '@/game/core/ridge'; -import { getMessages } from '@/shared/i18n'; - -/** Wires i18n copy into the pure Bridge stage catalog. */ -export function loadBridgeDialogueCatalog(): BridgeDialogueCatalog { - const bridge = getMessages().scenes.ridge.bridge; - return { - speakers: { - prompt: bridge.speakers.prompt, - cicka: bridge.speakers.cicka, - bridgeDraftsperson: bridge.speakers.bridgeDraftsperson - }, - lines: { ...bridge.dialogue } - }; -} diff --git a/src/game/scenes/ridge/content/routeCatalog.ts b/src/game/scenes/ridge/content/routeCatalog.ts new file mode 100644 index 0000000..bb044b4 --- /dev/null +++ b/src/game/scenes/ridge/content/routeCatalog.ts @@ -0,0 +1,10 @@ +import { + loadRouteCatalogFromMessages, + type RidgeRouteDialogueCatalog +} from '@/game/core/ridge'; +import { getMessages } from '@/shared/i18n'; + +/** Wires i18n copy into the full first-playable Ridge route catalog. */ +export function loadRidgeRouteDialogueCatalog(): RidgeRouteDialogueCatalog { + return loadRouteCatalogFromMessages(getMessages().scenes.ridge); +} diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index c796c8e..9bb193e 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -2,11 +2,15 @@ import * as Phaser from 'phaser'; import { bridgeActions, bridgeStore, - type OpenOverlayOptions + type OpenOverlayOptions, + type RidgeRouteBeatState } from '@/game/bridge/store'; import { - createBridgeStage, + createRidgeRouteStages, RidgeConsoleSession, + RIDGE_GUITAR_ITEM, + RIDGE_INITIAL_BEAT, + type RidgeAreaId, type RidgeCommandResult, type RidgeSessionEvent } from '@/game/core/ridge'; @@ -23,7 +27,7 @@ import { import { bindSideViewKeyboard } from '@/game/sharedSceneRuntime/input/sceneKeyboard'; import { StickVisualProvider } from '../art/stick/StickVisualProvider'; import { toRidgeVisualViewModel } from '../art/types'; -import { loadBridgeDialogueCatalog } from '../content/bridgeCatalog'; +import { loadRidgeRouteDialogueCatalog } from '../content/routeCatalog'; import type { RidgeConversationPanelView } from '../sceneUi/RidgeConversationPanel'; import type { RidgeDevControls } from './ridgeDevControls'; @@ -50,6 +54,13 @@ export class RidgeScene extends Phaser.Scene { private getRidgeDevControls?: () => RidgeDevControls | undefined; private lastConversationKey: string | null = null; private escJustHandled = false; + private skipKey?: Phaser.Input.Keyboard.Key; + private warpKeys?: { + bridge: Phaser.Input.Keyboard.Key; + concert: Phaser.Input.Keyboard.Key; + dance: Phaser.Input.Keyboard.Key; + relay: Phaser.Input.Keyboard.Key; + }; constructor() { super(PHASER_SCENE_KEYS.ridge); @@ -63,17 +74,31 @@ export class RidgeScene extends Phaser.Scene { create(): void { const route = bridgeStore.getState().progress.ridge.firstPlayableRoute; - const beat = - route.activeAreaId === 'bridge' ? route.bridgeBeat : 'concert_handoff'; + const areaId = route.activeAreaId; + const hasGuitar = + route.beat === 'concert_cleared' || + areaId === 'danceFestival' || + areaId === 'relay'; this.session = new RidgeConsoleSession({ - stage: createBridgeStage(loadBridgeDialogueCatalog()), - beat, - progress: 0.05 + stages: createRidgeRouteStages(loadRidgeRouteDialogueCatalog()), + areaId, + beat: route.beat, + progress: 0.05, + inventory: hasGuitar ? [RIDGE_GUITAR_ITEM] : [] }); this.visuals = new StickVisualProvider(this); this.keys = bindSideViewKeyboard(this.input.keyboard, { includeEscapeKey: true }); + if (import.meta.env.DEV && this.input.keyboard) { + this.skipKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.CLOSED_BRACKET); + this.warpKeys = { + bridge: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), + concert: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), + dance: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), + relay: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR) + }; + } this.cameras.main.setZoom(1); this.syncPresentation(); @@ -85,6 +110,7 @@ export class RidgeScene extends Phaser.Scene { if (!this.session || !this.visuals || !this.keys || this.isPaused) return; this.handleSceneUiActions(); + this.handleDevSkipKeys(); const observation = this.session.observe(); if (observation.mode === 'conversation') { @@ -131,6 +157,37 @@ export class RidgeScene extends Phaser.Scene { this.isPaused = paused; } + private handleDevSkipKeys(): void { + if (!import.meta.env.DEV || !this.session) return; + + if (this.skipKey && Phaser.Input.Keyboard.JustDown(this.skipKey)) { + this.applyResult(this.session.exec('skip')); + this.syncPresentation(); + return; + } + + if (!this.warpKeys) return; + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.bridge)) { + this.applyResult(this.session.exec('warp bridge')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.concert)) { + this.applyResult(this.session.exec('warp concert')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.dance)) { + this.applyResult(this.session.exec('warp dance')); + this.syncPresentation(); + return; + } + if (Phaser.Input.Keyboard.JustDown(this.warpKeys.relay)) { + this.applyResult(this.session.exec('warp relay')); + this.syncPresentation(); + } + } + private handleSceneUiActions(): void { if (!this.session) return; const action = bridgeActions.consumeSceneUiAction(RIDGE_SCENE_ID); @@ -166,11 +223,19 @@ export class RidgeScene extends Phaser.Scene { private handleSessionEvent(event: RidgeSessionEvent): void { if (event.type === 'beat_changed') { - bridgeActions.setRidgeBridgeBeat(event.beat); + bridgeActions.setRidgeRouteBeat(event.beat as RidgeRouteBeatState); return; } - if (event.type === 'concert_handoff') { - bridgeActions.triggerRidgeConcertHandoff(); + if (event.type === 'area_handoff') { + const areaId = event.areaId as RidgeAreaId; + bridgeActions.setRidgeAreaHandoff(areaId, RIDGE_INITIAL_BEAT[areaId] as RidgeRouteBeatState); + return; + } + if (event.type === 'route_reset') { + // v0: dedication ends the CRT story and returns to Overworld. + // Post-game Open Ridge Return can replace this later. + bridgeActions.resetRidgeFirstPlayableRoute(); + this.onClose(); } } @@ -231,6 +296,8 @@ export class RidgeScene extends Phaser.Scene { this.visuals = undefined; this.session = undefined; this.keys = undefined; + this.skipKey = undefined; + this.warpKeys = undefined; this.lastConversationKey = null; } } @@ -239,7 +306,9 @@ function portraitForSpeaker( speakerId: string ): RidgeConversationPanelView['portrait'] { if (speakerId === 'cicka') return 'cicka'; - if (speakerId === 'bridgeDraftsperson') return 'draftsperson'; - if (speakerId === 'prompt') return 'prompt'; + if (speakerId === 'bridgeDraftsperson' || speakerId === 'injuredGuitarist') { + return 'draftsperson'; + } + if (speakerId === 'prompt' || speakerId === 'dedication') return 'prompt'; return 'player'; } diff --git a/src/game/scenes/ridge/runtime/ridgeDevControls.ts b/src/game/scenes/ridge/runtime/ridgeDevControls.ts index 3615c52..63ad22d 100644 --- a/src/game/scenes/ridge/runtime/ridgeDevControls.ts +++ b/src/game/scenes/ridge/runtime/ridgeDevControls.ts @@ -1,9 +1,9 @@ -import type { RidgeBridgeBeatState } from '@/game/bridge/store'; +import type { RidgeRouteBeatState } from '@/game/bridge/store'; /** Lightweight Ridge DEV readouts for the stick/console rebuild. */ export interface RidgeDevPlayerSnapshot { progress: number; - beat: RidgeBridgeBeatState; + beat: RidgeRouteBeatState; mode: 'explore' | 'conversation'; nearby: string[]; } diff --git a/src/shared/i18n/messages/en/scenes.ts b/src/shared/i18n/messages/en/scenes.ts index b80cfc3..ab6c065 100644 --- a/src/shared/i18n/messages/en/scenes.ts +++ b/src/shared/i18n/messages/en/scenes.ts @@ -72,6 +72,146 @@ export const sceneMessages = { }, handoffNote: "evening music ahead", }, + concert: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + injuredGuitarist: "Injured Guitarist", + crowd: "Crowd", + }, + dialogue: { + "concert.crowd.delay_barks.01": "Show's late. Crossing's full of patience wearing thin.", + "concert.crowd.delay_barks.02": "Heard the guitarist wiped out trying to look brave.", + "concert.crowd.delay_barks.03": "Someone's behind the stage props. Maybe start there.", + "concert.guitarist.injury.01": "I tried a one-leg skateboard solo. The street voted no.", + "concert.guitarist.injury.02": "Wrist won't play loud. Pride won't either.", + "concert.guitarist.injury.03": "Learn the phrase with me", + "concert.guitarist.practice_riff.01": "Practice the forgiving riff", + "concert.guitarist.practice_riff.02": "You find the phrase without failing.", + "concert.guitarist.practice_riff.03": "That much courage can clear a street.", + "concert.performance.auto_success.01": "Start the concert", + "concert.performance.auto_success.02": "The phrase lands. Soft. True.", + "concert.performance.auto_success.03": "Alright—show happened. People can move.", + "concert.performance.auto_success.04": "Take the guitar. Carry the comfort.", + "concert.guitarist.guitar_handoff.01": "Keep it for the road ahead.", + "concert.guitarist.guitar_handoff.02": "Play it when quiet needs company.", + "concert.guitarist.guitar_handoff.03": "The guitar rests warm against your side.", + "concert.cicka.band_resting_spot.01": "Sit near hidden Cicka", + "concert.cicka.band_resting_spot.02": "mrrp.", + "concert.cicka.band_resting_spot.03": "Cicka loafs with the band", + "concert.cicka.band_resting_spot.04": "purr.", + "concert.exit.dance_transition.01": "Follow the opened crossing", + "concert.exit.dance_transition.02": "Festival setup waits downhill.", + "concert.exit.dance_transition.03": "The page warms toward afternoon.", + }, + }, + dance: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + traveler: "Traveler", + hillShuttleDriver: "Hill-Shuttle Driver", + operationsHelper: "Last-Stop Operations Helper", + danceTeacher: "Dance Teacher", + festivalSteward: "Festival Steward", + }, + dialogue: { + "dance.traveler.relay_wayfinding.01": "Relay is up the hill shuttle.", + "dance.traveler.relay_wayfinding.02": + "Last daylight ride—only after setup clears.", + "dance.traveler.relay_wayfinding.03": + "Help Operations with lanterns, then the Dance Teacher with one step.", + "dance.driver.shuttle_delay.01": + "Can't leave until the steward opens the gate.", + "dance.driver.shuttle_delay.02": + "Clipboard says ready. My feet disagree.", + "dance.driver.shuttle_delay.03": "He keeps rereading the same safe line.", + "dance.driver.shuttle_delay.choice.help": + "One step. Privately. Maybe the Dance Teacher…", + "dance.driver.shuttle_delay.choice.help_hint": + "Walk left-of-center to the Dance Teacher (skirt pose, raised arm).", + "dance.driver.shuttle_delay.choice.wait": + "You give him space and ask around.", + "dance.operations_helper.handoff_check.01": + "Help check the operations handoff", + "dance.operations_helper.handoff_check.02": + "If the lantern line fails, I fail with it.", + "dance.operations_helper.handoff_check.03": + "You check the crates with her. One clean pass is enough.", + "dance.operations_helper.handoff_check.done.01": + "Handoff done — find the Dance Teacher next", + "dance.operations_helper.handoff_check.done.02": + "She can almost leave the plaza alone now.", + "dance.locals.triangulated_read.01": + "Road opens after setup clears at the gate.", + "dance.locals.triangulated_read.02": + "She waits on one perfect lantern. He waits on anything but asking.", + "dance.locals.triangulated_read.03": + "Help Operations, then ask me for one private step for him.", + "dance.driver.one_step_practice.01": "Practice one private dance step", + "dance.driver.one_step_practice.02": + "No audience. He learns exactly one shared rhythm.", + "dance.driver.one_step_practice.03": + "Okay. I can offer that much later.", + "dance.driver.one_step_practice.done.01": + "Step learned — clear the service gate next", + "dance.driver.one_step_practice.done.02": + "Dignity intact. The gate can open when setup finishes.", + "dance.driver.folded_song_request.01": + "Help fold a tiny song request", + "dance.driver.folded_song_request.02": + "No confession. Just one dance later.", + "dance.driver.folded_song_request.03": + "She reads it. Soft nod. No spotlight.", + "dance.driver.folded_song_request.04": + "Now clear the service gate on the right.", + "dance.setup_clearance.01": "Clear the service gate for the shuttle", + "dance.setup_clearance.02": "Secure the lantern line.", + "dance.setup_clearance.03": "Tape the service lane clear.", + "dance.setup_clearance.04": "Gate open. Last daylight window.", + "dance.setup_clearance.05": + "Shuttle sign flips. Board the van to the right.", + "dance.shuttle.last_daylight_ride.01": "All aboard the last ride.", + "dance.shuttle.last_daylight_ride.02": + "The hill lifts into sunset paper.", + "dance.shuttle.last_daylight_ride.03": "Relay waits quiet above.", + "dance.cicka.resting_spot.01": "Cicka loafs on the operations table", + "dance.cicka.resting_spot.02": "Cicka settles by the cleared gate", + "dance.cicka.resting_spot.03": "mrrp.", + }, + }, + relay: { + speakers: { + prompt: "Prompt", + cicka: "Cicka", + dedication: "Dedication", + }, + dialogue: { + "relay.overlook.inspect.01": "Look out over the finished route", + "relay.overlook.inspect.02": + "The pages below still hold their changes.", + "relay.sit_and_play.prompt.01": "Sit and play beside Cicka", + "relay.sit_and_play.prompt.02": + "You settle. The guitar finds the concert phrase.", + "relay.sit_and_play.prompt.03": "The overlook softens.", + "relay.montage.bridge.01": "(memory) The finished bridge holds.", + "relay.montage.concert.01": + "(memory) The crossing clears; the guitar changes hands.", + "relay.montage.dance.01": + "(memory) Night lanterns wake after you leave.", + "relay.guitar.sunset.01": + "Sunset lowers while the phrase keeps breathing.", + "relay.guitar.let_song_end.01": "Let the song end", + "relay.guitar.let_song_end.02": "The phrase resolves into quiet.", + "relay.cicka.threshold_meow.01": "mrrp.", + "relay.cicka.threshold_meow.02": + "Cicka turns back once, then slips into warm paper light.", + "relay.cicka.threshold_meow.03": + "The overlook holds empty for a breath.", + "relay.dedication.card.01": "For Cicka.", + "relay.dedication.card.02": "Thank you for playing.", + }, + }, }, stampedeSketch: { result: {