diff --git a/src/game/core/ridge/content/bridgeStage.ts b/src/game/core/ridge/content/bridgeStage.ts index 5cc9fee..2fe87eb 100644 --- a/src/game/core/ridge/content/bridgeStage.ts +++ b/src/game/core/ridge/content/bridgeStage.ts @@ -119,7 +119,9 @@ function resolveBridgeInteractables( kind: spot.kind, distance, prompt: catalog.lines[plan.prompt] ?? plan.prompt, - conversationId: plan.conversationId + conversationId: plan.conversationId, + progress: spot.progress, + actorId: 'actorId' in spot ? spot.actorId : undefined }); } @@ -133,12 +135,12 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan { spotId: 'cicka', conversationId: 'bridge.cicka.first_meet', - prompt: 'bridge.cicka.first_meet.01' + prompt: 'bridge.cicka.first_meet.prompt' }, { spotId: 'draftsperson', conversationId: 'bridge.draftsperson.missing_span', - prompt: 'bridge.draftsperson.missing_span.03' + prompt: 'bridge.draftsperson.missing_span.prompt' } ]; case 'needs_toy_car': @@ -146,12 +148,12 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan { spotId: 'cicka', conversationId: 'bridge.cicka.parallel_play', - prompt: 'bridge.cicka.parallel_play.01' + prompt: 'bridge.cicka.parallel_play.prompt' }, { spotId: 'draftsperson', conversationId: 'bridge.draftsperson.missing_span', - prompt: 'bridge.draftsperson.missing_span.03' + prompt: 'bridge.draftsperson.missing_span.prompt' } ]; case 'toy_car_shared': @@ -159,7 +161,7 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan { spotId: 'draftsperson', conversationId: 'bridge.draftsperson.toy_car_test', - prompt: 'bridge.draftsperson.toy_car_test.01' + prompt: 'bridge.draftsperson.toy_car_test.prompt' } ]; case 'bridge_complete': @@ -167,7 +169,7 @@ function interactPlansForBeat(beat: RidgeWorldState['beat']): BridgeInteractPlan { spotId: 'concert-exit', conversationId: 'bridge.exit.opened_crossing', - prompt: 'bridge.exit.opened_crossing.01' + prompt: 'bridge.exit.opened_crossing.prompt' } ]; default: diff --git a/src/game/core/ridge/content/concertStage.ts b/src/game/core/ridge/content/concertStage.ts index 6a4d179..15644e3 100644 --- a/src/game/core/ridge/content/concertStage.ts +++ b/src/game/core/ridge/content/concertStage.ts @@ -104,7 +104,7 @@ function resolveConcertInteractables( plans.push({ spotId: 'crowd', conversationId: 'concert.crowd.delay_barks', - prompt: 'concert.crowd.delay_barks.01' + prompt: 'concert.crowd.delay_barks.prompt' }); plans.push({ spotId: 'guitarist', @@ -116,21 +116,21 @@ function resolveConcertInteractables( : 'concert.guitarist.injury', prompt: state.beat === 'concert_practiced' - ? 'concert.performance.auto_success.01' + ? 'concert.performance.auto_success.prompt' : state.flags.has('met_guitarist') - ? 'concert.guitarist.practice_riff.01' - : 'concert.guitarist.injury.01' + ? 'concert.guitarist.practice_riff.prompt' + : 'concert.guitarist.injury.prompt' }); plans.push({ spotId: 'cicka-nook', conversationId: 'concert.cicka.band_resting_spot', - prompt: 'concert.cicka.band_resting_spot.01' + prompt: 'concert.cicka.band_resting_spot.prompt' }); if (state.beat === 'concert_practiced') { plans.push({ spotId: 'stage', conversationId: 'concert.performance.auto_success', - prompt: 'concert.performance.auto_success.01' + prompt: 'concert.performance.auto_success.prompt' }); } } @@ -139,17 +139,17 @@ function resolveConcertInteractables( plans.push({ spotId: 'guitarist', conversationId: 'concert.guitarist.guitar_handoff', - prompt: 'concert.guitarist.guitar_handoff.01' + prompt: 'concert.guitarist.guitar_handoff.prompt' }); plans.push({ spotId: 'cicka-nook', conversationId: 'concert.cicka.band_resting_spot', - prompt: 'concert.cicka.band_resting_spot.03' + prompt: 'concert.cicka.band_resting_spot.prompt' }); plans.push({ spotId: 'dance-exit', conversationId: 'concert.exit.dance_transition', - prompt: 'concert.exit.dance_transition.01' + prompt: 'concert.exit.dance_transition.prompt' }); } diff --git a/src/game/core/ridge/content/danceStage.ts b/src/game/core/ridge/content/danceStage.ts index 47ac2f9..5b73a59 100644 --- a/src/game/core/ridge/content/danceStage.ts +++ b/src/game/core/ridge/content/danceStage.ts @@ -157,26 +157,22 @@ function resolveDanceInteractables( plans.push({ spotId: 'traveler', conversationId: 'dance.traveler.relay_wayfinding', - prompt: 'dance.traveler.relay_wayfinding.01' + prompt: 'dance.traveler.relay_wayfinding.prompt' }); plans.push({ spotId: 'steward', conversationId: 'dance.locals.triangulated_read', - prompt: 'dance.locals.triangulated_read.01' + prompt: 'dance.locals.triangulated_read.prompt' }); 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' + prompt: 'dance.driver.one_step_practice.prompt' }); 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' + prompt: 'dance.operations_helper.handoff_check.prompt' }); plans.push({ spotId: 'driver', @@ -184,13 +180,13 @@ function resolveDanceInteractables( ? 'dance.driver.folded_song_request' : 'dance.driver.shuttle_delay', prompt: bothReady(state) - ? 'dance.driver.folded_song_request.01' - : 'dance.driver.shuttle_delay.01' + ? 'dance.driver.folded_song_request.prompt' + : 'dance.driver.shuttle_delay.prompt' }); plans.push({ spotId: 'cicka', conversationId: 'dance.cicka.resting_spot', - prompt: 'dance.cicka.resting_spot.01' + prompt: 'dance.cicka.resting_spot.prompt' }); } @@ -198,7 +194,7 @@ function resolveDanceInteractables( plans.push({ spotId: 'gate', conversationId: 'dance.setup_clearance', - prompt: 'dance.setup_clearance.01' + prompt: 'dance.setup_clearance.prompt' }); } @@ -206,7 +202,7 @@ function resolveDanceInteractables( plans.push({ spotId: 'shuttle', conversationId: 'dance.shuttle.last_daylight_ride', - prompt: 'dance.shuttle.last_daylight_ride.01' + prompt: 'dance.shuttle.last_daylight_ride.prompt' }); plans.push({ spotId: 'cicka', diff --git a/src/game/core/ridge/content/dialogueHelpers.ts b/src/game/core/ridge/content/dialogueHelpers.ts index f030095..eb0901c 100644 --- a/src/game/core/ridge/content/dialogueHelpers.ts +++ b/src/game/core/ridge/content/dialogueHelpers.ts @@ -31,7 +31,9 @@ export function collectNearbyFromPlans( kind: spot.kind, distance, prompt: resolvePrompt(plan.prompt), - conversationId: plan.conversationId + conversationId: plan.conversationId, + progress: spot.progress, + actorId: spot.actorId }); } return result; diff --git a/src/game/core/ridge/content/relayStage.ts b/src/game/core/ridge/content/relayStage.ts index d7dea45..c337611 100644 --- a/src/game/core/ridge/content/relayStage.ts +++ b/src/game/core/ridge/content/relayStage.ts @@ -79,17 +79,17 @@ function resolveRelayInteractables( { spotId: 'arrival', conversationId: 'relay.overlook.inspect', - prompt: 'relay.overlook.inspect.01' + prompt: 'relay.overlook.inspect.prompt' }, { spotId: 'cicka', conversationId: 'relay.sit_and_play.prompt', - prompt: 'relay.sit_and_play.prompt.01' + prompt: 'relay.sit_and_play.prompt.prompt' }, { spotId: 'sit', conversationId: 'relay.sit_and_play.prompt', - prompt: 'relay.sit_and_play.prompt.01' + prompt: 'relay.sit_and_play.prompt.prompt' } ]; diff --git a/src/game/core/ridge/content/testBridgeCatalog.ts b/src/game/core/ridge/content/testBridgeCatalog.ts index 9783649..5b18863 100644 --- a/src/game/core/ridge/content/testBridgeCatalog.ts +++ b/src/game/core/ridge/content/testBridgeCatalog.ts @@ -8,20 +8,25 @@ export const TEST_BRIDGE_DIALOGUE_CATALOG: BridgeDialogueCatalog = { bridgeDraftsperson: 'Bridge Draftsperson' }, lines: { - 'bridge.cicka.first_meet.01': 'Sit near Cicka', + 'bridge.cicka.first_meet.prompt': 'Pet Cicka', + 'bridge.cicka.first_meet.01': 'You sit near Cicka resting in the cornfield.', 'bridge.cicka.first_meet.02': 'Small chirp.', 'bridge.cicka.first_meet.03': 'Cicka bats the tiny car back into place.', + 'bridge.draftsperson.missing_span.prompt': 'Talk to Draftsperson', 'bridge.draftsperson.missing_span.01': 'Missing span worry.', 'bridge.draftsperson.missing_span.02': 'Toy car missing.', 'bridge.draftsperson.missing_span.03': 'Look for the tiny test car', + 'bridge.cicka.parallel_play.prompt': 'Play with Cicka', 'bridge.cicka.parallel_play.01': 'Sit with Cicka', 'bridge.cicka.parallel_play.02': 'Roll the car back gently', 'bridge.cicka.parallel_play.03': 'Quiet purr.', 'bridge.cicka.parallel_play.04': 'Cicka leaves the tiny car beside you.', + 'bridge.draftsperson.toy_car_test.prompt': 'Test Blueprint', 'bridge.draftsperson.toy_car_test.01': 'Set the tiny car on the drawing', 'bridge.draftsperson.toy_car_test.02': 'Courage line.', 'bridge.draftsperson.toy_car_test.03': 'The toy car rolls across the new span.', 'bridge.draftsperson.toy_car_test.04': 'That line holds.', + 'bridge.exit.opened_crossing.prompt': 'Cross Bridge', 'bridge.exit.opened_crossing.01': 'Cross the finished bridge', 'bridge.exit.opened_crossing.02': 'Thank you.', 'bridge.exit.opened_crossing.03': 'The page turns toward evening music.' diff --git a/src/game/core/ridge/content/testRouteCatalog.ts b/src/game/core/ridge/content/testRouteCatalog.ts index be1a75e..59edfaf 100644 --- a/src/game/core/ridge/content/testRouteCatalog.ts +++ b/src/game/core/ridge/content/testRouteCatalog.ts @@ -12,26 +12,33 @@ export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { crowd: 'Crowd' }, lines: { + 'concert.crowd.delay_barks.prompt': 'Listen to Crowd', '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.prompt': 'Talk to Guitarist', '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.prompt': 'Practice Riff', '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.prompt': 'Play Concert', '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.prompt': 'Take Guitar', '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.prompt': 'Pet Cicka', '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.prompt': 'Head Downhill', '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.' @@ -48,40 +55,49 @@ export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { festivalSteward: 'Festival Steward' }, lines: { + 'dance.traveler.relay_wayfinding.prompt': 'Talk to Traveler', '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.prompt': 'Talk to Driver', '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.prompt': 'Help Operations', '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.prompt': 'Check Steward', '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.prompt': 'Practice Dance 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.prompt': 'Hand Song Request', '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.prompt': 'Open Service Gate', '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.prompt': 'Board Shuttle', '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.prompt': 'Pet Cicka', '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.' @@ -94,8 +110,10 @@ export const TEST_ROUTE_DIALOGUE_CATALOG: RidgeRouteDialogueCatalog = { dedication: 'Dedication' }, lines: { + 'relay.overlook.inspect.prompt': 'Inspect Overlook', '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.prompt': 'Sit & Play Guitar', '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.', diff --git a/src/game/core/ridge/observe.ts b/src/game/core/ridge/observe.ts index e3e5c61..5efa142 100644 --- a/src/game/core/ridge/observe.ts +++ b/src/game/core/ridge/observe.ts @@ -30,6 +30,7 @@ export function observeRidgeWorld( speaker: line.speaker, speakerId: line.speakerId, text: line.text, + emotion: line.emotion, lineId: line.id, lineIndex: state.conversation.lineIndex, lineCount: state.conversation.lines.length, diff --git a/src/game/core/ridge/session.ts b/src/game/core/ridge/session.ts index bc344ce..3e3a155 100644 --- a/src/game/core/ridge/session.ts +++ b/src/game/core/ridge/session.ts @@ -20,17 +20,10 @@ import type { RidgeStageRegistry, RidgeWorldState } from './types'; -import { RIDGE_GUITAR_ITEM, RIDGE_INITIAL_BEAT } from './types'; +import { RIDGE_GUITAR_ITEM, RIDGE_INITIAL_BEAT, ridgeNextArea } from './types'; const DEFAULT_STEP = 0.05; -const NEXT_AREA: Record = { - bridge: 'concert', - concert: 'danceFestival', - danceFestival: 'relay', - relay: null -}; - export interface RidgeSessionOptions { /** Preferred: full multi-area registry for Compact Area Transitions. */ stages?: RidgeStageRegistry; @@ -247,7 +240,7 @@ export class RidgeConsoleSession { } private handleSkip(): RidgeCommandResult { - const next = NEXT_AREA[this.state.areaId]; + const next = ridgeNextArea(this.state.areaId); if (!next) { return this.fail('Already at Relay. Use: warp bridge|concert|dance|relay'); } diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index 5666a4a..2a68c29 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -53,11 +53,21 @@ export type RidgeActorId = | 'counterpart-cat' | 'shuttle'; +export type RidgeEmotion = + | 'neutral' + | 'curious' + | 'playful' + | 'determined' + | 'thoughtful' + | 'surprised' + | 'sleepy'; + export interface RidgeDialogueLine { id: string; speakerId: string; speaker: string; text: string; + emotion?: RidgeEmotion; } export interface RidgeDialogueChoice { @@ -115,6 +125,10 @@ export interface RidgeInteractable { distance: number; prompt: string; conversationId: string; + /** Stage position of the spot, so presentation can anchor a prompt to it. */ + progress: number; + /** Set when the spot is embodied by an actor rather than scenery. */ + actorId?: RidgeActorId; } export interface RidgeActorPresence { @@ -177,6 +191,7 @@ export interface RidgeObservation { speaker: string; speakerId: string; text: string; + emotion?: RidgeEmotion; lineId: string; lineIndex: number; lineCount: number; @@ -229,5 +244,24 @@ export const RIDGE_INITIAL_BEAT: Record = { relay: 'relay_linger' }; +/** Left-to-right Compact Ridge Area order for the First Playable Route. */ +const RIDGE_AREA_ORDER = ['bridge', 'concert', 'danceFestival', 'relay'] as const; + +/** Next area on the route, or null at Relay. */ +export function ridgeNextArea(areaId: RidgeAreaId): RidgeAreaId | null { + const index = RIDGE_AREA_ORDER.indexOf(areaId); + if (index < 0 || index >= RIDGE_AREA_ORDER.length - 1) return null; + return RIDGE_AREA_ORDER[index + 1]!; +} + +/** + * Previous area for DEV reverse-warp. Wraps Bridge → Relay so `[` / `]` cycle. + */ +export function ridgePrevArea(areaId: RidgeAreaId): RidgeAreaId { + const index = RIDGE_AREA_ORDER.indexOf(areaId); + if (index <= 0) return RIDGE_AREA_ORDER[RIDGE_AREA_ORDER.length - 1]!; + return RIDGE_AREA_ORDER[index - 1]!; +} + export const RIDGE_GUITAR_ITEM = 'guitar'; export const RIDGE_TOY_CAR_ITEM = 'toy-car'; diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index 85c71e9..8500234 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -1,75 +1,108 @@ -// 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 { RidgeActorId, RidgeFacing } from '@/game/core/ridge'; import type { RidgeVisualProvider, RidgeVisualViewModel } from '../types'; +import { ActorSpritePool, headTopFor, type ActorRenderRequest } from './actorSprites'; +import { drawAmbientFar, drawAmbientNear } from './ambientLayer'; +import { drawRidgeAreaLayer, type SceneryLayer } from './areaSets'; +import { drawCrtAtmosphere, prefersReducedMotion, sketchTick } from './atmosphere'; +import { BarkDirector, type BarkLines } from './barkDirector'; +import { PresenceLayer, type ActiveBark, type PlacedPresence } from './presenceLayer'; import { - drawStickCicka, - drawStickCrowd, - drawStickDanceTeacher, - drawStickDraftsperson, - drawStickDriver, - drawStickGuitar, - drawStickGuitarist, - drawStickOperationsHelper, - drawStickPlayer, - drawStickShuttle, - drawStickSteward, - drawStickToyCar, - drawStickTraveler -} from './stickFigures'; - -const PAPER = 0xfbfbf9; -const INK = 0x1a1a1a; -const FAINT = 0x4b4337; - -const STAGE_WIDTH = 1600; -const STAGE_HEIGHT = 720; -const GROUND_Y = 520; + DEPTH, + GROUND_Y, + LAYERS, + PAPER, + PRESENCE_FAR, + PRESENCE_NEAR, + STAGE_HEIGHT, + STAGE_WIDTH, + VIEW_ABOVE_GROUND, + VIEW_BELOW_GROUND, + VIEW_HEIGHT +} from './palette'; + +const CRT_TEXTURE_KEY = 'ridge-stick-crt'; +const SCENERY_TEXTURE_KEY: Record = { + far: 'ridge-stick-far', + near: 'ridge-stick-near', + fore: 'ridge-stick-fore' +}; +const SCENERY_LAYERS: readonly SceneryLayer[] = ['far', 'near', 'fore']; + +/** + * Zoom is driven by height, not width: it keeps the ground line, the figures, + * and the sky in the same proportion on any screen, and lets wide displays see + * more of the stage instead of larger characters. + */ +const MIN_ZOOM = 0.9; +const MAX_ZOOM = 2.4; +/** Milliseconds of grace after the last movement before the walk cycle stops. */ +const WALK_RELEASE_MS = 150; +/** How far an NPC will turn to acknowledge the player, in stage progress. */ +const AWARENESS_RANGE = 0.13; + +/** Clearance above a head for a nameplate, so bubbles stack above it. */ +const PLATE_HEIGHT = 60; export interface StickVisualProviderOptions { stageWidth?: number; stageHeight?: number; + /** Role tags shown under each resident's name. */ + roles?: Partial>; + /** Ambient lines residents mutter as the player walks past. */ + barks?: BarkLines; } /** - * Mathematical stick-figure presentation for Ridge. - * Swap this class for a sprite-backed provider later without touching core. + * Stick-figure Ridge presentation. + * + * Performance rules: + * - Scenery bakes into one DynamicTexture per parallax band (1 quad each). + * Never use Graphics#generateTexture (Canvas + willReadFrequently) for stages. + * - Figures redraw only when their stepped pose changes; following them is a + * transform update. + * - Ambient drift redraws on the ~11 FPS sketch clock, not per frame. + * - Text objects update only when their content actually changes. */ export class StickVisualProvider implements RidgeVisualProvider { private readonly scene: Phaser.Scene; private readonly stageWidth: number; private readonly stageHeight: number; - private readonly root: Phaser.GameObjects.Container; - private readonly background: Phaser.GameObjects.Graphics; - private readonly actors: Phaser.GameObjects.Graphics; - private readonly overlay: Phaser.GameObjects.Graphics; - private promptText?: Phaser.GameObjects.Text; - private readonly nameLabels = new Map(); + private readonly roles: Partial>; + private readonly actorPool: ActorSpritePool; + private readonly presence: PresenceLayer; + private readonly barkDirector: BarkDirector; + private readonly sceneryImages = new Map(); + private readonly ambientFar: Phaser.GameObjects.Graphics; + private readonly ambientNear: Phaser.GameObjects.Graphics; + private crtImage?: Phaser.GameObjects.Image; + private lastSceneryKey = ''; + private lastViewportKey = ''; + private lastAmbientKey = ''; + private lastPlayerProgress = Number.NaN; + private lastMovedAt = -Infinity; private destroyed = false; + /** Reused each sync to avoid per-frame array churn on the hot path. */ + private readonly actorRequests: ActorRenderRequest[] = []; + private readonly presencePlates: PlacedPresence[] = []; + private readonly barkCandidates: RidgeActorId[] = []; + private readonly activeBarks: ActiveBark[] = []; constructor(scene: Phaser.Scene, options: StickVisualProviderOptions = {}) { this.scene = scene; this.stageWidth = options.stageWidth ?? STAGE_WIDTH; this.stageHeight = options.stageHeight ?? STAGE_HEIGHT; + this.roles = options.roles ?? {}; - this.background = scene.add.graphics(); - this.actors = scene.add.graphics(); - this.overlay = scene.add.graphics(); - this.root = scene.add.container(0, 0, [this.background, this.actors, this.overlay]); - this.root.setDepth(10); - - this.promptText = scene.add - .text(0, 0, '', { - fontFamily: 'Caveat, Comic Neue, cursive', - fontSize: '22px', - color: '#1a1a1a', - backgroundColor: '#fbfbf9cc', - padding: { x: 10, y: 6 } - }) - .setOrigin(0.5, 1) - .setDepth(40) - .setVisible(false); + this.ambientFar = scene.add + .graphics() + .setDepth(DEPTH.ambientFar) + .setScrollFactor(LAYERS.far.scrollFactor, 1); + this.ambientNear = scene.add.graphics().setDepth(DEPTH.ambientNear); + + this.actorPool = new ActorSpritePool(scene); + this.presence = new PresenceLayer(scene); + this.barkDirector = new BarkDirector(options.barks ?? {}); scene.cameras.main.setBounds(0, 0, this.stageWidth, this.stageHeight); scene.cameras.main.setBackgroundColor(PAPER); @@ -82,270 +115,339 @@ export class StickVisualProvider implements RidgeVisualProvider { sync(view: RidgeVisualViewModel): void { if (this.destroyed) return; - 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) { - case 'player': - drawStickPlayer(this.actors, x, y, actor.facing, 1.15); - break; - case 'cicka': - 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); - break; - 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); - } - } + const now = this.scene.time.now; + const motion = !prefersReducedMotion(); + const stepTick = sketchTick(now); + // Decorative motion freezes under reduced-motion; the walk cycle does not, + // because it is feedback for something the player is actively doing. + const tick = motion ? stepTick : 0; - for (const [id, label] of this.nameLabels) { - if (!visibleIds.has(id)) label.setVisible(false); - } + this.syncViewport(); + this.syncScenery(view); + this.syncAmbient(view, tick); 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 - 130) - .setVisible(true); - } else { - this.promptText.setVisible(false); - } - this.scene.cameras.main.centerOn( - this.worldXForProgress(player.progress), - GROUND_Y - 80 - ); - } + const playerProgress = player?.progress ?? view.progress; + const walking = this.trackWalking(playerProgress, now) && view.mode === 'explore'; + + this.syncActors(view, playerProgress, tick, stepTick, motion, walking); + this.syncPresence(view, playerProgress, now, tick, motion); + this.followCamera(view, playerProgress); } destroy(): void { this.destroyed = true; - this.root.destroy(true); - this.promptText?.destroy(); - this.promptText = undefined; - for (const label of this.nameLabels.values()) label.destroy(); - this.nameLabels.clear(); - } + this.actorPool.destroy(); + this.presence.destroy(); + this.ambientFar.destroy(); + this.ambientNear.destroy(); + this.crtImage?.destroy(); + this.crtImage = undefined; + + for (const image of this.sceneryImages.values()) image.destroy(); + this.sceneryImages.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); + for (const key of Object.values(SCENERY_TEXTURE_KEY)) { + if (this.scene.textures.exists(key)) this.scene.textures.remove(key); + } + if (this.scene.textures.exists(CRT_TEXTURE_KEY)) { + this.scene.textures.remove(CRT_TEXTURE_KEY); } - label.setText(text).setPosition(x, y - 78).setVisible(true); } - private drawBackground( - areaId: RidgeAreaId, - crossingOpen: boolean, - beat: RidgeVisualViewModel['beat'] - ): void { - const g = this.background; - g.clear(); - g.fillStyle(PAPER, 1); - g.fillRect(0, 0, this.stageWidth, this.stageHeight); - - g.lineStyle(1, FAINT, 0.08); - for (let y = 40; y < this.stageHeight; y += 36) { - g.lineBetween(0, y, this.stageWidth, y); - } + /** Zoom so figures stay a readable size on any canvas, and rebake the CRT. */ + private syncViewport(): void { + const cam = this.scene.cameras.main; + const key = `${Math.round(cam.width)}x${Math.round(cam.height)}`; + if (key === this.lastViewportKey) return; + this.lastViewportKey = key; - 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); - } + cam.setZoom(clamp(cam.height / VIEW_HEIGHT, MIN_ZOOM, MAX_ZOOM)); + this.bakeCrtOverlay(Math.round(cam.width), Math.round(cam.height)); } - 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); - } - g.lineStyle(2, INK, 0.35); - g.beginPath(); - g.moveTo(0, 280); - g.lineTo(220, 210); - g.lineTo(480, 260); - g.lineTo(760, 190); - g.lineTo(1100, 250); - g.lineTo(1400, 200); - g.lineTo(this.stageWidth, 240); - g.strokePath(); - - 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); + private syncScenery(view: RidgeVisualViewModel): void { + // Only Relay redresses per beat; elsewhere the crossing state covers it. + const key = + view.areaId === 'relay' + ? `${view.areaId}|${view.beat}` + : `${view.areaId}|${view.crossingOpen}`; + if (key === this.lastSceneryKey) return; + this.lastSceneryKey = key; + + for (const layer of SCENERY_LAYERS) { + this.bakeSceneryLayer(layer, view); } + } + + private bakeSceneryLayer(layer: SceneryLayer, view: RidgeVisualViewModel): void { + const spec = LAYERS[layer]; + const g = this.scene.make.graphics({ x: 0, y: 0 }); + drawRidgeAreaLayer(g, layer, view.areaId, view.crossingOpen, view.beat, { + worldXForProgress: (progress) => this.worldXForProgress(progress) + }); - const riverLeft = this.worldXForProgress(0.58); - const riverRight = this.worldXForProgress(0.78); - g.lineStyle(2, INK, 0.45); - for (let y = GROUND_Y + 8; y < GROUND_Y + 70; y += 10) { - g.beginPath(); - g.moveTo(riverLeft, y); - for (let x = riverLeft; x <= riverRight; x += 24) { - g.lineTo(x + 12, y + ((x / 24) % 2 === 0 ? 3 : -3)); + const key = SCENERY_TEXTURE_KEY[layer]; + try { + const texture = replaceDynamicTexture(this.scene, key, spec.width, spec.height); + texture.draw(g); + texture.render(); + + let image = this.sceneryImages.get(layer); + if (image) { + image.setTexture(key); + } else { + image = this.scene.add + .image(0, spec.top, key) + .setOrigin(0, 0) + .setDepth(spec.depth) + // Parallax on X only: the camera barely pans vertically, and a + // vertical factor would slide the horizon out of its own band. + .setScrollFactor(spec.scrollFactor, 1); + this.sceneryImages.set(layer, image); } - g.strokePath(); + image.setVisible(true); + } finally { + g.destroy(); } + } + + private bakeCrtOverlay(width: number, height: number): void { + const w = Math.max(1, width); + const h = Math.max(1, height); + const g = this.scene.make.graphics({ x: 0, y: 0 }); + drawCrtAtmosphere(g, w, h); + + const texture = replaceDynamicTexture(this.scene, CRT_TEXTURE_KEY, w, h); + texture.draw(g); + texture.render(); + g.destroy(); - g.lineStyle(4, INK, 1); - if (bridgeOpen) { - g.lineBetween(riverLeft, GROUND_Y - 4, riverRight, GROUND_Y - 4); - } else { - 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); - g.strokeRect(mid - 42, GROUND_Y - 90, 84, 50); + if (this.crtImage) { + this.crtImage.setTexture(CRT_TEXTURE_KEY).setDisplaySize(w, h); + return; } + this.crtImage = this.scene.add + .image(0, 0, CRT_TEXTURE_KEY) + .setOrigin(0, 0) + .setScrollFactor(0) + .setDepth(DEPTH.crt) + .setDisplaySize(w, h); + } - g.lineStyle(2, INK, 0.5); - g.strokeCircle(140, 110, 28); + private syncAmbient(view: RidgeVisualViewModel, tick: number): void { + const key = `${view.areaId}|${tick}`; + if (key === this.lastAmbientKey) return; + this.lastAmbientKey = key; + drawAmbientFar(this.ambientFar, view.areaId, tick); + drawAmbientNear(this.ambientNear, view.areaId, tick); } - 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); + /** True while the player is actively walking, with a short release. */ + private trackWalking(progress: number, now: number): boolean { + if (Number.isNaN(this.lastPlayerProgress)) { + this.lastPlayerProgress = progress; + return false; } - // 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); + if (Math.abs(progress - this.lastPlayerProgress) > 0.0001) { + this.lastPlayerProgress = progress; + this.lastMovedAt = now; } - // 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); + return now - this.lastMovedAt < WALK_RELEASE_MS; + } + + private syncActors( + view: RidgeVisualViewModel, + playerProgress: number, + tick: number, + stepTick: number, + motion: boolean, + walking: boolean + ): void { + const requests = this.actorRequests; + requests.length = 0; + const playerX = this.worldXForProgress(playerProgress); + + view.actors.forEach((actor, index) => { + if (!actor.visible) return; + + const x = this.worldXForProgress(actor.progress); + const isPlayer = actor.id === 'player'; + const isWalking = isPlayer && walking; + const isTalking = view.speakingActorId === actor.id; + + // Residents turn to acknowledge you, the way a street does. + const aware = !isPlayer && Math.abs(actor.progress - playerProgress) < AWARENESS_RANGE; + const towardPlayer: RidgeFacing = playerX >= x ? 'right' : 'left'; + + // Idle breath is a transform, so it never touches the command buffer. + const breath = + motion && !isWalking ? Math.round(Math.sin((tick + index * 3) * 0.42) * 1.4) : 0; + + requests.push({ + id: actor.id, + x, + y: GROUND_Y, + facing: aware ? towardPlayer : actor.facing, + bob: breath, + pose: { + frame: (isWalking ? stepTick : tick) + index, + walking: isWalking, + talking: isTalking + } + }); + }); + + this.actorPool.sync(requests); + } + + private syncPresence( + view: RidgeVisualViewModel, + playerProgress: number, + now: number, + tick: number, + motion: boolean + ): void { + const inConversation = view.mode === 'conversation'; + this.syncNameplates(view, playerProgress, inConversation); + + if (inConversation) { + this.barkDirector.interrupt(now); + this.presence.syncBarks([]); + this.presence.syncFocus(null, 0); + return; } - // moon - g.lineStyle(2, INK, 0.55); - g.strokeCircle(this.stageWidth - 160, 100, 26); + + this.syncAmbientBarks(view, now); + this.syncInteractPip(view, tick, motion); } - 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); + /** Nearby residents get a nameplate; the focused one is reserved for the pip. */ + private syncNameplates( + view: RidgeVisualViewModel, + playerProgress: number, + inConversation: boolean + ): void { + const plates = this.presencePlates; + const barkCandidates = this.barkCandidates; + plates.length = 0; + barkCandidates.length = 0; + const focusActorId = view.focus?.actorId; + + for (const actor of view.actors) { + if (!actor.visible || !showsPresenceChrome(actor.id)) continue; + + const alpha = nameplateAlpha(actor.progress, playerProgress, inConversation); + if (alpha <= 0.02) continue; + + plates.push({ + id: actor.id, + name: actor.label, + role: this.roles[actor.id] ?? '', + x: this.worldXForProgress(actor.progress), + y: GROUND_Y + headTopFor(actor.id), + alpha + }); + + if (actor.id !== focusActorId) barkCandidates.push(actor.id); } - // lantern posts - g.lineStyle(2, INK, 0.8); - for (let i = 0; i < 8; i += 1) { - const x = 160 + i * 170; - g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); - g.strokeRect(x - 8, GROUND_Y - 110, 16, 20); + + this.presence.syncNameplates(plates); + } + + private syncAmbientBarks(view: RidgeVisualViewModel, now: number): void { + const bark = this.barkDirector.update(now, this.barkCandidates); + const barks = this.activeBarks; + barks.length = 0; + if (bark) { + const speaker = view.actors.find((actor) => actor.id === bark.actorId); + if (speaker) { + barks.push({ + id: bark.actorId, + text: bark.text, + x: this.worldXForProgress(speaker.progress), + y: GROUND_Y + headTopFor(speaker.id) - PLATE_HEIGHT, + alpha: bark.alpha + }); + } } - // 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); + this.presence.syncBarks(barks); + } + + private syncInteractPip(view: RidgeVisualViewModel, tick: number, motion: boolean): void { + const focus = view.focus; + if (!focus) { + this.presence.syncFocus(null, 0); + return; } - // dance floor edge near teacher - g.lineStyle(2, INK, 0.35); - g.strokeEllipse(this.worldXForProgress(0.4), GROUND_Y - 20, 100, 28); + + const anchor = focus.actorId + ? view.actors.find((actor) => actor.id === focus.actorId && actor.visible) + : undefined; + const x = this.worldXForProgress(anchor ? anchor.progress : focus.progress); + const y = anchor + ? GROUND_Y + headTopFor(anchor.id) - PLATE_HEIGHT + : GROUND_Y - 96; + const bob = motion ? (tick % 6 < 3 ? 0 : -3) : 0; + + this.presence.syncFocus({ key: focus.spotId + focus.prompt, label: focus.prompt, x, y }, bob); } - 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); + /** Frame the player, and widen to hold both speakers during a conversation. */ + private followCamera(view: RidgeVisualViewModel, playerProgress: number): void { + const playerX = this.worldXForProgress(playerProgress); + let centerX = playerX; + + if (view.mode === 'conversation' && view.speakingActorId) { + const speaker = view.actors.find((actor) => actor.id === view.speakingActorId); + if (speaker?.visible) { + centerX = (playerX + this.worldXForProgress(speaker.progress)) / 2; + } } - // 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 + + this.scene.cameras.main.centerOn( + centerX, + GROUND_Y - (VIEW_ABOVE_GROUND - VIEW_BELOW_GROUND) / 2 ); - // 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); } } + +/** Props and the player never wear nameplates or mutter ambient lines. */ +function showsPresenceChrome(id: RidgeActorId): boolean { + return id !== 'player' && id !== 'toy-car' && id !== 'guitar'; +} + +function nameplateAlpha( + progress: number, + playerProgress: number, + inConversation: boolean +): number { + if (inConversation) return 0; + return presenceAlpha(Math.abs(progress - playerProgress)); +} + +function presenceAlpha(distance: number): number { + if (distance <= PRESENCE_NEAR) return 1; + if (distance >= PRESENCE_FAR) return 0; + return 1 - (distance - PRESENCE_NEAR) / (PRESENCE_FAR - PRESENCE_NEAR); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function replaceDynamicTexture( + scene: Phaser.Scene, + key: string, + width: number, + height: number +): Phaser.Textures.DynamicTexture { + if (scene.textures.exists(key)) { + scene.textures.remove(key); + } + const created = scene.textures.addDynamicTexture(key, width, height); + if (!created) { + throw new Error(`Failed to create DynamicTexture "${key}"`); + } + return created; +} diff --git a/src/game/scenes/ridge/art/stick/actorSprites.ts b/src/game/scenes/ridge/art/stick/actorSprites.ts new file mode 100644 index 0000000..80e6fc5 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/actorSprites.ts @@ -0,0 +1,186 @@ +import type * as Phaser from 'phaser'; +import type { RidgeActorId, RidgeFacing } from '@/game/core/ridge'; +import { DEPTH } from './palette'; +import { + drawStickCicka, + drawStickCrowd, + drawStickDanceTeacher, + drawStickDraftsperson, + drawStickDriver, + drawStickGuitar, + drawStickGuitarist, + drawStickOperationsHelper, + drawStickPlayer, + drawStickShuttle, + drawStickSteward, + drawStickToyCar, + drawStickTraveler, + type StickPose +} from './stickFigures'; + +/** + * Global figure size. The original cast was drawn tiny against a 1600x720 + * stage; scaling every figure through one multiplier keeps their internal + * proportions and ink weights intact. + */ +const FIGURE_SCALE = 1.6; + +/** + * Distance above the ground line where a figure's silhouette ends, used to + * hang nameplates and bubbles clear of the art. + * + * Stored unscaled and multiplied by {@link FIGURE_SCALE} on read, so retuning + * figure size cannot silently drop chrome onto somebody's head. + */ +const HEAD_TOP: Record = { + player: -49, + cicka: -28, + 'counterpart-cat': -25, + draftsperson: -48, + 'toy-car': -14, + guitarist: -46, + crowd: -41, + guitar: -24, + traveler: -44, + driver: -46, + 'operations-helper': -46, + 'dance-teacher': -50, + steward: -48, + shuttle: -29 +}; + +export function headTopFor(id: RidgeActorId): number { + return (HEAD_TOP[id] ?? -44) * FIGURE_SCALE; +} + +export interface ActorRenderRequest { + id: RidgeActorId; + x: number; + y: number; + facing: RidgeFacing; + pose: StickPose; + /** Vertical offset for breathing and walk bounce — applied as a transform. */ + bob: number; +} + +/** + * One Graphics object per actor. + * + * The split matters for cost: a figure's command buffer is rebuilt only when + * its pose changes on the stepped clock, while following it around the stage + * is a transform update on an unchanged buffer. + */ +export class ActorSpritePool { + private readonly scene: Phaser.Scene; + private readonly sprites = new Map(); + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + sync(requests: readonly ActorRenderRequest[]): void { + const seen = new Set(); + + for (const request of requests) { + seen.add(request.id); + let sprite = this.sprites.get(request.id); + if (!sprite) { + sprite = new ActorSprite(this.scene, request.id); + this.sprites.set(request.id, sprite); + } + sprite.sync(request); + } + + for (const [id, sprite] of this.sprites) { + if (!seen.has(id)) sprite.hide(); + } + } + + destroy(): void { + for (const sprite of this.sprites.values()) sprite.destroy(); + this.sprites.clear(); + } +} + +class ActorSprite { + private readonly graphics: Phaser.GameObjects.Graphics; + private readonly id: RidgeActorId; + private poseKey = ''; + private placedX = Number.NaN; + private placedY = Number.NaN; + + constructor(scene: Phaser.Scene, id: RidgeActorId) { + this.id = id; + this.graphics = scene.add.graphics().setDepth(DEPTH.actor + depthOffsetFor(id)); + } + + sync(request: ActorRenderRequest): void { + const key = `${request.facing}|${request.pose.frame}|${request.pose.walking}|${request.pose.talking}`; + if (key !== this.poseKey) { + this.poseKey = key; + this.graphics.clear(); + drawActor(this.graphics, this.id, request.facing, request.pose); + } + + const x = Math.round(request.x); + const y = Math.round(request.y + request.bob); + if (x !== this.placedX || y !== this.placedY) { + this.placedX = x; + this.placedY = y; + this.graphics.setPosition(x, y); + } + this.graphics.setVisible(true); + } + + hide(): void { + this.graphics.setVisible(false); + } + + destroy(): void { + this.graphics.destroy(); + } +} + +/** Props sit behind people; the player reads above the crowd. */ +function depthOffsetFor(id: RidgeActorId): number { + if (id === 'player') return 3; + if (id === 'toy-car' || id === 'guitar' || id === 'shuttle') return -1; + if (id === 'crowd') return -2; + return 0; +} + +type ActorDrawer = ( + g: Phaser.GameObjects.Graphics, + facing: RidgeFacing, + pose: StickPose, + s: number +) => void; + +/** Per-cast drawer table — avoids a high-cyclomatic switch on every redraw. */ +const ACTOR_DRAWERS: Record = { + player: (g, facing, pose, s) => drawStickPlayer(g, 0, 0, facing, 1.15 * s, pose), + cicka: (g, _facing, pose, s) => drawStickCicka(g, 0, 0, 1.1 * s, pose), + 'counterpart-cat': (g, _facing, pose, s) => drawStickCicka(g, 0, 0, 0.95 * s, pose), + draftsperson: (g, facing, pose, s) => drawStickDraftsperson(g, 0, 0, facing, 1.05 * s, pose), + 'toy-car': (g, _facing, _pose, s) => drawStickToyCar(g, 22, 4, 1.1 * s), + guitarist: (g, facing, pose, s) => drawStickGuitarist(g, 0, 0, facing, 1.05 * s, pose), + crowd: (g, _facing, pose, s) => drawStickCrowd(g, 0, 0, s, pose), + guitar: (g, _facing, _pose, s) => drawStickGuitar(g, 20, 2, 1.1 * s), + traveler: (g, facing, pose, s) => drawStickTraveler(g, 0, 0, facing, s, pose), + driver: (g, facing, pose, s) => drawStickDriver(g, 0, 0, facing, 1.05 * s, pose), + 'operations-helper': (g, facing, pose, s) => + drawStickOperationsHelper(g, 0, 0, facing, 1.05 * s, pose), + 'dance-teacher': (g, facing, pose, s) => drawStickDanceTeacher(g, 0, 0, facing, 1.05 * s, pose), + steward: (g, facing, pose, s) => drawStickSteward(g, 0, 0, facing, s, pose), + shuttle: (g, _facing, _pose, s) => drawStickShuttle(g, 0, 0, 1.1 * s) +}; + +/** Figures draw around a local origin so the pool can move them freely. */ +function drawActor( + g: Phaser.GameObjects.Graphics, + id: RidgeActorId, + facing: RidgeFacing, + pose: StickPose +): void { + ACTOR_DRAWERS[id](g, facing, pose, FIGURE_SCALE); +} diff --git a/src/game/scenes/ridge/art/stick/ambientLayer.ts b/src/game/scenes/ridge/art/stick/ambientLayer.ts new file mode 100644 index 0000000..bf6045d --- /dev/null +++ b/src/game/scenes/ridge/art/stick/ambientLayer.ts @@ -0,0 +1,148 @@ +import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; +import { drawBird, drawCloud, jitter } from './atmosphere'; +import { BRIDGE_CAMP_X, GROUND_Y, INK, PAPER, SKY_TOP, STAGE_WIDTH } from './palette'; + +/** + * Drifting set dressing, redrawn on the stepped sketch clock (~11 FPS) rather + * than per frame. Every pass stays under ~40 draw commands, so the whole layer + * costs less than a single character redraw. + */ + +const CLOUD_COUNT = 4; +const BIRD_COUNT = 3; +const MOTE_COUNT = 14; + +/** Sky band: clouds crawling and birds crossing behind the horizon. */ +export function drawAmbientFar( + g: Phaser.GameObjects.Graphics, + areaId: RidgeAreaId, + tick: number +): void { + g.clear(); + if (areaId === 'concert') return; + + const span = STAGE_WIDTH + 260; + for (let i = 0; i < CLOUD_COUNT; i += 1) { + const speed = 0.55 + jitter(i * 3.3) * 0.5; + const x = (((jitter(i) * span + tick * speed) % span) + span) % span - 130; + const y = SKY_TOP + 22 + jitter(i * 5.5) * 74; + drawCloud(g, x, y, 0.95 + jitter(i * 2.1) * 0.55, 0.32); + } + + for (let i = 0; i < BIRD_COUNT; i += 1) { + const speed = 1.6 + jitter(i * 7.1) * 1.1; + const x = (((jitter(i * 1.7) * span + tick * speed) % span) + span) % span - 130; + const drift = Math.sin((tick + i * 9) * 0.12) * 9; + drawBird(g, x, SKY_TOP + 34 + jitter(i * 4.4) * 66 + drift, tick + i, 0.42); + } +} + +/** Ground band: the small stuff moving through the playable lane. */ +export function drawAmbientNear( + g: Phaser.GameObjects.Graphics, + areaId: RidgeAreaId, + tick: number +): void { + g.clear(); + + if (areaId === 'bridge') { + drawCampSmoke(g, tick); + drawDriftingSeeds(g, tick); + return; + } + if (areaId === 'concert') { + drawRisingEmbers(g, tick); + return; + } + if (areaId === 'danceFestival') { + drawFallingPetals(g, tick); + return; + } + drawSlowMotes(g, tick); +} + +/** + * Bridge: smoke off the campfire. A column of loops that widen, lean, and fade + * as they climb — the one signal that says somebody lives here. + */ +function drawCampSmoke(g: Phaser.GameObjects.Graphics, tick: number): void { + const rise = 150; + const x = BRIDGE_CAMP_X + 62; + + for (let i = 0; i < 6; i += 1) { + const t = (((i * (rise / 6) + tick * 1.2) % rise) + rise) % rise; + const climb = t / rise; + const y = GROUND_Y - 22 - t; + const lean = Math.sin(climb * 3.1 + tick * 0.08) * 22 * climb; + g.lineStyle(2, INK, 0.3 * (1 - climb)); + g.strokeCircle(x + lean, y, 4 + climb * 13); + } +} + +/** Bridge: dandelion seeds tumbling on the river breeze. */ +function drawDriftingSeeds(g: Phaser.GameObjects.Graphics, tick: number): void { + const span = STAGE_WIDTH + 160; + g.lineStyle(1.6, INK, 0.34); + for (let i = 0; i < MOTE_COUNT; i += 1) { + const speed = 1.1 + jitter(i * 2.7) * 1.4; + const x = (((jitter(i) * span + tick * speed) % span) + span) % span - 80; + const y = GROUND_Y - 60 - jitter(i * 6.3) * 190 + Math.sin((tick + i * 5) * 0.18) * 14; + g.strokeCircle(x, y, 2.4); + g.lineBetween(x, y + 2, x - 4, y + 7); + } +} + +/** Concert: sparks and note marks lifting off the stage. */ +function drawRisingEmbers(g: Phaser.GameObjects.Graphics, tick: number): void { + const rise = 260; + for (let i = 0; i < MOTE_COUNT; i += 1) { + const speed = 1.5 + jitter(i * 4.9) * 1.6; + const t = (((jitter(i) * rise + tick * speed) % rise) + rise) % rise; + const y = GROUND_Y - 30 - t; + const x = 200 + jitter(i * 3.1) * (STAGE_WIDTH - 340) + Math.sin((tick + i * 7) * 0.2) * 16; + const fade = 0.5 * (1 - t / rise); + + if (i % 4 === 0) { + // A stray quaver riding the noise. + g.lineStyle(2, INK, fade + 0.15); + g.lineBetween(x, y, x, y - 11); + g.fillStyle(INK, fade + 0.15); + g.fillCircle(x - 2.5, y, 3); + } else { + g.fillStyle(PAPER, fade + 0.25); + g.fillCircle(x, y, 2.6); + g.lineStyle(1.4, INK, fade + 0.2); + g.strokeCircle(x, y, 2.6); + } + } +} + +/** Dance Festival: paper petals falling through the bunting. */ +function drawFallingPetals(g: Phaser.GameObjects.Graphics, tick: number): void { + const fall = 300; + g.lineStyle(1.5, INK, 0.4); + for (let i = 0; i < MOTE_COUNT + 4; i += 1) { + const speed = 1.3 + jitter(i * 5.7) * 1.5; + const t = (((jitter(i * 1.3) * fall + tick * speed) % fall) + fall) % fall; + const x = 60 + jitter(i) * (STAGE_WIDTH - 120) + Math.sin((tick + i * 11) * 0.16) * 22; + const y = GROUND_Y - 300 + t; + const w = 4 + jitter(i * 2.2) * 3; + // Flip width on the stepped clock so each petal tumbles. + const flip = (tick + i) % 6 < 3 ? 1 : 0.35; + g.fillStyle(PAPER, 0.85); + g.fillTriangle(x, y - w, x + w * flip, y, x, y + w); + g.strokeTriangle(x, y - w, x + w * flip, y, x, y + w); + } +} + +/** Relay: slow dust in low sun. */ +function drawSlowMotes(g: Phaser.GameObjects.Graphics, tick: number): void { + for (let i = 0; i < MOTE_COUNT; i += 1) { + const x = 80 + jitter(i) * (STAGE_WIDTH - 160) + Math.sin((tick + i * 6) * 0.09) * 26; + const y = + GROUND_Y - 40 + jitter(i * 3.9) * 60 - ((tick * 0.4 + jitter(i * 8.1) * 200) % 200); + g.fillStyle(INK, 0.16 + jitter(i * 2.6) * 0.14); + g.fillCircle(x, y, 1.8 + jitter(i * 4.1) * 1.6); + } +} diff --git a/src/game/scenes/ridge/art/stick/areaSets.ts b/src/game/scenes/ridge/art/stick/areaSets.ts new file mode 100644 index 0000000..30b79ee --- /dev/null +++ b/src/game/scenes/ridge/art/stick/areaSets.ts @@ -0,0 +1,607 @@ +// Each area authors three parallax bands; the branching is set-dressing data. +// fallow-ignore-file complexity +import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; +import type { RidgeVisualViewModel } from '../types'; +import { + drawCornStalk, + drawGroundBand, + drawHatch, + drawInkBlob, + drawMountainRange, + drawPaperBase, + drawPaperBacking, + drawSunOrMoon, + drawTree, + jitter, + strokeLeaf +} from './atmosphere'; +import { + BRIDGE_CAMP_X, + GROUND_Y, + HORIZON_Y, + INK, + LAYERS, + PAPER, + PAPER_WARM, + SKY_TOP, + STAGE_WIDTH +} from './palette'; + +/** + * Parallax band being baked. + * - `far` owns the sky wash and everything past the horizon. + * - `near` is transparent except for the playable lane and its set dressing. + * - `fore` is the framing silhouette that slides past the camera fastest. + */ +export type SceneryLayer = 'far' | 'near' | 'fore'; + +export interface AreaSetContext { + worldXForProgress: (progress: number) => number; +} + +export function drawRidgeAreaLayer( + g: Phaser.GameObjects.Graphics, + layer: SceneryLayer, + areaId: RidgeAreaId, + crossingOpen: boolean, + beat: RidgeVisualViewModel['beat'], + ctx: AreaSetContext +): void { + if (layer === 'far') { + drawPaperBase(g, STAGE_WIDTH, HORIZON_Y); + drawFarBand(g, areaId); + return; + } + if (layer === 'fore') { + drawForeBand(g, areaId); + return; + } + + drawGroundBand(g, STAGE_WIDTH); + drawTreeline(g, areaId); + + if (areaId === 'bridge') drawBridgeNear(g, crossingOpen, ctx); + else if (areaId === 'concert') drawConcertNear(g, crossingOpen, ctx); + else if (areaId === 'danceFestival') drawDanceNear(g, crossingOpen, ctx); + else drawRelayNear(g, beat, ctx); +} + +// --- far band ------------------------------------------------------------- + +/** + * Distant ridges stop above the woodland mass. Filling them to the ground line + * turns the lower half of the frame into one grey slab behind the corn. + */ +const RIDGE_BASE_Y = GROUND_Y - 118; + +function drawFarBand(g: Phaser.GameObjects.Graphics, areaId: RidgeAreaId): void { + if (areaId === 'concert') { + // Night: the sky is the darkest thing behind the lane, not the trees. + g.fillStyle(INK, 0.13); + g.fillRect(0, 0, STAGE_WIDTH, RIDGE_BASE_Y); + drawSunOrMoon(g, STAGE_WIDTH - 240, SKY_TOP + 40, 26, 'moon'); + g.fillStyle(INK, 0.35); + for (let i = 0; i < 26; i += 1) { + const sx = jitter(i * 3.1) * STAGE_WIDTH; + const sy = SKY_TOP + 8 + jitter(i * 7.7) * 120; + g.fillRect(sx, sy, 2, 2); + } + drawRidges(g, areaId, 0.05, 0.09); + return; + } + + if (areaId === 'relay') { + drawSunOrMoon(g, STAGE_WIDTH * 0.66, SKY_TOP + 88, 40, 'sunset'); + drawRidges(g, areaId, 0.04, 0.075); + return; + } + + drawSunOrMoon(g, areaId === 'bridge' ? 220 : 250, SKY_TOP + 48, 26, 'sun'); + drawRidges(g, areaId, 0.035, 0.065); + + if (areaId === 'bridge') { + // Distant town on the near ridge — a promise of somewhere to walk toward. + g.lineStyle(1.6, INK, 0.26); + const townY = RIDGE_BASE_Y - 26; + for (let i = 0; i < 7; i += 1) { + const h = 16 + (i % 3) * 10; + g.strokeRect(1180 + i * 15, townY - h, 10, h); + } + g.lineBetween(1168, townY, 1306, townY); + } +} + +/** Two crests at different heights and tones: the cheapest read of distance. */ +function drawRidges( + g: Phaser.GameObjects.Graphics, + areaId: RidgeAreaId, + farAlpha: number, + nearAlpha: number +): void { + drawMountainRange(g, ridgeLine(areaId, 0), farAlpha, RIDGE_BASE_Y); + drawMountainRange(g, ridgeLine(areaId, 1), nearAlpha, RIDGE_BASE_Y); +} + +/** + * Peaks live in the upper half of the visible sky, never above it. `rank` 0 is + * the far crest; rank 1 sits lower and is seeded differently so the two never + * trace each other. + * + * Sampled densely rather than authored as a handful of corners: six points + * across 1600px produce enormous straight diagonals that read as ruled lines, + * not hills. + */ +function ridgeLine(areaId: RidgeAreaId, rank: number): ReadonlyArray { + const crest = SKY_TOP + (rank === 0 ? 40 : 84); + const relief = rank === 0 ? 46 : 34; + const seed = (areaId === 'concert' ? 4.1 : areaId === 'relay' ? 8.7 : 1.3) + rank * 17; + + const points: Array = []; + const step = 58; + for (let x = -step; x <= STAGE_WIDTH + step; x += step) { + const t = x / step; + // Two out-of-phase waves plus a little noise: broad hills carrying smaller + // shoulders, which is what a ridge actually looks like from a distance. + const shape = + Math.sin(t * 0.34 + seed) * 0.6 + + Math.sin(t * 0.79 + seed * 2.4) * 0.28 + + (jitter(t + seed) - 0.5) * 0.24; + points.push([x, crest - shape * relief]); + } + return points; +} + +/** + * Massed woodland stitching the horizon to the playable lane. + * + * Dense and grounded like the backup pass, but sampled as a union silhouette + * so overlapping crowns fill solid instead of punching Venn-diagram holes. + */ +function drawTreeline(g: Phaser.GameObjects.Graphics, areaId: RidgeAreaId): void { + // Haze gap: a pale strip separating the distant ridge from the woodland, so + // the two masses do not merge into one flat slab. + g.fillStyle(PAPER, 0.72); + g.fillRect(0, GROUND_Y - 128, STAGE_WIDTH, 46); + g.fillStyle(PAPER, 0.4); + g.fillRect(0, GROUND_Y - 148, STAGE_WIDTH, 20); + + const base = areaId === 'concert' ? 0.14 : 0.11; + drawCanopyBand(g, GROUND_Y - 6, 72, 54, base, 3.1); + drawCanopyBand(g, GROUND_Y - 2, 46, 38, base + 0.09, 7.4); +} + +function drawCanopyBand( + g: Phaser.GameObjects.Graphics, + baseY: number, + crown: number, + spacing: number, + alpha: number, + seed: number +): void { + g.fillStyle(INK, alpha); + g.beginPath(); + g.moveTo(-spacing, baseY); + for (let x = -spacing; x <= STAGE_WIDTH + spacing; x += 8) { + g.lineTo(x, baseY - canopyHeightAt(x, crown, spacing, seed)); + } + g.lineTo(STAGE_WIDTH + spacing, baseY); + g.closePath(); + g.fillPath(); + + fadeBelow(g, baseY, alpha); +} + +/** + * Silhouette height of the treeline at `x`: the union of nearby crowns, sampled + * as a height field. + * + * Chaining overlapping `arc()` calls into a single path looks like the obvious + * way to mass foliage, but the path self-intersects and the fill rule then + * cancels every overlap, leaving a patchwork of lens-shaped seams. Sampling the + * outline instead yields one simple polygon that fills solid. + */ +function canopyHeightAt(x: number, crown: number, spacing: number, seed: number): number { + // Continuous floor, so the band never opens a gap down to the ground. + let height = crown * 0.2; + const first = Math.floor(x / spacing) - 1; + + for (let i = first; i <= first + 2; i += 1) { + const cx = i * spacing; + const radius = spacing * (0.6 + jitter(cx * 0.07 + seed) * 0.45); + const dx = x - cx; + if (Math.abs(dx) >= radius) continue; + const lift = crown * (0.34 + jitter(cx * 0.11 + seed) * 0.66); + height = Math.max(height, lift + Math.sqrt(radius * radius - dx * dx) * 0.55); + } + return height; +} + +/** + * Ramps a baked mass into the tone below it over a few flat steps. + * + * A silhouette that simply stops leaves a ruled horizontal across the stage, + * and scattering marks along the join to hide it only trades one artefact for a + * field of blobs. Stepping the alpha down costs five rects and reads as haze. + */ +function fadeBelow(g: Phaser.GameObjects.Graphics, y: number, alpha: number): void { + const depth = 30; + const steps = 5; + for (let i = 0; i < steps; i += 1) { + const band = depth / steps; + g.fillStyle(INK, alpha * (1 - i / steps) * 0.8); + g.fillRect(0, y + band * i, STAGE_WIDTH, band + 1); + } +} + +// --- near band ------------------------------------------------------------ + +function drawBridgeNear( + g: Phaser.GameObjects.Graphics, + bridgeOpen: boolean, + ctx: AreaSetContext +): void { + // Grounded timber — trunks meet the lane so crowns never float on haze. + for (let i = 0; i < 7; i += 1) { + drawTree(g, 600 + i * 96, GROUND_Y - 2, i % 2 === 0 ? 'pine' : 'round', 1.15, 0.45); + } + for (let i = 0; i < 4; i += 1) { + drawTree(g, 214 + i * 74, GROUND_Y - 2, 'bush', 0.8, 0.26); + } + + drawCornField(g, 30, 470); + + const riverLeft = ctx.worldXForProgress(0.58); + const riverRight = ctx.worldXForProgress(0.78); + g.fillStyle(INK, 0.06); + g.fillRect(riverLeft - 8, GROUND_Y, riverRight - riverLeft + 16, 78); + g.lineStyle(2, INK, 0.32); + for (let y = GROUND_Y + 14; y < GROUND_Y + 70; y += 15) { + g.lineBetween(riverLeft, y, riverRight - 30, y + 2); + g.lineBetween(riverRight - 18, y + 6, riverRight, y + 5); + } + + if (bridgeOpen) { + g.lineStyle(5.5, INK, 1); + g.lineBetween(riverLeft, GROUND_Y - 4, riverRight, GROUND_Y - 4); + g.lineStyle(2, INK, 0.5); + g.lineBetween(riverLeft, GROUND_Y - 20, riverRight, GROUND_Y - 20); + for (let i = 0; i <= 6; i += 1) { + const px = riverLeft + ((riverRight - riverLeft) / 6) * i; + g.lineBetween(px, GROUND_Y - 20, px, GROUND_Y - 4); + } + } else { + const mid = (riverLeft + riverRight) / 2; + g.lineStyle(5.5, INK, 1); + g.lineBetween(riverLeft, GROUND_Y - 4, mid - 40, GROUND_Y - 4); + g.lineBetween(mid + 40, GROUND_Y - 4, riverRight, GROUND_Y - 4); + drawPaperBacking(g, mid, GROUND_Y - 28, 88, 52); + g.lineStyle(1.4, INK, 0.4); + g.lineBetween(mid - 36, GROUND_Y - 56, mid + 36, GROUND_Y - 56); + g.lineBetween(mid - 36, GROUND_Y - 44, mid + 20, GROUND_Y - 44); + } + + const campX = BRIDGE_CAMP_X; + g.fillStyle(PAPER_WARM, 1); + g.lineStyle(2.8, INK, 0.85); + g.beginPath(); + g.moveTo(campX - 40, GROUND_Y); + g.lineTo(campX, GROUND_Y - 52); + g.lineTo(campX + 40, GROUND_Y); + g.closePath(); + g.fillPath(); + g.strokePath(); + drawHatch(g, campX + 6, GROUND_Y - 34, 32, 34, 8, 0.26, 0.9); + + // Fire ring in front of the tent — the anchor the drifting smoke rises from. + g.lineStyle(2.4, INK, 0.75); + g.fillStyle(INK, 0.8); + for (let i = 0; i < 3; i += 1) { + const a = -0.7 + i * 0.7; + g.lineBetween(campX + 62, GROUND_Y, campX + 62 + Math.sin(a) * 16, GROUND_Y - 18); + } + g.lineStyle(2.2, INK, 0.55); + g.lineBetween(campX + 46, GROUND_Y - 1, campX + 78, GROUND_Y - 1); +} + +/** + * A worked cornfield: massed leaves first, then a few whole stalks standing in + * them. Rows of bare stalks alone read as dead winter branches. + */ +function drawCornField(g: Phaser.GameObjects.Graphics, fromX: number, toX: number): void { + const span = toX - fromX; + + g.fillStyle(INK, 0.09); + g.fillRect(fromX, GROUND_Y - 62, span, 62); + + g.fillStyle(INK, 0.26); + for (let i = 0; i < 46; i += 1) { + const x = fromX + jitter(i * 1.9) * span; + const y = GROUND_Y - 12 - jitter(i * 4.7) * 52; + const side = i % 2 === 0 ? 1 : -1; + strokeLeaf(g, x, y, side * (18 + jitter(i * 2.3) * 20), 10 + jitter(i * 5.1) * 12); + } + + for (let i = 0; i < 11; i += 1) { + const x = fromX + 18 + (span / 11) * i + jitter(i * 3.3) * 16; + drawCornStalk(g, x, GROUND_Y, 76 + jitter(i) * 30, 0, 0.55); + } +} + +function drawConcertNear( + g: Phaser.GameObjects.Graphics, + crossingOpen: boolean, + ctx: AreaSetContext +): void { + for (let i = 0; i < 5; i += 1) { + const x = 130 + i * 268; + const h = 150 + (i % 2) * 34; + g.lineStyle(2.8, INK, 0.9); + g.fillStyle(PAPER, 0.4); + g.fillRect(x, GROUND_Y - h, 146, h); + g.strokeRect(x, GROUND_Y - h, 146, h); + drawHatch(g, x + 2, GROUND_Y - h + 2, 142, h - 4, 22, 0.1, 0.6); + // Lit windows read as warm negative space against the hatching. + g.fillStyle(PAPER, 0.95); + for (let row = 0; row < 2; row += 1) { + for (let col = 0; col < 3; col += 1) { + if (jitter(i * 9 + row * 3 + col) < 0.35) continue; + const wx = x + 18 + col * 42; + const wy = GROUND_Y - h + 26 + row * 52; + g.fillRect(wx, wy, 28, 34); + g.lineStyle(2, INK, 0.85); + g.strokeRect(wx, wy, 28, 34); + } + } + g.lineStyle(2.4, INK, 0.85); + g.strokeRect(x + 54, GROUND_Y - 62, 40, 62); + } + + // Street lamps with a hatched pool of light beneath each. + for (let i = 0; i < 5; i += 1) { + const x = 210 + i * 300; + g.lineStyle(2.6, INK, 0.85); + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 108); + g.lineBetween(x, GROUND_Y - 108, x + 16, GROUND_Y - 118); + g.fillStyle(PAPER, 0.9); + g.fillCircle(x + 22, GROUND_Y - 118, 9); + g.strokeCircle(x + 22, GROUND_Y - 118, 9); + g.fillStyle(INK, 0.05); + g.fillTriangle(x + 22, GROUND_Y - 110, x - 26, GROUND_Y, x + 70, GROUND_Y); + } + + const stage = ctx.worldXForProgress(0.4); + g.lineStyle(3, INK, 0.9); + g.fillStyle(PAPER_WARM, 0.85); + g.fillRect(stage - 90, GROUND_Y - 30, 180, 30); + g.strokeRect(stage - 90, GROUND_Y - 30, 180, 30); + g.lineBetween(stage - 96, GROUND_Y - 120, stage - 96, GROUND_Y - 30); + g.lineBetween(stage + 96, GROUND_Y - 120, stage + 96, GROUND_Y - 30); + g.lineBetween(stage - 96, GROUND_Y - 120, stage + 96, GROUND_Y - 120); + g.lineStyle(2, INK, 0.45); + for (let i = 0; i < 5; i += 1) { + const bx = stage - 72 + i * 36; + g.lineBetween(bx, GROUND_Y - 120, bx, GROUND_Y - 104); + g.strokeRect(bx - 6, GROUND_Y - 104, 12, 10); + } + + const gate = ctx.worldXForProgress(0.55); + g.lineStyle(3.4, INK, crossingOpen ? 0.18 : 0.95); + g.lineBetween(gate, GROUND_Y - 6, gate, GROUND_Y - 84); + if (!crossingOpen) { + g.lineBetween(gate - 46, GROUND_Y - 44, gate + 46, GROUND_Y - 44); + drawHatch(g, gate - 46, GROUND_Y - 44, 92, 38, 10, 0.2, 0.9); + } + + const nook = ctx.worldXForProgress(0.72); + g.fillStyle(INK, 0.09); + g.fillRect(nook - 38, GROUND_Y - 94, 84, 94); + g.lineStyle(2.2, INK, 0.4); + g.strokeRect(nook - 38, GROUND_Y - 94, 84, 94); + drawHatch(g, nook - 36, GROUND_Y - 92, 80, 90, 14, 0.14, 0.5); +} + +function drawDanceNear( + g: Phaser.GameObjects.Graphics, + crossingOpen: boolean, + ctx: AreaSetContext +): void { + // Bunting strung between poles, sagging between each pair. + g.lineStyle(1.9, INK, 0.45); + g.beginPath(); + g.moveTo(90, GROUND_Y - 132); + for (let x = 90; x < STAGE_WIDTH - 80; x += 110) { + g.lineTo(x + 55, GROUND_Y - 112); + g.lineTo(x + 110, GROUND_Y - 132); + } + g.strokePath(); + g.fillStyle(PAPER_WARM, 0.9); + g.lineStyle(1.6, INK, 0.5); + for (let i = 0; i < 26; i += 1) { + const x = 110 + i * 55; + const sag = i % 2 === 0 ? 118 : 128; + g.fillTriangle(x - 9, GROUND_Y - sag, x + 9, GROUND_Y - sag, x, GROUND_Y - sag + 20); + g.strokeTriangle(x - 9, GROUND_Y - sag, x + 9, GROUND_Y - sag, x, GROUND_Y - sag + 20); + } + + for (let i = 0; i < 6; i += 1) { + const x = 170 + i * 224; + g.lineStyle(2.4, INK, 0.8); + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 134); + g.fillStyle(PAPER, 0.9); + g.fillRect(x - 9, GROUND_Y - 118, 18, 22); + g.strokeRect(x - 9, GROUND_Y - 118, 18, 22); + g.lineStyle(1.4, INK, 0.35); + g.lineBetween(x - 5, GROUND_Y - 112, x + 5, GROUND_Y - 102); + } + + // Trodden dance circle. + const circle = ctx.worldXForProgress(0.4); + g.lineStyle(2, INK, 0.3); + g.strokeEllipse(circle, GROUND_Y - 16, 220, 52, 14); + g.lineStyle(1.4, INK, 0.16); + g.strokeEllipse(circle, GROUND_Y - 14, 170, 40, 12); + + // Market stall. + const stall = ctx.worldXForProgress(0.24); + g.lineStyle(2.6, INK, 0.8); + g.fillStyle(PAPER_WARM, 0.9); + g.fillRect(stall - 54, GROUND_Y - 78, 108, 78); + g.strokeRect(stall - 54, GROUND_Y - 78, 108, 78); + g.beginPath(); + g.moveTo(stall - 66, GROUND_Y - 78); + g.lineTo(stall, GROUND_Y - 104); + g.lineTo(stall + 66, GROUND_Y - 78); + g.closePath(); + g.strokePath(); + drawHatch(g, stall - 52, GROUND_Y - 60, 104, 58, 12, 0.16, 0.7); + + const gate = ctx.worldXForProgress(0.68); + g.lineStyle(3.2, INK, crossingOpen ? 0.2 : 1); + g.strokeRect(gate - 38, GROUND_Y - 96, 76, 96); + if (crossingOpen) { + g.lineStyle(2, INK, 0.35); + g.lineBetween(gate + 38, GROUND_Y - 96, gate + 84, GROUND_Y - 42); + } else { + drawHatch(g, gate - 36, GROUND_Y - 94, 72, 92, 11, 0.18, 0.8); + } +} + +function drawRelayNear( + g: Phaser.GameObjects.Graphics, + beat: RidgeVisualViewModel['beat'], + ctx: AreaSetContext +): void { + g.lineStyle(4, INK, 1); + g.lineBetween(ctx.worldXForProgress(0.12), GROUND_Y, ctx.worldXForProgress(0.9), GROUND_Y); + g.lineBetween( + ctx.worldXForProgress(0.85), + GROUND_Y, + ctx.worldXForProgress(0.96), + GROUND_Y + 46 + ); + + // Cairn of stacked stones — a quiet marker for the last walk. + const cairn = ctx.worldXForProgress(0.3); + g.lineStyle(2.2, INK, 0.75); + g.fillStyle(PAPER, 0.6); + for (let i = 0; i < 4; i += 1) { + const w = 34 - i * 6; + drawInkBlob(g, cairn, GROUND_Y - 12 - i * 17, w * 0.5, 9, cairn + i, 8); + g.fillPath(); + g.strokePath(); + } + + const bench = ctx.worldXForProgress(0.55); + g.lineStyle(2.6, INK, 0.85); + g.lineBetween(bench - 38, GROUND_Y - 20, bench + 38, GROUND_Y - 20); + g.lineBetween(bench - 30, GROUND_Y - 20, bench - 30, GROUND_Y); + g.lineBetween(bench + 30, GROUND_Y - 20, bench + 30, GROUND_Y); + g.lineBetween(bench - 38, GROUND_Y - 34, bench + 38, GROUND_Y - 34); + + const spire = ctx.worldXForProgress(0.85); + const complete = beat === 'relay_complete'; + g.lineStyle(2.6, INK, complete ? 0.2 : 0.8); + g.lineBetween(spire - 26, GROUND_Y, spire, GROUND_Y - 132); + g.lineBetween(spire + 26, GROUND_Y, spire, GROUND_Y - 132); + g.strokeCircle(spire, GROUND_Y - 96, 34); + g.lineBetween(spire - 20, GROUND_Y - 96, spire + 20, GROUND_Y - 96); + if (!complete) { + g.lineStyle(1.5, INK, 0.3); + for (let i = 0; i < 6; i += 1) { + const a = (i / 6) * Math.PI * 2; + g.lineBetween( + spire + Math.cos(a) * 42, + GROUND_Y - 96 + Math.sin(a) * 42, + spire + Math.cos(a) * 58, + GROUND_Y - 96 + Math.sin(a) * 58 + ); + } + } +} + +// --- foreground band ------------------------------------------------------ + +/** + * Heavy ink silhouettes that frame the playable lane. Drawn in the layer's own + * space, so y = LAYERS.fore.height is the bottom of the screen. + */ +function drawForeBand(g: Phaser.GameObjects.Graphics, areaId: RidgeAreaId): void { + const width = LAYERS.fore.width; + const base = LAYERS.fore.height; + + if (areaId === 'concert') { + // Backs of heads: you are standing inside the crowd, not watching it. + g.fillStyle(INK, 0.88); + for (let i = 0; i < 22; i += 1) { + const x = 30 + i * 94 + jitter(i * 4.2) * 36; + const r = 20 + jitter(i * 8.9) * 10; + const shoulder = base - 30 + jitter(i * 1.9) * 16; + g.fillCircle(x, shoulder - r, r); + g.fillTriangle(x - r * 1.5, base, x + r * 1.5, base, x, shoulder - r * 1.4); + if (i % 5 === 1) { + g.fillRect(x + r * 0.8, shoulder - r * 3.4, 7, r * 2.6); + } + } + return; + } + + if (areaId === 'danceFestival') { + g.fillStyle(INK, 0.8); + for (let i = 0; i < 12; i += 1) { + const x = 70 + i * 170 + jitter(i * 3.7) * 44; + g.fillRect(x, base - 82, 5, 82); + drawInkBlob(g, x + 2, base - 92, 14, 17, i, 8); + g.fillPath(); + } + drawForeGrass(g, width, base, 0.7); + return; + } + + drawForeGrass(g, width, base, areaId === 'relay' ? 0.62 : 0.76); + if (areaId === 'bridge') { + for (let i = 0; i < 8; i += 1) { + drawForeCorn(g, 60 + i * 252 + jitter(i * 6.1) * 70, base, 110 + jitter(i * 2.9) * 50); + } + } +} + +/** Dense tuft line. Short and overlapping, so it reads as grass, not spikes. */ +function drawForeGrass( + g: Phaser.GameObjects.Graphics, + width: number, + base: number, + alpha: number +): void { + g.fillStyle(INK, alpha * 0.55); + for (let x = -6; x < width; x += 6) { + const h = 20 + jitter(x * 1.9) * 34; + const lean = (jitter(x * 2.7) - 0.5) * 20; + g.fillTriangle(x - 4, base, x + 4, base, x + lean, base - h); + } + g.fillStyle(INK, alpha); + for (let x = -6; x < width; x += 7) { + const h = 12 + jitter(x * 0.7) * 26; + const lean = (jitter(x * 1.3) - 0.5) * 14; + g.fillTriangle(x - 4, base, x + 4, base, x + lean, base - h); + } +} + +/** Tall corn framing the near foreground: stalk, tassel, and drooping leaves. */ +function drawForeCorn( + g: Phaser.GameObjects.Graphics, + x: number, + base: number, + height: number +): void { + g.fillStyle(INK, 0.8); + g.fillTriangle(x - 4, base, x + 4, base, x + 2, base - height); + g.fillTriangle(x - 1, base - height, x + 4, base - height, x + 4, base - height - 26); + + for (let i = 0; i < 4; i += 1) { + const at = base - height * (0.3 + i * 0.19); + const side = i % 2 === 0 ? 1 : -1; + const reach = side * (36 + jitter(x + i) * 22); + strokeLeaf(g, x + 1, at, reach, 22 + i * 4); + } +} diff --git a/src/game/scenes/ridge/art/stick/atmosphere.ts b/src/game/scenes/ridge/art/stick/atmosphere.ts new file mode 100644 index 0000000..96d284a --- /dev/null +++ b/src/game/scenes/ridge/art/stick/atmosphere.ts @@ -0,0 +1,437 @@ +import type * as Phaser from 'phaser'; +import { GROUND_Y, INK, PAPER, PAPER_WARM, STAGE_HEIGHT, WASH } from './palette'; + +/** Stepped sketchbook clock (~11 FPS) so motion reads as hand-drawn. */ +export function sketchTick(timeMs: number): number { + return Math.floor(timeMs / 90); +} + +export function prefersReducedMotion(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** Stable scatter so baked scenery looks hand-placed but never re-rolls. */ +export function jitter(seed: number): number { + const n = Math.sin(seed * 127.1) * 43758.5453; + return n - Math.floor(n); +} + +/** + * Soft cream wash. The warm tint is stacked into shallow steps at the top of + * the sky and left to fade out before the horizon, so the sky gets depth + * without a hard tonal edge anywhere the scenery has to sit against. + */ +export function drawPaperBase( + g: Phaser.GameObjects.Graphics, + width: number, + height: number +): void { + g.fillStyle(PAPER, 1); + g.fillRect(0, 0, width, height); + for (let i = 0; i < 4; i += 1) { + g.fillStyle(PAPER_WARM, 0.22); + g.fillRect(0, 0, width, 44 + i * 34); + } +} + +/** + * Parallel hatching for shadow mass. Line count is bounded by `spacing`, so + * callers control the cost directly. + */ +export function drawHatch( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + width: number, + height: number, + spacing: number, + alpha: number, + lean = 0.5 +): void { + const shift = Math.max(1, height * lean); + g.lineStyle(1.4, INK, alpha); + for (let topX = x; topX < x + width + shift; topX += spacing) { + // Clip the slanted segment to the band so hatching never bleeds outside it. + const enter = Math.max(0, (topX - (x + width)) / shift); + const exit = Math.min(1, (topX - x) / shift); + if (enter >= exit) continue; + g.lineBetween( + topX - enter * shift, + y + enter * height, + topX - exit * shift, + y + exit * height + ); + } +} + +/** Irregular ink silhouette — reads more handmade than a circle or ellipse. */ +export function drawInkBlob( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + radiusX: number, + radiusY: number, + seed: number, + points = 9 +): void { + g.beginPath(); + for (let i = 0; i <= points; i += 1) { + const angle = (i / points) * Math.PI * 2; + const wobble = 0.86 + jitter(seed + i) * 0.28; + const px = x + Math.cos(angle) * radiusX * wobble; + const py = y + Math.sin(angle) * radiusY * wobble; + if (i === 0) g.moveTo(px, py); + else g.lineTo(px, py); + } + g.closePath(); +} + +/** CRT-ish vignette + tape corners. No per-scanline loops. */ +export function drawCrtAtmosphere( + g: Phaser.GameObjects.Graphics, + width: number, + height: number +): void { + g.fillStyle(WASH, 0.1); + g.fillRect(0, 0, width, 16); + g.fillRect(0, height - 18, width, 18); + g.fillRect(0, 0, 14, height); + g.fillRect(width - 14, 0, 14, height); + + g.fillStyle(WASH, 0.07); + g.fillTriangle(0, 0, 100, 0, 0, 80); + g.fillTriangle(width, 0, width - 100, 0, width, 80); + g.fillTriangle(0, height, 120, height, 0, height - 90); + g.fillTriangle(width, height, width - 120, height, width, height - 90); + + g.lineStyle(2.2, INK, 0.5); + g.fillStyle(PAPER_WARM, 0.9); + drawTape(g, 16, 12, 54, 14, -12); + drawTape(g, width - 72, 14, 54, 14, 10); + drawTape(g, 20, height - 30, 48, 12, 8); + drawTape(g, width - 74, height - 28, 50, 12, -6); +} + +function drawTape( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + w: number, + h: number, + tiltHint: number +): void { + g.fillRect(x, y, w, h); + g.strokeRect(x, y, w, h); + g.lineStyle(1, INK, 0.2); + g.lineBetween(x + 4, y + h * 0.35, x + w - 4 + tiltHint * 0.05, y + h * 0.35); + g.lineStyle(2, INK, 0.35); +} + +/** Tiny foot shadow — avoid fillEllipse (32-point tessellation per call). */ +export function drawContactShadow( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + width = 28, + alpha = 0.16 +): void { + g.fillStyle(INK, alpha); + g.fillRect(x - width * 0.5, y + 2, width, 4); + g.fillStyle(INK, alpha * 0.5); + g.fillRect(x - width * 0.34, y + 6, width * 0.68, 3); +} + +/** + * The lane the cast walks on. + * + * Deliberately the quietest surface in the frame: one heavy horizon stroke, + * a pale trodden band, and a scatter of stones. Texture here competes with + * feet, so it stays under the ground line where nothing else is drawn. + */ +export function drawGroundBand( + g: Phaser.GameObjects.Graphics, + width: number, + groundY = GROUND_Y +): void { + g.fillStyle(INK, 0.04); + g.fillRect(0, groundY, width, 30); + g.fillStyle(INK, 0.075); + g.fillRect(0, groundY + 30, width, STAGE_HEIGHT - groundY - 30); + + g.lineStyle(5, INK, 1); + g.lineBetween(0, groundY, width, groundY); + + // Broken kerb line: a continuous second rule reads as a printing error. + g.lineStyle(1.8, INK, 0.22); + for (let x = 0; x < width; x += 190) { + const run = 110 + jitter(x * 0.9) * 60; + g.lineBetween(x, groundY + 30, x + run, groundY + 31); + } + + g.fillStyle(INK, 0.22); + for (let x = 24; x < width; x += 68) { + g.fillRect(x, groundY + 9 + jitter(x * 1.7) * 16, 3 + jitter(x) * 4, 2); + } +} + +/** + * Flat-bottomed cloud built as one simple polygon. + * + * Chained `arc()` bumps self-intersect, and the fill rule then knocks holes in + * the overlaps and the stroke draws every hidden interior edge, so the mark + * reads as a Venn diagram or a row of chevrons. Sampling the union of the bumps + * as a height field keeps it a single closed outline. + */ +export function drawCloud( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1, + alpha = 0.35 +): void { + const s = 16 * scale; + const bumps: ReadonlyArray = [ + [-0.72, 0.5], + [0.02, 0.82], + [0.78, 0.56] + ]; + const left = x - s * 1.24; + const right = x + s * 1.36; + + g.fillStyle(PAPER, 1); + g.lineStyle(1.8, INK, alpha); + g.beginPath(); + g.moveTo(left, y + s * 0.2); + for (let px = left; px <= right; px += 4) { + let top = y + s * 0.2; + for (const [offset, radius] of bumps) { + const cx = x + s * offset; + const r = s * radius; + const dx = px - cx; + if (Math.abs(dx) >= r) continue; + top = Math.min(top, y - Math.sqrt(r * r - dx * dx)); + } + g.lineTo(px, top); + } + g.lineTo(right, y + s * 0.2); + g.closePath(); + g.fillPath(); + g.strokePath(); +} + +export function drawBird( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + wingPhase: number, + alpha = 0.45 +): void { + const flap = wingPhase % 2 === 0 ? -4 : 3; + g.lineStyle(2, INK, alpha); + g.beginPath(); + g.moveTo(x - 8, y + flap); + g.lineTo(x, y); + g.lineTo(x + 8, y + flap); + g.strokePath(); +} + +/** + * Distant ridge silhouette. + * + * Kept as a clean contour over a pale fill: scattered hatch marks at this + * distance read as specks of dirt on the page rather than as shading. + */ +export function drawMountainRange( + g: Phaser.GameObjects.Graphics, + points: ReadonlyArray, + alpha = 0.12, + baseY = GROUND_Y +): void { + if (points.length < 2) return; + + // One flat tone per range. Depth comes from stacking two calls at different + // alphas, not from shading a single mass, which only muddies it. + fillRidge(g, points, baseY, INK, alpha, 0); + + // The crest is barely darker than its own fill. A crisp contour over a pale + // mass reads as a line drawn on the sky rather than as a distant hillside. + g.lineStyle(1.8, INK, Math.min(0.3, alpha + 0.08)); + g.beginPath(); + g.moveTo(points[0]![0], points[0]![1]); + for (let i = 1; i < points.length; i += 1) { + g.lineTo(points[i]![0], points[i]![1]); + } + g.strokePath(); +} + +function fillRidge( + g: Phaser.GameObjects.Graphics, + points: ReadonlyArray, + baseY: number, + color: number, + alpha: number, + offsetY: number +): void { + g.fillStyle(color, alpha); + g.beginPath(); + g.moveTo(points[0]![0], points[0]![1] + offsetY); + for (let i = 1; i < points.length; i += 1) { + g.lineTo(points[i]![0], points[i]![1] + offsetY); + } + g.lineTo(points[points.length - 1]![0], baseY); + g.lineTo(points[0]![0], baseY); + g.closePath(); + g.fillPath(); +} + +/** Corn stalk for the Bridge field: stalk, tassel, and drooping leaves. */ +export function drawCornStalk( + g: Phaser.GameObjects.Graphics, + x: number, + groundY: number, + height: number, + sway = 0, + alpha = 0.75 +): void { + const top = groundY - height; + g.lineStyle(2.4, INK, alpha); + g.lineBetween(x, groundY, x + sway, top); + g.lineStyle(1.6, INK, alpha * 0.8); + g.lineBetween(x + sway, top, x + sway + 5, top - 11); + g.lineBetween(x + sway, top, x + sway - 3, top - 9); + + g.fillStyle(INK, alpha * 0.85); + for (let i = 0; i < 4; i += 1) { + const at = groundY - height * (0.3 + i * 0.19); + strokeLeaf(g, x + sway * 0.5, at, (i % 2 === 0 ? 1 : -1) * (26 + i * 7), 16 + i * 4); + } +} + +/** + * Leaf that leaves the stem flat then droops, built from a short polygon so it + * reads as foliage instead of an arrowhead. + */ +export function strokeLeaf( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + reach: number, + droop: number +): void { + g.beginPath(); + g.moveTo(x, y - 2); + g.lineTo(x + reach * 0.55, y - 1 + droop * 0.18); + g.lineTo(x + reach, y + droop); + g.lineTo(x + reach * 0.5, y + droop * 0.28); + g.lineTo(x, y + 3); + g.closePath(); + g.fillPath(); +} + +/** Varied woodland mark — pine, rounded deciduous, or low bush. */ +export function drawTree( + g: Phaser.GameObjects.Graphics, + x: number, + groundY: number, + kind: 'pine' | 'round' | 'bush', + scale = 1, + alpha = 0.55 +): void { + const s = 14 * scale; + g.lineStyle(2.2, INK, alpha); + g.fillStyle(PAPER, 0.4); + + if (kind === 'pine') { + g.lineBetween(x, groundY, x, groundY - s * 0.4); + g.beginPath(); + g.moveTo(x, groundY - s * 2.5); + g.lineTo(x - s * 0.7, groundY - s * 0.35); + g.lineTo(x + s * 0.7, groundY - s * 0.35); + g.closePath(); + g.fillPath(); + g.strokePath(); + g.beginPath(); + g.moveTo(x, groundY - s * 1.75); + g.lineTo(x - s * 0.95, groundY - s * 0.15); + g.lineTo(x + s * 0.95, groundY - s * 0.15); + g.closePath(); + g.strokePath(); + g.lineStyle(1.2, INK, alpha * 0.6); + g.lineBetween(x - s * 0.4, groundY - s * 0.9, x + s * 0.1, groundY - s * 1.2); + } else if (kind === 'bush') { + drawInkBlob(g, x, groundY - s * 0.55, s * 0.8, s * 0.62, x, 7); + g.fillPath(); + g.strokePath(); + } else { + g.lineBetween(x, groundY, x, groundY - s * 0.7); + drawInkBlob(g, x, groundY - s * 1.35, s * 1.15, s * 1, x, 9); + g.fillPath(); + g.strokePath(); + g.lineStyle(1.2, INK, alpha * 0.55); + g.lineBetween(x - s * 0.5, groundY - s * 1.1, x - s * 0.1, groundY - s * 1.5); + } +} + +export function drawSunOrMoon( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + radius: number, + mode: 'sun' | 'moon' | 'sunset' +): void { + if (mode === 'sunset') { + g.lineStyle(2, INK, 0.22); + for (let i = 0; i < 5; i += 1) { + g.strokeCircle(x, y, radius + i * 26); + } + g.fillStyle(INK, 0.08); + g.fillCircle(x, y, radius); + g.lineStyle(2.5, INK, 0.7); + g.strokeCircle(x, y, radius); + return; + } + + if (mode === 'moon') { + g.fillStyle(PAPER, 0.9); + g.fillCircle(x, y, radius); + g.lineStyle(2.2, INK, 0.7); + g.strokeCircle(x, y, radius); + g.lineStyle(1.4, INK, 0.35); + g.strokeCircle(x - radius * 0.25, y - radius * 0.15, radius * 0.18); + g.strokeCircle(x + radius * 0.3, y + radius * 0.2, radius * 0.12); + g.fillStyle(PAPER_WARM, 0.5); + g.fillCircle(x + radius * 0.35, y - radius * 0.1, radius * 0.72); + return; + } + + g.lineStyle(2, INK, 0.45); + g.strokeCircle(x, y, radius); + for (let i = 0; i < 8; i += 1) { + const a = (i / 8) * Math.PI * 2; + g.lineBetween( + x + Math.cos(a) * (radius + 4), + y + Math.sin(a) * (radius + 4), + x + Math.cos(a) * (radius + 14), + y + Math.sin(a) * (radius + 14) + ); + } +} + +export function drawPaperBacking( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + w: number, + h: number +): void { + g.fillStyle(PAPER_WARM, 0.9); + g.fillRect(x - w * 0.5, y - h, w, h); + g.lineStyle(2, INK, 0.55); + g.strokeRect(x - w * 0.5, y - h, w, h); + g.lineStyle(2, INK, 0.25); + g.lineBetween(x - w * 0.5 + 4, y, x + w * 0.5 + 4, y); + g.lineBetween(x + w * 0.5, y - h + 4, x + w * 0.5 + 4, y); +} diff --git a/src/game/scenes/ridge/art/stick/barkDirector.ts b/src/game/scenes/ridge/art/stick/barkDirector.ts new file mode 100644 index 0000000..666bf3f --- /dev/null +++ b/src/game/scenes/ridge/art/stick/barkDirector.ts @@ -0,0 +1,100 @@ +/** Ambient chatter scheduling. No Phaser or DOM, so it stays testable. */ + +import type { RidgeActorId } from '@/game/core/ridge'; + +export type BarkLines = Partial>; + +export interface BarkPerformance { + actorId: RidgeActorId; + text: string; + /** 0..1 fade envelope for the bubble. */ + alpha: number; +} + +const HOLD_MS = 2900; +const FADE_MS = 260; +const MIN_GAP_MS = 3200; +const MAX_GAP_MS = 6400; + +/** + * Picks one nearby resident at a time to mutter something. + * + * One line at a time on purpose: overlapping chatter turns into noise, and a + * single bubble reads as a world you are walking through rather than a UI. + */ +export class BarkDirector { + private readonly lines: BarkLines; + private readonly random: () => number; + private current: { actorId: RidgeActorId; text: string; startedAt: number } | null = null; + private nextAt = 0; + private readonly lastLineByActor = new Map(); + + constructor(lines: BarkLines, random: () => number = Math.random) { + this.lines = lines; + this.random = random; + } + + /** + * @param candidates actors currently worth hearing from, nearest first. + * @returns the line to show right now, if any. + */ + update(now: number, candidates: readonly RidgeActorId[]): BarkPerformance | null { + if (this.current) { + const elapsed = now - this.current.startedAt; + const stillOnStage = candidates.includes(this.current.actorId); + if (elapsed < HOLD_MS && stillOnStage) { + return { + actorId: this.current.actorId, + text: this.current.text, + alpha: envelope(elapsed) + }; + } + this.current = null; + this.nextAt = now + MIN_GAP_MS + this.random() * (MAX_GAP_MS - MIN_GAP_MS); + return null; + } + + if (candidates.length === 0) { + // Nobody around: let the next line land soon after someone shows up. + this.nextAt = Math.min(this.nextAt, now + MIN_GAP_MS); + return null; + } + if (now < this.nextAt) return null; + + const picked = this.pick(candidates); + if (!picked) { + this.nextAt = now + MIN_GAP_MS; + return null; + } + + this.current = { ...picked, startedAt: now }; + this.lastLineByActor.set(picked.actorId, picked.text); + return { ...picked, alpha: 0 }; + } + + /** Drop any line in flight, e.g. when a real conversation opens. */ + interrupt(now: number): void { + if (!this.current) return; + this.current = null; + this.nextAt = now + MIN_GAP_MS; + } + + private pick(candidates: readonly RidgeActorId[]): { actorId: RidgeActorId; text: string } | null { + const speakable = candidates.filter((id) => (this.lines[id]?.length ?? 0) > 0); + if (speakable.length === 0) return null; + + const actorId = speakable[Math.floor(this.random() * speakable.length)] ?? speakable[0]!; + const options = this.lines[actorId]!; + const previous = this.lastLineByActor.get(actorId); + const fresh = options.length > 1 ? options.filter((line) => line !== previous) : options; + const text = fresh[Math.floor(this.random() * fresh.length)] ?? fresh[0]!; + return { actorId, text }; + } +} + +function envelope(elapsed: number): number { + if (elapsed < FADE_MS) return elapsed / FADE_MS; + const remaining = HOLD_MS - elapsed; + if (remaining < FADE_MS) return Math.max(0, remaining / FADE_MS); + return 1; +} diff --git a/src/game/scenes/ridge/art/stick/palette.ts b/src/game/scenes/ridge/art/stick/palette.ts new file mode 100644 index 0000000..01d3043 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -0,0 +1,63 @@ +export const PAPER = 0xfbfbf9; +export const PAPER_WARM = 0xf4f1ea; +export const INK = 0x1a1a1a; +export const WASH = 0x2a241c; + +/** CSS twins of {@link PAPER} / {@link INK} for Phaser Text styles. */ +export const PAPER_CSS = '#fbfbf9'; +export const INK_CSS = '#1a1a1a'; + +export const STAGE_WIDTH = 1600; +export const STAGE_HEIGHT = 720; +export const GROUND_Y = 520; + +/** + * Parallax bands. Scenery bakes into one texture per band, so each band costs a + * single quad per frame no matter how dense the drawing is. + */ +/** + * Vertical composition, in world units around {@link GROUND_Y}. Camera zoom is + * derived from this so the same slice of world is framed on every screen, and + * scenery can be authored against a window that is actually visible. + * + * Sized for a phone-friendly walk: enough sky and fore verge that the route + * reads as a place, not a close-up talking head. + */ +export const VIEW_ABOVE_GROUND = 250; +export const VIEW_BELOW_GROUND = 90; +export const VIEW_HEIGHT = VIEW_ABOVE_GROUND + VIEW_BELOW_GROUND; +export const SKY_TOP = GROUND_Y - VIEW_ABOVE_GROUND; + +export const LAYERS = { + // Runs all the way to the ground line so the distant fill passes behind the + // canopy instead of ending in a visible tonal seam above it. + far: { top: 0, width: STAGE_WIDTH, height: GROUND_Y, scrollFactor: 0.35, depth: 5 }, + near: { top: 0, width: STAGE_WIDTH, height: STAGE_HEIGHT, scrollFactor: 1, depth: 14 }, + // Tall enough for fore corn to frame the lane. Wider than the stage: a + // scroll factor above 1 outruns the right edge otherwise. + fore: { top: 458, width: 2000, height: 170, scrollFactor: 1.22, depth: 30 } +} as const; + +/** Bottom of the far band — distant silhouettes rest on this line. */ +export const HORIZON_Y = LAYERS.far.height; + +/** Bridge campfire, in world X. Shared so drifting smoke lands on the tent. */ +export const BRIDGE_CAMP_X = 742; + +export type RidgeLayerId = keyof typeof LAYERS; + +export const DEPTH = { + ambientFar: 8, + ambientNear: 16, + actor: 20, + presence: 36, + crt: 50 +} as const; + +/** + * Nameplate fade window, in stage progress. Wide enough that anyone clearly on + * screen is labelled, with a short ramp at the edge: a plate lingering at half + * opacity looks like a rendering fault rather than a deliberate fade. + */ +export const PRESENCE_NEAR = 0.27; +export const PRESENCE_FAR = 0.33; diff --git a/src/game/scenes/ridge/art/stick/presenceLayer.ts b/src/game/scenes/ridge/art/stick/presenceLayer.ts new file mode 100644 index 0000000..6eee065 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/presenceLayer.ts @@ -0,0 +1,305 @@ +import type * as Phaser from 'phaser'; +import { createUiText } from '@/game/sharedSceneRuntime/text/createUiText'; +import { DEPTH, INK, INK_CSS, PAPER, PAPER_CSS, PAPER_WARM } from './palette'; + +export interface NameplateContent { + name: string; + role: string; +} + +export interface PlacedPresence extends NameplateContent { + id: string; + x: number; + y: number; + alpha: number; +} + +export interface FocusPrompt { + key: string; + label: string; + x: number; + y: number; +} + +export interface ActiveBark { + id: string; + text: string; + x: number; + y: number; + alpha: number; +} + +/** + * Floating world chrome: who someone is, what you can do with them, and the + * throwaway things they say as you pass. + * + * Every piece draws its paper chrome once into container-local space, so + * following an actor around only costs a transform update. + */ +export class PresenceLayer { + private readonly scene: Phaser.Scene; + private readonly nameplates = new Map(); + private readonly barks = new Map(); + private readonly seenIds = new Set(); + private focus?: FocusPip; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + syncNameplates(entries: readonly PlacedPresence[]): void { + this.seenIds.clear(); + + for (const entry of entries) { + this.seenIds.add(entry.id); + let plate = this.nameplates.get(entry.id); + if (!plate) { + plate = new Nameplate(this.scene); + this.nameplates.set(entry.id, plate); + } + plate.setContent(entry); + plate.place(entry.x, entry.y, entry.alpha); + } + + for (const [id, plate] of this.nameplates) { + if (!this.seenIds.has(id)) plate.hide(); + } + } + + syncFocus(prompt: FocusPrompt | null, bob: number): void { + if (!prompt) { + this.focus?.hide(); + return; + } + if (!this.focus) this.focus = new FocusPip(this.scene); + this.focus.setLabel(prompt.key, prompt.label); + this.focus.place(prompt.x, prompt.y + bob); + } + + syncBarks(active: readonly ActiveBark[]): void { + this.seenIds.clear(); + + for (const bark of active) { + this.seenIds.add(bark.id); + let bubble = this.barks.get(bark.id); + if (!bubble) { + bubble = new SpeechBubble(this.scene); + this.barks.set(bark.id, bubble); + } + bubble.setText(bark.text); + bubble.place(bark.x, bark.y, bark.alpha); + } + + for (const [id, bubble] of this.barks) { + if (!this.seenIds.has(id)) bubble.hide(); + } + } + + destroy(): void { + for (const plate of this.nameplates.values()) plate.destroy(); + for (const bubble of this.barks.values()) bubble.destroy(); + this.nameplates.clear(); + this.barks.clear(); + this.focus?.destroy(); + this.focus = undefined; + } +} + +/** Name over a role tag, on a torn paper chip. */ +class Nameplate { + private readonly container: Phaser.GameObjects.Container; + private readonly chip: Phaser.GameObjects.Graphics; + private readonly nameText: Phaser.GameObjects.Text; + private readonly roleText: Phaser.GameObjects.Text; + private contentKey = ''; + private placedX = Number.NaN; + private placedY = Number.NaN; + private placedAlpha = -1; + + constructor(scene: Phaser.Scene) { + this.chip = scene.add.graphics(); + this.nameText = createUiText(scene, 0, 0, '', { + fontSize: '17px', + color: INK_CSS + }).setOrigin(0.5, 1); + this.roleText = createUiText(scene, 0, 0, '', { + fontSize: '11px', + color: INK_CSS + }).setOrigin(0.5, 1); + this.roleText.setAlpha(0.75); + + this.container = scene.add + .container(0, 0, [this.chip, this.nameText, this.roleText]) + .setDepth(DEPTH.presence) + .setVisible(false); + } + + setContent({ name, role }: NameplateContent): void { + const key = `${name}|${role}`; + if (key === this.contentKey) return; + this.contentKey = key; + + this.nameText.setText(name); + this.roleText.setText(role.toUpperCase()); + + const hasRole = role.length > 0; + const nameH = Math.ceil(this.nameText.height); + const roleH = hasRole ? Math.ceil(this.roleText.height) : 0; + + this.roleText.setY(0); + this.nameText.setY(hasRole ? -roleH - 1 : 0); + + const width = Math.max(this.nameText.width, this.roleText.width) + 18; + const height = nameH + roleH + 9; + const top = -height + 4; + + this.chip.clear(); + // Hard offset shadow first, matching the paper-cutout UI convention. + this.chip.fillStyle(INK, 0.3); + this.chip.fillRect(-width / 2 + 4, top + 4, width, height); + this.chip.fillStyle(PAPER_WARM, 1); + this.chip.fillRect(-width / 2, top, width, height); + this.chip.lineStyle(2.4, INK, 1); + this.chip.strokeRect(-width / 2, top, width, height); + // Stem down toward the head. + this.chip.lineStyle(2, INK, 0.55); + this.chip.lineBetween(0, top + height, 0, top + height + 8); + } + + place(x: number, y: number, alpha: number): void { + const rx = Math.round(x); + const ry = Math.round(y); + if (rx !== this.placedX || ry !== this.placedY) { + this.placedX = rx; + this.placedY = ry; + this.container.setPosition(rx, ry); + } + if (alpha !== this.placedAlpha) { + this.placedAlpha = alpha; + this.container.setAlpha(alpha); + } + this.container.setVisible(alpha > 0.02); + } + + hide(): void { + this.container.setVisible(false); + this.placedAlpha = -1; + } + + destroy(): void { + this.container.destroy(); + } +} + +/** The "you can talk to this" pip that hovers over the focused target. */ +class FocusPip { + private readonly container: Phaser.GameObjects.Container; + private readonly chrome: Phaser.GameObjects.Graphics; + private readonly label: Phaser.GameObjects.Text; + private labelKey = ''; + + constructor(scene: Phaser.Scene) { + this.chrome = scene.add.graphics(); + this.label = createUiText(scene, 0, 0, '', { + fontSize: '14px', + color: PAPER_CSS + }).setOrigin(0.5, 1); + + this.container = scene.add + .container(0, 0, [this.chrome, this.label]) + .setDepth(DEPTH.presence + 2) + .setVisible(false); + } + + setLabel(key: string, text: string): void { + if (key === this.labelKey) return; + this.labelKey = key; + this.label.setText(text); + + const width = this.label.width + 22; + const height = Math.ceil(this.label.height) + 10; + const top = -height; + + this.chrome.clear(); + this.chrome.fillStyle(INK, 0.94); + this.chrome.fillRect(-width / 2, top, width, height); + this.chrome.lineStyle(2, PAPER, 0.85); + this.chrome.strokeRect(-width / 2, top, width, height); + // Caret pointing down at the target. + this.chrome.fillStyle(INK, 0.94); + this.chrome.fillTriangle(-7, top + height - 1, 7, top + height - 1, 0, top + height + 9); + + this.label.setY(-7); + } + + place(x: number, y: number): void { + this.container.setPosition(Math.round(x), Math.round(y)).setVisible(true); + } + + hide(): void { + this.container.setVisible(false); + } + + destroy(): void { + this.container.destroy(); + } +} + +/** Small overheard line — the passing chatter of the street. */ +class SpeechBubble { + private readonly container: Phaser.GameObjects.Container; + private readonly chrome: Phaser.GameObjects.Graphics; + private readonly label: Phaser.GameObjects.Text; + private textKey = ''; + + constructor(scene: Phaser.Scene) { + this.chrome = scene.add.graphics(); + this.label = createUiText(scene, 0, 0, '', { + fontSize: '14px', + color: INK_CSS + }).setOrigin(0.5, 1); + + this.container = scene.add + .container(0, 0, [this.chrome, this.label]) + .setDepth(DEPTH.presence + 1) + .setVisible(false); + } + + setText(text: string): void { + if (text === this.textKey) return; + this.textKey = text; + this.label.setText(text); + + const width = this.label.width + 22; + const height = Math.ceil(this.label.height) + 12; + const top = -height; + + this.chrome.clear(); + this.chrome.fillStyle(PAPER, 0.96); + this.chrome.fillRect(-width / 2, top, width, height); + this.chrome.lineStyle(2.2, INK, 0.85); + this.chrome.strokeRect(-width / 2, top, width, height); + this.chrome.fillStyle(PAPER, 1); + this.chrome.fillTriangle(-9, top + height - 2, 5, top + height - 2, -4, top + height + 10); + this.chrome.lineStyle(2.2, INK, 0.85); + this.chrome.lineBetween(-9, top + height - 1, -4, top + height + 10); + this.chrome.lineBetween(-4, top + height + 10, 5, top + height - 1); + + this.label.setY(-8); + } + + place(x: number, y: number, alpha: number): void { + this.container + .setPosition(Math.round(x), Math.round(y)) + .setAlpha(alpha) + .setVisible(alpha > 0.02); + } + + hide(): void { + this.container.setVisible(false); + } + + destroy(): void { + this.container.destroy(); + } +} diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 6fd0935..58bb8af 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -2,56 +2,129 @@ // fallow-ignore-file complexity import type * as Phaser from 'phaser'; import type { RidgeFacing } from '@/game/core/ridge'; +import { drawContactShadow, drawInkBlob } from './atmosphere'; +import { INK, PAPER, PAPER_WARM } from './palette'; -const INK = 0x1a1a1a; -const PAPER = 0xfbfbf9; +/** Thick outer contour, lighter interior marks — the house line-weight rule. */ +const CONTOUR = 3.2; +const DETAIL = 1.6; + +/** + * Stepped pose for a figure. Frames advance on the ~11 FPS sketch clock, and a + * figure is only redrawn when its pose key actually changes. + */ +export interface StickPose { + frame: number; + walking: boolean; + talking: boolean; +} + +const STILL: StickPose = { frame: 0, walking: false, talking: false }; + +/** Walk cycle: contact, pass, contact, pass. */ +function legPhaseOf(pose: StickPose): number { + if (!pose.walking) return 0; + return [1, 0, -1, 0][pose.frame % 4] ?? 0; +} + +/** Figures rise slightly on the passing frames so the walk has bounce. */ +function bodyLiftOf(pose: StickPose, s: number): number { + if (!pose.walking) return 0; + return pose.frame % 2 === 1 ? -s * 0.07 : 0; +} export function drawStickPlayer( g: Phaser.GameObjects.Graphics, x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): void { const s = 18 * scale; const dir = facing === 'left' ? -1 : 1; - g.lineStyle(3, INK, 1); - g.fillStyle(PAPER, 1); + const lift = bodyLiftOf(pose, s); - g.fillCircle(x, y - s * 1.7, s * 0.45); - g.strokeCircle(x, y - s * 1.7, s * 0.45); - g.lineBetween(x, y - s * 1.25, x, y - s * 0.2); - 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); - 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); - // 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); + drawContactShadow(g, x, y, 34 * scale, 0.18); + drawBasePerson(g, x, y + lift, facing, scale, pose, { + hair: 'messy', + scarf: true, + eyes: 'determined' + }); + + const top = y + lift; + + // Signature travel pack, worn on the trailing shoulder. + g.lineStyle(CONTOUR - 0.6, INK, 1); + g.fillStyle(PAPER_WARM, 1); + g.fillRect(x - dir * s * 0.62, top - s * 1.12, s * 0.4, s * 0.58); + g.strokeRect(x - dir * s * 0.62, top - s * 1.12, s * 0.4, s * 0.58); + g.lineStyle(DETAIL, INK, 0.6); + g.lineBetween(x - dir * s * 0.62, top - s * 0.96, x - dir * s * 0.22, top - s * 0.96); + g.strokeCircle(x - dir * s * 0.42, top - s * 0.76, s * 0.06); + + // Scarf tail, trailing further when walking. + const tail = pose.walking ? 0.85 : 0.62; + g.lineStyle(2.8, INK, 1); + g.beginPath(); + g.moveTo(x, top - s * 1.26); + g.lineTo(x - dir * s * 0.42, top - s * 1.14); + g.lineTo(x - dir * s * tail, top - s * (pose.walking ? 1.12 : 0.95)); + g.strokePath(); } export function drawStickCicka( g: Phaser.GameObjects.Graphics, x: number, y: number, - scale = 1 + scale = 1, + pose: StickPose = STILL ): void { const s = 12 * scale; - g.lineStyle(2.5, INK, 1); + drawContactShadow(g, x, y, 28 * scale, 0.14); + + g.lineStyle(CONTOUR - 0.7, INK, 1); g.fillStyle(PAPER, 1); - 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); - 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); - 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); + + drawInkBlob(g, x, y - s * 0.4, s * 0.82, s * 0.5, 11, 10); + g.fillPath(); + g.strokePath(); + + g.fillCircle(x + s * 0.7, y - s * 0.8, s * 0.5); + g.strokeCircle(x + s * 0.7, y - s * 0.8, s * 0.5); + + // Ears flick between frames — the cheapest sign of a living animal. + const flick = pose.frame % 8 === 0 ? s * 0.12 : 0; g.beginPath(); - g.moveTo(x - s * 0.75, y - s * 0.35); - g.lineTo(x - s * 1.2, y - s * 0.9); + g.moveTo(x + s * 0.44, y - s * 1.1); + g.lineTo(x + s * 0.34 - flick, y - s * 1.54); + g.lineTo(x + s * 0.66, y - s * 1.16); g.strokePath(); + g.beginPath(); + g.moveTo(x + s * 0.86, y - s * 1.1); + g.lineTo(x + s * 0.99 + flick, y - s * 1.54); + g.lineTo(x + s * 0.73, y - s * 1.16); + g.strokePath(); + + g.fillStyle(INK, 1); + g.fillCircle(x + s * 0.86, y - s * 0.85, s * 0.09); + g.lineStyle(DETAIL, INK, 1); + g.lineBetween(x + s * 0.96, y - s * 0.8, x + s * 1.03, y - s * 0.77); + g.lineBetween(x + s * 0.93, y - s * 0.74, x + s * 1.28, y - s * 0.85); + g.lineBetween(x + s * 0.93, y - s * 0.69, x + s * 1.28, y - s * 0.64); + + // Tail sweeps on the stepped clock. + const sweep = Math.sin(pose.frame * 0.35) * s * 0.3; + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.beginPath(); + g.moveTo(x - s * 0.78, y - s * 0.4); + g.lineTo(x - s * 1.14 - sweep * 0.5, y - s * 0.85); + g.lineTo(x - s * 0.96 - sweep, y - s * 1.3); + g.strokePath(); + + g.fillStyle(INK, 1); + g.fillCircle(x - s * 0.3, y + s * 0.08, s * 0.12); + g.fillCircle(x + s * 0.3, y + s * 0.08, s * 0.12); } export function drawStickDraftsperson( @@ -59,15 +132,29 @@ export function drawStickDraftsperson( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): void { const s = 17 * scale; const dir = facing === 'left' ? -1 : 1; - 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); + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { + hair: 'messy', + glasses: true, + eyes: 'thoughtful' + }); + + // Blueprint roll tucked under the arm. + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.42, y - s * 1.06, s * 0.92, s * 0.56); + g.strokeRect(x + dir * s * 0.42, y - s * 1.06, s * 0.92, s * 0.56); + g.lineStyle(DETAIL, INK, 0.45); + g.lineBetween(x + dir * s * 0.5, y - s * 0.86, x + dir * s * 1.2, y - s * 0.86); + g.lineBetween(x + dir * s * 0.5, y - s * 0.72, x + dir * s * 1.05, y - s * 0.72); + + g.lineStyle(2, INK, 1); + g.lineBetween(x - dir * s * 0.15, y - s * 1.86, x + dir * s * 0.35, y - s * 1.96); } export function drawStickToyCar( @@ -77,20 +164,46 @@ export function drawStickToyCar( scale = 1 ): void { const s = 8 * scale; - g.lineStyle(2, INK, 1); + g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); g.fillRect(x - s, y - s * 0.7, s * 2, s * 0.8); g.strokeRect(x - s, y - s * 0.7, s * 2, s * 0.8); 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); + g.lineBetween(x - s * 0.2, y - s * 0.7, x + s * 0.2, y - s * 1.1); + g.lineBetween(x + s * 0.2, y - s * 1.1, x + s * 0.7, y - s * 0.7); } interface PersonStyle { - hair?: 'messy' | 'bun' | 'cap' | 'hat' | 'ponytail'; + hair?: 'messy' | 'bun' | 'cap' | 'hat' | 'ponytail' | 'beanie'; skirt?: boolean; apron?: boolean; raisedArm?: boolean; walkingStick?: boolean; + glasses?: boolean; + scarf?: boolean; + eyes?: 'determined' | 'thoughtful' | 'happy' | 'focused'; +} + +/** + * Shared NPC place-and-pose: shadow, base person, and facing metrics. + * Keeps the per-role drawers from cloning the same 12-line preamble. + */ +function placeStickPerson( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + facing: RidgeFacing, + scale: number, + pose: StickPose, + style: PersonStyle = {}, + personScale = scale +): { s: number; dir: number } { + const s = 16 * scale; + const dir = facing === 'left' ? -1 : 1; + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, personScale, pose, style); + return { s, dir }; } function drawBasePerson( @@ -99,63 +212,211 @@ function drawBasePerson( y: number, facing: RidgeFacing, scale: number, + pose: StickPose, style: PersonStyle = {} ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; - g.lineStyle(3, INK, 1); + const leg = legPhaseOf(pose); + const headY = y - s * 1.68; + + // Shadow mass first, so contour ink always sits on top of it. + g.fillStyle(INK, 0.1); + g.fillRect(x - dir * s * 0.05, y - s * 1.3, dir * s * 0.24, s * 1.15); + + g.lineStyle(CONTOUR, INK, 1); g.fillStyle(PAPER, 1); + g.fillCircle(x, headY, s * 0.44); + g.strokeCircle(x, headY, s * 0.44); + + drawFace(g, x, headY, dir, s, style, pose); + drawHair(g, x, headY, dir, s, style.hair); + + // Torso + g.lineStyle(CONTOUR, INK, 1); + g.lineBetween(x, y - s * 1.26, x, y - s * 0.15); - 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') { + if (style.scarf) { + g.fillStyle(INK, 0.22); + g.fillRect(x - s * 0.26, y - s * 1.34, s * 0.52, s * 0.19); + g.lineStyle(DETAIL, INK, 0.8); + g.strokeRect(x - s * 0.26, y - s * 1.34, s * 0.52, s * 0.19); + g.lineStyle(CONTOUR, INK, 1); + } + + drawArms(g, x, y, dir, s, style, pose, leg); + + // Legs + g.lineStyle(CONTOUR, INK, 1); + if (style.skirt) { + g.fillStyle(PAPER, 1); 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.moveTo(x, y - s * 0.6); + g.lineTo(x - s * 0.58, y + s * 0.2); + g.lineTo(x + s * 0.58, y + s * 0.2); + g.closePath(); + g.fillPath(); g.strokePath(); + g.lineBetween(x - s * 0.2, y + s * 0.2, x - s * 0.24 + s * 0.4 * leg, y + s * 0.58); + g.lineBetween(x + s * 0.2, y + s * 0.2, x + s * 0.24 - s * 0.4 * leg, y + s * 0.58); + } else { + g.lineBetween(x, y - s * 0.15, x + s * (0.34 + 0.28 * leg), y + s * 0.58); + g.lineBetween(x, y - s * 0.15, x - s * (0.34 - 0.28 * leg), y + s * 0.58); } - g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); + if (style.apron) { + g.lineStyle(DETAIL + 0.4, INK, 0.9); + g.strokeRect(x - s * 0.3, y - s * 0.95, s * 0.6, s * 0.72); + g.lineStyle(DETAIL, INK, 0.4); + g.lineBetween(x - s * 0.3, y - s * 0.6, x + s * 0.3, y - s * 0.6); + } - 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); + if (style.walkingStick) { + g.lineStyle(2.6, INK, 1); + g.lineBetween(x + dir * s * 0.72, y - s * 0.58, x + dir * s * 0.88, y + s * 0.58); + } +} + +function drawFace( + g: Phaser.GameObjects.Graphics, + x: number, + headY: number, + dir: number, + s: number, + style: PersonStyle, + pose: StickPose +): void { + g.fillStyle(INK, 1); + if (style.eyes === 'happy') { + g.lineStyle(2, INK, 1); + g.beginPath(); + g.moveTo(x + dir * s * 0.08, headY - s * 0.04); + g.lineTo(x + dir * s * 0.17, headY - s * 0.12); + g.lineTo(x + dir * s * 0.26, headY - s * 0.04); + g.strokePath(); + } else if (style.eyes === 'thoughtful') { + g.lineStyle(2, INK, 1); + g.lineBetween(x + dir * s * 0.06, headY - s * 0.16, x + dir * s * 0.27, headY - s * 0.11); + g.fillCircle(x + dir * s * 0.19, headY - s * 0.01, s * 0.06); + } else if (style.eyes === 'focused') { + g.lineStyle(2.2, INK, 1); + g.lineBetween(x + dir * s * 0.08, headY - s * 0.14, x + dir * s * 0.28, headY - s * 0.14); + g.fillCircle(x + dir * s * 0.2, headY - s * 0.02, s * 0.07); } 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); + g.fillCircle(x + dir * s * 0.19, headY - s * 0.03, s * 0.075); } - 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); + // Mouth: open on alternating frames while talking. + g.lineStyle(DETAIL, INK, 0.85); + if (pose.talking && pose.frame % 2 === 0) { + g.fillStyle(INK, 0.85); + g.fillCircle(x + dir * s * 0.24, headY + s * 0.19, s * 0.07); } 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); + g.lineBetween(x + dir * s * 0.14, headY + s * 0.2, x + dir * s * 0.3, headY + s * 0.19); } - if (style.apron) { - g.strokeRect(x - s * 0.28, y - s * 0.95, s * 0.56, s * 0.7); + if (style.glasses) { + g.lineStyle(2, INK, 1); + g.strokeCircle(x + dir * s * 0.19, headY - s * 0.03, s * 0.15); + g.lineBetween(x, headY - s * 0.03, x + dir * s * 0.05, headY - s * 0.03); } +} - 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); +function drawHair( + g: Phaser.GameObjects.Graphics, + x: number, + headY: number, + dir: number, + s: number, + hair: PersonStyle['hair'] +): void { + g.lineStyle(CONTOUR, INK, 1); + if (hair === 'messy') { + // A capped fringe rather than raised spikes, which read as horns or ears. + g.fillStyle(INK, 0.9); + g.beginPath(); + g.moveTo(x - s * 0.45, headY - s * 0.1); + g.lineTo(x - s * 0.34, headY - s * 0.42); + g.lineTo(x - s * 0.02, headY - s * 0.5); + g.lineTo(x + s * 0.3, headY - s * 0.4); + g.lineTo(x + s * 0.45, headY - s * 0.08); + g.lineTo(x + s * 0.24, headY - s * 0.28); + g.lineTo(x - s * 0.12, headY - s * 0.2); + g.closePath(); + g.fillPath(); + g.lineStyle(1.8, INK, 0.8); + g.lineBetween(x - s * 0.2, headY - s * 0.46, x - s * 0.3, headY - s * 0.62); + g.lineStyle(CONTOUR, INK, 1); + } else if (hair === 'bun') { + g.fillStyle(PAPER, 1); + g.fillCircle(x - dir * s * 0.34, headY - s * 0.3, s * 0.22); + g.strokeCircle(x - dir * s * 0.34, headY - s * 0.3, s * 0.22); + g.lineStyle(DETAIL, INK, 0.5); + g.lineBetween(x - s * 0.3, headY - s * 0.36, x + s * 0.3, headY - s * 0.36); + } else if (hair === 'beanie') { + g.fillStyle(INK, 0.85); + g.beginPath(); + g.moveTo(x - s * 0.46, headY - s * 0.14); + g.lineTo(x - s * 0.36, headY - s * 0.56); + g.lineTo(x + s * 0.36, headY - s * 0.56); + g.lineTo(x + s * 0.46, headY - s * 0.14); + g.closePath(); + g.fillPath(); + g.strokePath(); + } else if (hair === 'cap') { + g.fillStyle(INK, 0.8); + g.fillRect(x - s * 0.38, headY - s * 0.52, s * 0.76, s * 0.36); + g.strokeRect(x - s * 0.38, headY - s * 0.52, s * 0.76, s * 0.36); + g.lineStyle(CONTOUR, INK, 1); + g.lineBetween(x - s * 0.1, headY - s * 0.16, x + dir * s * 0.72, headY - s * 0.2); + } else if (hair === 'hat') { + g.fillStyle(PAPER, 1); + g.fillRect(x - s * 0.3, headY - s * 0.62, s * 0.6, s * 0.42); + g.strokeRect(x - s * 0.3, headY - s * 0.62, s * 0.6, s * 0.42); + g.lineBetween(x - s * 0.6, headY - s * 0.2, x + s * 0.6, headY - s * 0.2); + } else if (hair === 'ponytail') { + g.fillStyle(PAPER, 1); + g.beginPath(); + g.moveTo(x - dir * s * 0.3, headY - s * 0.34); + g.lineTo(x - dir * s * 0.74, headY + s * 0.1); + g.lineTo(x - dir * s * 0.5, headY + s * 0.18); + g.closePath(); + g.fillPath(); + g.strokePath(); + } +} + +function drawArms( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + dir: number, + s: number, + style: PersonStyle, + pose: StickPose, + leg: number +): void { + const shoulder = y - s * 0.98; + g.lineStyle(CONTOUR, INK, 1); + + if (style.raisedArm) { + const wave = pose.frame % 2 === 0 ? 0.1 : -0.06; + g.lineBetween(x, shoulder, x + dir * s * 0.58, shoulder - s * (0.5 + wave)); + g.lineBetween(x, shoulder, x - dir * s * 0.58, shoulder + s * (0.42 - wave)); + return; } + + if (pose.talking) { + // A small gesture beat while speaking. + const gesture = pose.frame % 2 === 0 ? 0.34 : 0.2; + g.lineBetween(x, shoulder, x + dir * s * 0.6, shoulder - s * gesture); + g.lineBetween(x, shoulder, x - dir * s * 0.48, shoulder + s * 0.44); + return; + } + + // Arms counter-swing against the legs. + g.lineBetween(x, shoulder, x + s * (0.62 * dir - 0.3 * leg), shoulder + s * 0.42); + g.lineBetween(x, shoulder, x - s * (0.46 * dir + 0.3 * leg), shoulder + s * 0.44); } export function drawStickGuitarist( @@ -163,28 +424,54 @@ export function drawStickGuitarist( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): 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); + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { + hair: 'beanie', + eyes: 'thoughtful' + }); + + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.fillStyle(PAPER, 1); + drawInkBlob(g, x + dir * s * 0.56, y - s * 0.5, s * 0.36, s * 0.52, 3, 10); + g.fillPath(); + g.strokePath(); + g.fillStyle(INK, 0.9); + g.fillCircle(x + dir * s * 0.56, y - s * 0.5, s * 0.13); + + g.lineStyle(CONTOUR - 0.8, INK, 1); + g.lineBetween(x + dir * s * 0.56, y - s * 0.96, x + dir * s * 0.56, y - s * 1.52); + g.strokeRect(x + dir * s * 0.46, y - s * 1.7, s * 0.2, s * 0.18); + + g.lineStyle(DETAIL, INK, 0.6); + g.lineBetween(x - dir * s * 0.3, y - s * 1.16, x + dir * s * 0.62, y - s * 0.36); + + // Arm bandage — the reason this whole beat exists. + g.lineStyle(3.4, INK, 0.35); + g.lineBetween(x + dir * s * 0.34, y - s * 0.72, x + dir * s * 0.56, y - s * 0.56); } export function drawStickCrowd( g: Phaser.GameObjects.Graphics, x: number, y: number, - scale = 1 + scale = 1, + pose: StickPose = STILL ): 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' }); + // Offset frames so the little group never moves in lockstep. + const at = (offset: number): StickPose => ({ + frame: pose.frame + offset, + walking: false, + talking: pose.talking + }); + drawBasePerson(g, x - 20 * scale, y, 'left', scale * 0.8, at(1), { hair: 'cap' }); + drawBasePerson(g, x, y, 'right', scale * 0.92, at(0), { + hair: 'messy', + eyes: 'happy', + raisedArm: true + }); + drawBasePerson(g, x + 22 * scale, y, 'left', scale * 0.76, at(2), { hair: 'ponytail' }); } export function drawStickGuitar( @@ -195,9 +482,13 @@ export function drawStickGuitar( ): 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); + g.fillStyle(PAPER, 1); + drawInkBlob(g, x, y - s * 0.2, s * 0.42, s * 0.62, 5, 10); + g.fillPath(); + g.strokePath(); + g.strokeCircle(x, y - s * 0.2, s * 0.15); + g.lineBetween(x, y - s * 0.8, x, y - s * 1.6); + g.strokeRect(x - s * 0.15, y - s * 1.8, s * 0.3, s * 0.25); } export function drawStickTraveler( @@ -205,14 +496,20 @@ export function drawStickTraveler( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): 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); + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { + hair: 'ponytail', + walkingStick: true, + eyes: 'happy' + }); + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.fillStyle(PAPER_WARM, 1); + g.fillRect(x - dir * s * 0.58, y - s * 1.12, s * 0.4, s * 0.58); + g.strokeRect(x - dir * s * 0.58, y - s * 1.12, s * 0.4, s * 0.58); + g.lineStyle(DETAIL, INK, 0.5); + g.lineBetween(x - dir * s * 0.58, y - s * 0.86, x - dir * s * 0.18, y - s * 0.86); } export function drawStickDriver( @@ -220,18 +517,23 @@ export function drawStickDriver( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): 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 + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { + hair: 'cap', + eyes: 'focused' + }); + g.lineStyle(CONTOUR - 0.7, INK, 1); 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); + g.fillRect(x + dir * s * 0.42, y - s * 1.12, s * 0.58, s * 0.78); + g.strokeRect(x + dir * s * 0.42, y - s * 1.12, s * 0.58, s * 0.78); + g.fillStyle(INK, 0.8); + g.fillRect(x + dir * s * 0.56, y - s * 1.22, s * 0.26, s * 0.13); + g.lineStyle(DETAIL, INK, 0.55); + g.lineBetween(x + dir * s * 0.5, y - s * 0.88, x + dir * s * 0.9, y - s * 0.88); + g.lineBetween(x + dir * s * 0.5, y - s * 0.72, x + dir * s * 0.84, y - s * 0.72); + g.lineBetween(x + dir * s * 0.5, y - s * 0.56, x + dir * s * 0.88, y - s * 0.56); } export function drawStickOperationsHelper( @@ -239,19 +541,33 @@ export function drawStickOperationsHelper( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): 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); + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { + hair: 'ponytail', + apron: true, + eyes: 'happy' + }); + + // Lantern swings gently on the stepped clock. + const swing = Math.sin(pose.frame * 0.3) * s * 0.08; + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.lineBetween(x + dir * s * 0.56, y - s * 0.58, x + dir * s * 0.72 + swing, y - s * 1.18); + g.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.52 + swing, y - s * 1.54, s * 0.44, s * 0.44); + g.strokeRect(x + dir * s * 0.52 + swing, y - s * 1.54, s * 0.44, s * 0.44); + g.lineBetween( + x + dir * s * 0.74 + swing, + y - s * 1.54, + x + dir * s * 0.74 + swing, + y - s * 1.72 + ); + g.lineStyle(DETAIL, INK, 0.5); + g.lineBetween(x + dir * s * 0.6 + swing, y - s * 1.42, x + dir * s * 0.88 + swing, y - s * 1.2); + g.lineStyle(1.3, INK, 0.32); + g.lineBetween(x + dir * s * 1.02 + swing, y - s * 1.34, x + dir * s * 1.34, y - s * 1.44); + g.lineBetween(x + dir * s * 1.02 + swing, y - s * 1.1, x + dir * s * 1.34, y - s * 1.02); } export function drawStickDanceTeacher( @@ -259,12 +575,15 @@ export function drawStickDanceTeacher( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): void { - drawBasePerson(g, x, y, facing, scale * 1.08, { + drawContactShadow(g, x, y, 34 * scale); + drawBasePerson(g, x, y, facing, scale * 1.08, pose, { hair: 'bun', skirt: true, - raisedArm: true + raisedArm: true, + eyes: 'happy' }); } @@ -273,15 +592,14 @@ export function drawStickSteward( x: number, y: number, facing: RidgeFacing, - scale = 1 + scale = 1, + pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale * 1.05, { hair: 'hat' }); - // key on belt + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { hair: 'hat' }, scale * 1.05); 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); + g.strokeCircle(x + dir * s * 0.36, y - s * 0.2, s * 0.12); + g.lineBetween(x + dir * s * 0.36, y - s * 0.08, x + dir * s * 0.36, y + s * 0.15); + g.lineBetween(x + dir * s * 0.36, y + s * 0.06, x + dir * s * 0.46, y + s * 0.06); } export function drawStickShuttle( @@ -291,14 +609,21 @@ export function drawStickShuttle( scale = 1 ): void { const s = 14 * scale; - g.lineStyle(2.5, INK, 1); + g.lineStyle(CONTOUR - 0.7, INK, 1); + g.fillStyle(PAPER_WARM, 1); + g.fillRect(x - s * 1.6, y - s * 1.05, s * 3.2, s * 0.95); + g.strokeRect(x - s * 1.6, y - s * 1.05, s * 3.2, s * 0.95); + g.fillStyle(PAPER, 1); + for (let i = 0; i < 3; i += 1) { + g.fillRect(x - s * 1.34 + i * s * 0.78, y - s * 0.9, s * 0.6, s * 0.42); + g.strokeRect(x - s * 1.34 + i * s * 0.78, y - s * 0.9, s * 0.6, s * 0.42); + } + g.fillStyle(INK, 0.85); + g.fillCircle(x - s, y + s * 0.1, s * 0.3); + g.fillCircle(x + s, y + s * 0.1, s * 0.3); 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); + g.fillCircle(x - s, y + s * 0.1, s * 0.12); + g.fillCircle(x + s, y + s * 0.1, s * 0.12); + g.lineStyle(2, INK, 0.65); + g.lineBetween(x - s * 0.4, y - s * 0.34, x + s * 0.9, y - s * 0.34); } diff --git a/src/game/scenes/ridge/art/types.ts b/src/game/scenes/ridge/art/types.ts index f3daec1..66d8385 100644 --- a/src/game/scenes/ridge/art/types.ts +++ b/src/game/scenes/ridge/art/types.ts @@ -1,9 +1,12 @@ import type { + RidgeActorId, RidgeActorPresence, RidgeAreaId, + RidgeInteractable, RidgeMode, RidgeObservation } from '@/game/core/ridge'; +import { actorIdForSpeaker } from '../content/castRegistry'; /** * Replaceable art seam. @@ -16,7 +19,10 @@ export interface RidgeVisualViewModel { facing: RidgeObservation['facing']; beat: RidgeObservation['beat']; ambience: string; - nearbyPrompt: string | null; + /** Nearest interactable from core; presentation anchors the pip to it. */ + focus: RidgeInteractable | null; + /** Who is speaking right now, so the world can animate their mouth. */ + speakingActorId: RidgeActorId | null; actors: readonly RidgeActorPresence[]; crossingOpen: boolean; } @@ -42,7 +48,10 @@ export function toRidgeVisualViewModel( facing: observation.facing, beat: observation.beat, ambience: observation.ambience, - nearbyPrompt: observation.nearby[0]?.prompt ?? null, + focus: observation.nearby[0] ?? null, + speakingActorId: observation.conversation + ? actorIdForSpeaker(observation.conversation.speakerId) + : null, actors: observation.actors, crossingOpen }; diff --git a/src/game/scenes/ridge/content/castRegistry.ts b/src/game/scenes/ridge/content/castRegistry.ts new file mode 100644 index 0000000..7a6997a --- /dev/null +++ b/src/game/scenes/ridge/content/castRegistry.ts @@ -0,0 +1,60 @@ +import type { RidgeActorId } from '@/game/core/ridge'; + +/** + * Portrait chip shown in the conversation panel. + * Narrator / dedication share `prompt`; several roles reuse a nearby face. + */ +export type RidgePortraitId = + | 'player' + | 'cicka' + | 'draftsperson' + | 'guitarist' + | 'driver' + | 'traveler' + | 'teacher' + | 'prompt'; + +interface CastSpeakerEntry { + /** Stage actor that mouths the line; omit for narrator-style voices. */ + actorId: RidgeActorId | null; + portrait: RidgePortraitId; +} + +/** + * Authored dialogue `speakerId` → stage actor + panel portrait. + * + * One table so mouth animation, camera framing, and the React panel cannot drift. + */ +const SPEAKER_CAST: Readonly> = { + player: { actorId: 'player', portrait: 'player' }, + cicka: { actorId: 'cicka', portrait: 'cicka' }, + counterpartCat: { actorId: 'counterpart-cat', portrait: 'cicka' }, + 'counterpart-cat': { actorId: 'counterpart-cat', portrait: 'cicka' }, + bridgeDraftsperson: { actorId: 'draftsperson', portrait: 'draftsperson' }, + draftsperson: { actorId: 'draftsperson', portrait: 'draftsperson' }, + injuredGuitarist: { actorId: 'guitarist', portrait: 'guitarist' }, + guitarist: { actorId: 'guitarist', portrait: 'guitarist' }, + crowd: { actorId: 'crowd', portrait: 'player' }, + danceDriver: { actorId: 'driver', portrait: 'driver' }, + hillShuttleDriver: { actorId: 'driver', portrait: 'driver' }, + driver: { actorId: 'driver', portrait: 'driver' }, + operationsHelper: { actorId: 'operations-helper', portrait: 'driver' }, + danceTeacher: { actorId: 'dance-teacher', portrait: 'teacher' }, + traveler: { actorId: 'traveler', portrait: 'traveler' }, + steward: { actorId: 'steward', portrait: 'traveler' }, + festivalSteward: { actorId: 'steward', portrait: 'traveler' }, + prompt: { actorId: null, portrait: 'prompt' }, + dedication: { actorId: null, portrait: 'prompt' } +}; + +const DEFAULT_PORTRAIT: RidgePortraitId = 'player'; + +/** Actor on stage for a dialogue speaker, or null for narrator voices. */ +export function actorIdForSpeaker(speakerId: string): RidgeActorId | null { + return SPEAKER_CAST[speakerId]?.actorId ?? null; +} + +/** Conversation-panel portrait for a dialogue speaker. */ +export function portraitForSpeaker(speakerId: string): RidgePortraitId { + return SPEAKER_CAST[speakerId]?.portrait ?? DEFAULT_PORTRAIT; +} diff --git a/src/game/scenes/ridge/content/presenceCatalog.ts b/src/game/scenes/ridge/content/presenceCatalog.ts new file mode 100644 index 0000000..f274974 --- /dev/null +++ b/src/game/scenes/ridge/content/presenceCatalog.ts @@ -0,0 +1,16 @@ +import type { RidgeActorId } from '@/game/core/ridge'; +import { getMessages } from '@/shared/i18n'; +import type { BarkLines } from '../art/stick/barkDirector'; + +export interface RidgePresenceCatalog { + /** Short role tag under a resident's name. */ + roles: Partial>; + /** Lines a resident mutters as the player walks past. */ + barks: BarkLines; +} + +/** Wires i18n presence copy into the Ridge world chrome. */ +export function loadRidgePresenceCatalog(): RidgePresenceCatalog { + const { roles, barks } = getMessages().scenes.ridge.presence; + return { roles, barks: barks as BarkLines }; +} diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index 9bb193e..60bdbc1 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -10,10 +10,12 @@ import { RidgeConsoleSession, RIDGE_GUITAR_ITEM, RIDGE_INITIAL_BEAT, + ridgePrevArea, type RidgeAreaId, type RidgeCommandResult, type RidgeSessionEvent } from '@/game/core/ridge'; +import { portraitForSpeaker } from '../content/castRegistry'; import { type OverlayId } from '@/game/overlays/overlayIds'; import { PHASER_SCENE_KEYS, RIDGE_SCENE_ID } from '@/game/scenes/sceneIds'; import { @@ -27,6 +29,7 @@ import { import { bindSideViewKeyboard } from '@/game/sharedSceneRuntime/input/sceneKeyboard'; import { StickVisualProvider } from '../art/stick/StickVisualProvider'; import { toRidgeVisualViewModel } from '../art/types'; +import { loadRidgePresenceCatalog } from '../content/presenceCatalog'; import { loadRidgeRouteDialogueCatalog } from '../content/routeCatalog'; import type { RidgeConversationPanelView } from '../sceneUi/RidgeConversationPanel'; import type { RidgeDevControls } from './ridgeDevControls'; @@ -53,14 +56,10 @@ export class RidgeScene extends Phaser.Scene { private isPaused = false; private getRidgeDevControls?: () => RidgeDevControls | undefined; private lastConversationKey: string | null = null; + private lastDevSnapshotKey = ''; 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; - }; + private nextKey?: Phaser.Input.Keyboard.Key; + private prevKey?: Phaser.Input.Keyboard.Key; constructor() { super(PHASER_SCENE_KEYS.ridge); @@ -88,18 +87,16 @@ export class RidgeScene extends Phaser.Scene { inventory: hasGuitar ? [RIDGE_GUITAR_ITEM] : [] }); - this.visuals = new StickVisualProvider(this); + const presence = loadRidgePresenceCatalog(); + this.visuals = new StickVisualProvider(this, { + roles: presence.roles, + barks: presence.barks + }); 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.nextKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.CLOSED_BRACKET); + this.prevKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.OPEN_BRACKET); } - this.cameras.main.setZoom(1); this.syncPresentation(); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.cleanup()); @@ -160,30 +157,15 @@ export class RidgeScene extends Phaser.Scene { private handleDevSkipKeys(): void { if (!import.meta.env.DEV || !this.session) return; - if (this.skipKey && Phaser.Input.Keyboard.JustDown(this.skipKey)) { + if (this.nextKey && Phaser.Input.Keyboard.JustDown(this.nextKey)) { 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')); + if (this.prevKey && Phaser.Input.Keyboard.JustDown(this.prevKey)) { + const currentArea = this.session.observe().areaId; + this.applyResult(this.session.exec(`warp ${ridgePrevArea(currentArea)}`)); this.syncPresentation(); } } @@ -246,13 +228,17 @@ export class RidgeScene extends Phaser.Scene { this.syncConversationUi(observation); if (import.meta.env.DEV) { - const controls = this.getRidgeDevControls?.(); - controls?.publishPlayerSnapshot?.({ - progress: observation.progress, - beat: observation.beat, - mode: observation.mode, - nearby: observation.nearby.map((item) => item.label) - }); + const nearbyLabels = observation.nearby.map((item) => item.label); + const snapshotKey = `${observation.progress.toFixed(3)}|${observation.beat}|${observation.mode}|${nearbyLabels.join(',')}`; + if (snapshotKey !== this.lastDevSnapshotKey) { + this.lastDevSnapshotKey = snapshotKey; + this.getRidgeDevControls?.()?.publishPlayerSnapshot?.({ + progress: observation.progress, + beat: observation.beat, + mode: observation.mode, + nearby: nearbyLabels + }); + } } } @@ -277,6 +263,7 @@ export class RidgeScene extends Phaser.Scene { speaker: c.speaker, speakerId: c.speakerId, text: c.text, + emotion: c.emotion, lineIndex: c.lineIndex, lineCount: c.lineCount, awaitingChoice: c.awaitingChoice, @@ -296,19 +283,8 @@ export class RidgeScene extends Phaser.Scene { this.visuals = undefined; this.session = undefined; this.keys = undefined; - this.skipKey = undefined; - this.warpKeys = undefined; + this.nextKey = undefined; + this.prevKey = undefined; this.lastConversationKey = null; } } - -function portraitForSpeaker( - speakerId: string -): RidgeConversationPanelView['portrait'] { - if (speakerId === 'cicka') return 'cicka'; - if (speakerId === 'bridgeDraftsperson' || speakerId === 'injuredGuitarist') { - return 'draftsperson'; - } - if (speakerId === 'prompt' || speakerId === 'dedication') return 'prompt'; - return 'player'; -} diff --git a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx index 4e9896b..288b604 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -1,6 +1,7 @@ -import { useEffect, useId } from 'react'; +import { useEffect, useId, useState } from 'react'; import type { SceneUiSurfaceProps } from '@/game/sceneUi/registry'; -import { Button, Card } from '@/shared/ui'; +import type { RidgeEmotion } from '@/game/core/ridge'; +import type { RidgePortraitId } from '../content/castRegistry'; export interface RidgeConversationChoiceView { id: string; @@ -16,8 +17,8 @@ export interface RidgeConversationPanelView { lineCount: number; awaitingChoice: boolean; choices: readonly RidgeConversationChoiceView[]; - /** Stick silhouette key for portrait frame. */ - portrait: 'player' | 'cicka' | 'draftsperson' | 'prompt'; + portrait: RidgePortraitId; + emotion?: RidgeEmotion; } export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfaceProps) { @@ -25,142 +26,286 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac const titleId = useId(); const textId = useId(); + const lineKey = view ? `${view.conversationId}:${view.lineIndex}:${view.text}` : ''; + const [typedLength, setTypedLength] = useState(0); + const [activeLineKey, setActiveLineKey] = useState(lineKey); + + // Reset the typewriter when the authored line changes — during render, not in + // an effect (react-hooks/set-state-in-effect). + if (lineKey !== activeLineKey) { + setActiveLineKey(lineKey); + setTypedLength(0); + } + + useEffect(() => { + if (!view) return; + const targetLen = view.text.length; + if (targetLen === 0) return; + + const interval = setInterval(() => { + setTypedLength((prev) => { + if (prev >= targetLen) { + clearInterval(interval); + return prev; + } + return prev + 1; + }); + }, 16); + + return () => clearInterval(interval); + }, [lineKey, view]); + + const isTyping = view ? typedLength < view.text.length : false; + useEffect(() => { - if (!view || view.awaitingChoice) return; + if (!view) return; const onKey = (event: KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ' || event.key === 'z' || event.key === 'Z') { - event.preventDefault(); - dispatchAction('ridgeConversationAdvance'); - } - if (event.key === 'Escape') { - event.preventDefault(); - dispatchAction('ridgeConversationLeave'); - } + handleConversationKey(event, view, isTyping, { + choose: (choiceId) => dispatchAction('ridgeConversationChoose', { choiceId }), + advance: () => dispatchAction('ridgeConversationAdvance'), + skipTypewriter: () => setTypedLength(view.text.length), + leave: () => dispatchAction('ridgeConversationLeave') + }); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [dispatchAction, view]); + }, [dispatchAction, view, isTyping]); if (!view) return null; + const visibleText = view.text.slice(0, typedLength); + + /** Same as Z/Space: skip the typewriter, or advance once the line is shown. */ + const handleBoxClick = () => { + if (view.awaitingChoice) return; + if (isTyping) { + setTypedLength(view.text.length); + return; + } + dispatchAction('ridgeConversationAdvance'); + }; + + // Stay in-flow: SceneUiHost already centers overlay panels with a transform + + // overflow clip. `position: fixed` here collapses against that host and renders + // as a clipped black strip while conversation mode freezes gameplay. return ( - - - -
-
-

- {view.speaker} -

-

- {view.lineIndex + 1}/{view.lineCount} -

+
+ {view.speaker} +
+ + + +
+
+ + {view.lineIndex + 1} / {view.lineCount} +

- {view.text} + {visibleText} + {isTyping ? | : null}

+ {/* Persona 5 Choice Cards */} {view.awaitingChoice && view.choices.length > 0 ? ( -
+
{view.choices.map((choice, index) => ( - + + {index + 1} + + + {choice.label} + + ))}
) : ( -
- - +
+
+ + +
+ + {!isTyping ? ( + + ▼ + + ) : null}
)}
- +
); } +interface ConversationKeyActions { + choose: (choiceId: string) => void; + advance: () => void; + skipTypewriter: () => void; + leave: () => void; +} + +/** Keyboard contract for the JRPG panel: 1–9 choose, Z/Space/Enter advance, Esc leave. */ +function handleConversationKey( + event: KeyboardEvent, + view: RidgeConversationPanelView, + isTyping: boolean, + actions: ConversationKeyActions +): void { + if (tryChoiceKey(event, view, actions.choose)) return; + if (tryAdvanceKey(event, view, isTyping, actions)) return; + if (event.key === 'Escape') { + event.preventDefault(); + actions.leave(); + } +} + +function tryChoiceKey( + event: KeyboardEvent, + view: RidgeConversationPanelView, + choose: (choiceId: string) => void +): boolean { + if (!view.awaitingChoice || view.choices.length === 0) return false; + if (event.key < '1' || event.key > '9') return false; + const index = parseInt(event.key, 10) - 1; + if (index >= view.choices.length) return false; + event.preventDefault(); + choose(view.choices[index].id); + return true; +} + +function tryAdvanceKey( + event: KeyboardEvent, + view: RidgeConversationPanelView, + isTyping: boolean, + actions: Pick +): boolean { + if (view.awaitingChoice) return false; + if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'z' && event.key !== 'Z') { + return false; + } + event.preventDefault(); + if (isTyping) actions.skipTypewriter(); + else actions.advance(); + return true; +} + function PortraitFrame({ portrait, + emotion = 'neutral', speaker }: { portrait: RidgeConversationPanelView['portrait']; + emotion?: RidgeEmotion; speaker: string; }) { return (
- - {portrait === 'cicka' ? : null} - {portrait === 'draftsperson' ? : null} - {portrait === 'player' ? : null} +
+ + {portrait === 'cicka' ? : null} + {portrait === 'draftsperson' ? : null} + {portrait === 'guitarist' ? : null} + {portrait === 'driver' ? : null} + {portrait === 'traveler' ? : null} + {portrait === 'teacher' ? : null} + {portrait === 'player' ? : null} {portrait === 'prompt' ? : null}
); } -function CickaPortrait() { +function CickaPortrait({ emotion }: { emotion: RidgeEmotion }) { return ( - - - + + + + {emotion === 'sleepy' ? ( + <> + + + + ) : emotion === 'curious' ? ( + <> + + + + ) : ( + <> + + + + )} + + ); } -function DraftspersonPortrait() { +function DraftspersonPortrait({ emotion }: { emotion: RidgeEmotion }) { return ( - + + + + + {emotion === 'surprised' ? ( + + ) : emotion === 'thoughtful' ? ( + + ) : ( + + )} @@ -170,16 +315,97 @@ function DraftspersonPortrait() { ); } -function PlayerPortrait() { +function GuitaristPortrait({ emotion }: { emotion: RidgeEmotion }) { + return ( + + + + + + {emotion === 'playful' ? ( + + ) : ( + + )} + + + + + ); +} + +function DriverPortrait() { + return ( + + + + + + + + + + + + + ); +} + +function TravelerPortrait() { + return ( + + + + + + + + + + + ); +} + +function TeacherPortrait() { + return ( + + + + + + + + + + + ); +} + +function PlayerPortrait({ emotion }: { emotion: RidgeEmotion }) { return ( - - - - - - - + + + + + {emotion === 'determined' ? ( + <> + + + + + + ) : ( + <> + + + + + )} + + + + ); } diff --git a/src/game/shell/InteractiveApp.test.tsx b/src/game/shell/InteractiveApp.test.tsx index 342b7e1..c58dbfc 100644 --- a/src/game/shell/InteractiveApp.test.tsx +++ b/src/game/shell/InteractiveApp.test.tsx @@ -703,6 +703,40 @@ describe('InteractiveApp', () => { }); }); + it('keeps Ridge conversation panel in-flow inside the scene UI overlay host', async () => { + bridgeActions.enterScene(RIDGE_SCENE_ID); + bridgeActions.setSceneUiPanel(RIDGE_SCENE_ID, 'ridgeConversation', { + conversationId: 'cicka-intro', + speaker: 'Cicka', + speakerId: 'cicka', + text: 'Soft footfalls on the page.', + lineIndex: 0, + lineCount: 2, + awaitingChoice: false, + choices: [], + portrait: 'cicka' + }); + render(); + + const dialog = screen.getByRole('dialog', { name: /cicka/i }); + const overlayHost = screen.getByTestId('scene-ui-panel-overlay'); + + // Fixed + host transform/overflow clips the dialog into a black strip mid-CRT. + expect(dialog.className).not.toMatch(/\bfixed\b/); + expect(overlayHost.contains(dialog)).toBe(true); + expect(within(dialog).getByRole('button', { name: /continue/i })).toBeDefined(); + expect(within(dialog).getByRole('button', { name: /step back/i })).toBeDefined(); + + // First click finishes the typewriter; second advances the line. + await userEvent.click(within(dialog).getByRole('button', { name: /continue/i })); + await userEvent.click(within(dialog).getByRole('button', { name: /continue/i })); + + expect(bridgeStore.getState().sceneUi.lastAction).toMatchObject({ + ownerSceneId: RIDGE_SCENE_ID, + action: 'ridgeConversationAdvance' + }); + }); + it('switches to static mode directly from the toolbar', async () => { const onSwitchToStatic = vi.fn(); render(); diff --git a/src/shared/i18n/messages/en/scenes.ts b/src/shared/i18n/messages/en/scenes.ts index ab6c065..5428d94 100644 --- a/src/shared/i18n/messages/en/scenes.ts +++ b/src/shared/i18n/messages/en/scenes.ts @@ -37,6 +37,64 @@ export const sceneMessages = { stampedeMemory: "mrrp!", }, }, + presence: { + roles: { + cicka: "road cat", + "counterpart-cat": "local cat", + draftsperson: "bridge draftsperson", + guitarist: "touring guitarist", + crowd: "waiting crowd", + traveler: "long-way walker", + driver: "shuttle driver", + "operations-helper": "festival crew", + "dance-teacher": "dance teacher", + steward: "festival steward", + shuttle: "last shuttle", + }, + barks: { + cicka: ["mrrp.", "prrt?", "...mrow.", "*tail flick*"], + "counterpart-cat": ["mrow.", "*slow blink*", "prrp."], + draftsperson: [ + "...load-bearing, load-bearing...", + "It's the middle span. It's always the middle span.", + "Measure twice. Then measure again.", + "Paper holds. Paper always holds.", + ], + guitarist: [ + "Two chords. I only need two.", + "...still can't close my hand around it.", + "Hums something unfinished.", + "The crowd's been patient. Too patient.", + ], + crowd: [ + "Is it starting?", + "I walked an hour for this.", + "Shh — listen.", + "Someone play something.", + ], + traveler: [ + "Long way yet.", + "Feet know the road better than I do.", + "Which ridge was it again?", + ], + driver: [ + "Schedule says one thing, the road says another.", + "Last ride leaves at sundown.", + "Everyone accounted for?", + ], + "operations-helper": [ + "Lanterns up!", + "Mind the cables, mind the cables.", + "Almost set. Almost.", + ], + "dance-teacher": [ + "One step. Then the next one.", + "Loosen the shoulders.", + "You already know this part.", + ], + steward: ["The gate opens when it opens.", "Ticket? Ah — go on.", "Busy night."], + }, + }, bridge: { speakers: { prompt: "Prompt", @@ -44,31 +102,36 @@ export const sceneMessages = { bridgeDraftsperson: "Bridge Draftsperson", }, dialogue: { - "bridge.cicka.first_meet.01": "Sit near Cicka", - "bridge.cicka.first_meet.02": "Small chirp.", - "bridge.cicka.first_meet.03": "Cicka bats the tiny car back into place.", + "bridge.cicka.first_meet.prompt": "Pet Cicka", + "bridge.cicka.first_meet.01": "You approach Cicka resting in the warm sunlight.", + "bridge.cicka.first_meet.02": "Mrreeow! *Cicka swats a tiny wheeled toy back into place with feline precision*", + "bridge.cicka.first_meet.03": "Looks like Cicka is running a secret quality-assurance test with a toy car.", + "bridge.draftsperson.missing_span.prompt": "Talk to Draftsperson", "bridge.draftsperson.missing_span.01": - "The middle span keeps looking brave until I imagine someone crossing it.", + "My blueprint was looking legendary... until I realized nobody can actually walk across an imaginary line!", "bridge.draftsperson.missing_span.02": - "I had a tiny test car for this. It was here a minute ago.", - "bridge.draftsperson.missing_span.03": "Look for the tiny test car", - "bridge.cicka.parallel_play.01": "Sit with Cicka", - "bridge.cicka.parallel_play.02": "Roll the car back gently", - "bridge.cicka.parallel_play.03": "Quiet purr.", - "bridge.cicka.parallel_play.04": "Cicka leaves the tiny car beside you.", + "I had a tiny test car to verify the bridge span, but a mischievous furry assistant snatched it!", + "bridge.draftsperson.missing_span.03": "Offer to retrieve the test car from Cicka", + "bridge.cicka.parallel_play.prompt": "Play with Cicka", + "bridge.cicka.parallel_play.01": "You sit beside Cicka in the warm sunlight.", + "bridge.cicka.parallel_play.02": "Gently roll the toy car back and forth", + "bridge.cicka.parallel_play.03": "Purrrr... *Cicka nudges the toy car into your hand with approval*", + "bridge.cicka.parallel_play.04": "Cicka entrusts you with the official Bridge Test Vehicle!", + "bridge.draftsperson.toy_car_test.prompt": "Test Blueprint", "bridge.draftsperson.toy_car_test.01": - "Set the tiny car on the drawing", + "You place the tiny test car onto the blueprint span.", "bridge.draftsperson.toy_car_test.02": - "If it can carry this much courage, maybe it can carry us.", + "If this little car can brave the gap on paper, we can build the real thing!", "bridge.draftsperson.toy_car_test.03": - "The toy car rolls across the new span.", + "Vroom! The toy car zips safely across the inked span without falling.", "bridge.draftsperson.toy_car_test.04": - "That line holds. The bridge knows it now.", - "bridge.exit.opened_crossing.01": "Cross the finished bridge", + "Aha! The math holds! The blueprint bridge comes alive under our feet!", + "bridge.exit.opened_crossing.prompt": "Cross Bridge", + "bridge.exit.opened_crossing.01": "You stride across the freshly completed bridge.", "bridge.exit.opened_crossing.02": - "Thank you. I think I can leave this line alone now.", + "Thanks partner! The path east is officially open!", "bridge.exit.opened_crossing.03": - "The page turns toward evening music.", + "The sketchbook page folds back, revealing the evening lights of Concert Crossing.", }, handoffNote: "evening music ahead", }, @@ -80,29 +143,36 @@ export const sceneMessages = { 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.", + "concert.crowd.delay_barks.prompt": "Listen to Crowd", + "concert.crowd.delay_barks.01": "Show's delayed! The crowd's patience is thinner than tracing paper!", + "concert.crowd.delay_barks.02": "Word is the lead guitarist tried a kickflip during soundcheck and wiped out!", + "concert.crowd.delay_barks.03": "He's hiding behind the stage props nursing his wrist and his ego.", + "concert.guitarist.injury.prompt": "Talk to Guitarist", + "concert.guitarist.injury.01": "Okay, in my defense... a one-legged skateboard guitar solo sounded epic on paper.", + "concert.guitarist.injury.02": "My wrist says 'absolutely not', but the show MUST go on!", + "concert.guitarist.injury.03": "Offer to learn his signature chord phrase", + "concert.guitarist.practice_riff.prompt": "Practice Riff", + "concert.guitarist.practice_riff.01": "You strum the forgiving acoustic chord progression.", + "concert.guitarist.practice_riff.02": "Your fingers find the melody naturally—clean, warm, and resonant.", + "concert.guitarist.practice_riff.03": "Whoa! You nailed the phrase! That rhythm has enough soul to clear this whole street!", + "concert.performance.auto_success.prompt": "Play Concert", + "concert.performance.auto_success.01": "You step up to the stage mic and play.", + "concert.performance.auto_success.02": "The chord ring out across the night plaza. The crowd goes wild!", + "concert.performance.auto_success.03": "Alright! Show was a hit! Path is cleared!", + "concert.performance.auto_success.04": "Here, take my guitar. You've earned it, maestro.", + "concert.guitarist.guitar_handoff.prompt": "Take Guitar", + "concert.guitarist.guitar_handoff.01": "Keep it safe. Let it sing whenever the quiet needs company.", + "concert.guitarist.guitar_handoff.02": "Play it out on the open ridge.", + "concert.guitarist.guitar_handoff.03": "The acoustic guitar rests comfortably slung across your back.", + "concert.cicka.band_resting_spot.prompt": "Pet Cicka", + "concert.cicka.band_resting_spot.01": "You sit near Cicka backstage.", + "concert.cicka.band_resting_spot.02": "Mrrp!", + "concert.cicka.band_resting_spot.03": "Cicka loafs peacefully on an amplifier casing.", + "concert.cicka.band_resting_spot.04": "Purrrr...", + "concert.exit.dance_transition.prompt": "Head Downhill", + "concert.exit.dance_transition.01": "You follow the open street toward the festival lights.", + "concert.exit.dance_transition.02": "The Dance Festival setup glimmers downhill.", + "concert.exit.dance_transition.03": "Warm festival banners flutter in the evening breeze.", }, }, dance: { @@ -116,68 +186,78 @@ export const sceneMessages = { festivalSteward: "Festival Steward", }, dialogue: { - "dance.traveler.relay_wayfinding.01": "Relay is up the hill shuttle.", + "dance.traveler.relay_wayfinding.prompt": "Talk to Traveler", + "dance.traveler.relay_wayfinding.01": "The summit Relay Spire is up the hill shuttle route!", "dance.traveler.relay_wayfinding.02": - "Last daylight ride—only after setup clears.", + "It's the last ride of the day—boards as soon as festival setup clears.", "dance.traveler.relay_wayfinding.03": - "Help Operations with lanterns, then the Dance Teacher with one step.", + "Help Operations with the lantern line, then check on the shuttle driver!", + "dance.driver.shuttle_delay.prompt": "Talk to Driver", "dance.driver.shuttle_delay.01": - "Can't leave until the steward opens the gate.", + "Can't depart yet! My clipboard has 40 checklist items and two cold feet.", "dance.driver.shuttle_delay.02": - "Clipboard says ready. My feet disagree.", - "dance.driver.shuttle_delay.03": "He keeps rereading the same safe line.", + "Clipboard says go. My nerves say stay in the bus forever.", + "dance.driver.shuttle_delay.03": "He keeps nervously re-reading the exact same checklist item.", "dance.driver.shuttle_delay.choice.help": - "One step. Privately. Maybe the Dance Teacher…", + "Offer to help him practice one private dance step", "dance.driver.shuttle_delay.choice.help_hint": - "Walk left-of-center to the Dance Teacher (skirt pose, raised arm).", + "Check in with the Dance Teacher nearby.", "dance.driver.shuttle_delay.choice.wait": - "You give him space and ask around.", + "Give him a moment and inspect the plaza setup", + "dance.operations_helper.handoff_check.prompt": "Help Operations", "dance.operations_helper.handoff_check.01": - "Help check the operations handoff", + "You help Operations string the festival lanterns.", "dance.operations_helper.handoff_check.02": - "If the lantern line fails, I fail with it.", + "If these festoon lights aren't glowing by sunset, the festival is bust!", "dance.operations_helper.handoff_check.03": - "You check the crates with her. One clean pass is enough.", + "You help secure the lantern lines. A warm golden glow illuminates the plaza!", "dance.operations_helper.handoff_check.done.01": - "Handoff done — find the Dance Teacher next", + "Operations handoff complete! Check on the Dance Teacher next.", "dance.operations_helper.handoff_check.done.02": - "She can almost leave the plaza alone now.", + "The plaza looks vibrant and cozy.", + "dance.locals.triangulated_read.prompt": "Check Steward", "dance.locals.triangulated_read.01": - "Road opens after setup clears at the gate.", + "Service gate opens the moment the shuttle is cleared!", "dance.locals.triangulated_read.02": - "She waits on one perfect lantern. He waits on anything but asking.", + "She's master of the lanterns. He just needs a little push of confidence.", "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", + "Let's teach him one simple dance step away from the crowd.", + "dance.driver.one_step_practice.prompt": "Practice Dance Step", + "dance.driver.one_step_practice.01": "You guide the driver through one simple step.", "dance.driver.one_step_practice.02": - "No audience. He learns exactly one shared rhythm.", + "Side step, tap, turn! No spotlight, no pressure—just smooth rhythm.", "dance.driver.one_step_practice.03": - "Okay. I can offer that much later.", + "Hey! I actually did it! That wasn't scary at all!", + "dance.driver.one_step_practice.done.prompt": "Clear Gate", "dance.driver.one_step_practice.done.01": - "Step learned — clear the service gate next", + "Driver confidence restored! Clear the service gate next.", "dance.driver.one_step_practice.done.02": - "Dignity intact. The gate can open when setup finishes.", + "Dignity 100%! Ready to roll the shuttle!", + "dance.driver.folded_song_request.prompt": "Hand Song Request", "dance.driver.folded_song_request.01": - "Help fold a tiny song request", + "You hand him a neatly folded paper song request.", "dance.driver.folded_song_request.02": - "No confession. Just one dance later.", + "A quiet request for the opening festival song.", "dance.driver.folded_song_request.03": - "She reads it. Soft nod. No spotlight.", + "He reads it with a smile and nods. 'Consider it played.'", "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.", + "All clear! Head to the shuttle service gate on the right.", + "dance.setup_clearance.prompt": "Open Service Gate", + "dance.setup_clearance.01": "You open the service gate for the final shuttle.", + "dance.setup_clearance.02": "Lantern lines secured.", + "dance.setup_clearance.03": "Service lane clear of obstacles.", + "dance.setup_clearance.04": "Gate swings wide open! Last daylight window active!", "dance.setup_clearance.05": - "Shuttle sign flips. Board the van to the right.", - "dance.shuttle.last_daylight_ride.01": "All aboard the last ride.", + "The shuttle engine hums to life. Board the van!", + "dance.shuttle.last_daylight_ride.prompt": "Board Shuttle", + "dance.shuttle.last_daylight_ride.01": "All aboard the Ridge Shuttle!", "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.", + "The bus climbs the winding hill into golden sunset light.", + "dance.shuttle.last_daylight_ride.03": "The Relay Spire appears atop the quiet ridge.", + "dance.cicka.resting_spot.prompt": "Pet Cicka", + "dance.cicka.resting_spot.01": "Cicka snoozes happily on the operations crate.", + "dance.cicka.resting_spot.02": "Cicka watches the shuttle gate with sleepy curiosity.", + "dance.cicka.resting_spot.03": "Purrr... mrreeow.", }, }, relay: { @@ -187,27 +267,29 @@ export const sceneMessages = { dedication: "Dedication", }, dialogue: { - "relay.overlook.inspect.01": "Look out over the finished route", + "relay.overlook.inspect.prompt": "Inspect Overlook", + "relay.overlook.inspect.01": "You gaze out over the entire sketchbook route below.", "relay.overlook.inspect.02": - "The pages below still hold their changes.", - "relay.sit_and_play.prompt.01": "Sit and play beside Cicka", + "From the blueprint bridge to the festival lights, every page holds your story.", + "relay.sit_and_play.prompt.prompt": "Sit & Play Guitar", + "relay.sit_and_play.prompt.01": "You sit on the summit bench 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.", + "You unslung the acoustic guitar. The sunset phrase echoes across the mountain air.", + "relay.sit_and_play.prompt.03": "The overlook fills with warm, peaceful evening light.", + "relay.montage.bridge.01": "(Memory) The blueprint bridge stands strong across the river.", "relay.montage.concert.01": - "(memory) The crossing clears; the guitar changes hands.", + "(Memory) The street crowd cheered as the guitar melody landed true.", "relay.montage.dance.01": - "(memory) Night lanterns wake after you leave.", + "(Memory) Festival lanterns glow warmly in the twilight below.", "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.", + "The sun dips below the horizon as your song gently concludes.", + "relay.guitar.let_song_end.01": "You let the final chord ring out into quiet.", + "relay.guitar.let_song_end.02": "The final acoustic note resolves peacefully.", + "relay.cicka.threshold_meow.01": "Mrreeow...", "relay.cicka.threshold_meow.02": - "Cicka turns back once, then slips into warm paper light.", + "Cicka glances back with a gentle purr, then leaps into the warm paper light.", "relay.cicka.threshold_meow.03": - "The overlook holds empty for a breath.", + "A quiet moment of serene completion.", "relay.dedication.card.01": "For Cicka.", "relay.dedication.card.02": "Thank you for playing.", },