From f502d05d16db12fd175423173d8b7a96913e2161 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Tue, 4 Aug 2026 19:39:51 +0200 Subject: [PATCH 1/7] feat(ridge): cozy stick sets with baked scenery Enrich area atmospheres while keeping walk smooth via WebGL DynamicTexture bakes and dirty actor/label updates. Co-authored-by: Cursor --- .../ridge/art/stick/StickVisualProvider.ts | 392 ++++++++---------- src/game/scenes/ridge/art/stick/areaSets.ts | 262 ++++++++++++ src/game/scenes/ridge/art/stick/atmosphere.ts | 295 +++++++++++++ src/game/scenes/ridge/art/stick/palette.ts | 10 + .../scenes/ridge/art/stick/stickFigures.ts | 8 +- src/game/scenes/ridge/runtime/RidgeScene.ts | 21 +- .../ridge/sceneUi/RidgeConversationPanel.tsx | 5 +- 7 files changed, 768 insertions(+), 225 deletions(-) create mode 100644 src/game/scenes/ridge/art/stick/areaSets.ts create mode 100644 src/game/scenes/ridge/art/stick/atmosphere.ts create mode 100644 src/game/scenes/ridge/art/stick/palette.ts diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index 85c71e9..25f447a 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -1,8 +1,8 @@ -// Stick sync redraws many actors/layers each frame; branching is presentation policy. -// fallow-ignore-file complexity import type * as Phaser from 'phaser'; -import type { RidgeAreaId } from '@/game/core/ridge'; import type { RidgeVisualProvider, RidgeVisualViewModel } from '../types'; +import { drawRidgeAreaSet } from './areaSets'; +import { drawCrtAtmosphere } from './atmosphere'; +import { GROUND_Y, PAPER, STAGE_HEIGHT, STAGE_WIDTH } from './palette'; import { drawStickCicka, drawStickCrowd, @@ -19,13 +19,8 @@ import { drawStickTraveler } from './stickFigures'; -const PAPER = 0xfbfbf9; -const INK = 0x1a1a1a; -const FAINT = 0x4b4337; - -const STAGE_WIDTH = 1600; -const STAGE_HEIGHT = 720; -const GROUND_Y = 520; +const BG_TEXTURE_KEY = 'ridge-stick-bg'; +const CRT_TEXTURE_KEY = 'ridge-stick-crt'; export interface StickVisualProviderOptions { stageWidth?: number; @@ -33,19 +28,28 @@ export interface StickVisualProviderOptions { } /** - * 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 once to a WebGL DynamicTexture (1 quad/frame). Never use + * Graphics#generateTexture (Canvas + willReadFrequently) for full stages. + * - Stick Graphics redraw only when actors move. + * - Text labels update only when text/position 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 bgImage?: Phaser.GameObjects.Image; + private crtImage?: Phaser.GameObjects.Image; private promptText?: Phaser.GameObjects.Text; private readonly nameLabels = new Map(); + private readonly labelState = new Map(); + private lastBackdropKey = ''; + private lastOverlayKey = ''; + private lastActorKey = ''; + private lastPrompt = ''; private destroyed = false; constructor(scene: Phaser.Scene, options: StickVisualProviderOptions = {}) { @@ -53,19 +57,15 @@ export class StickVisualProvider implements RidgeVisualProvider { this.stageWidth = options.stageWidth ?? STAGE_WIDTH; this.stageHeight = options.stageHeight ?? STAGE_HEIGHT; - 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.actors = scene.add.graphics().setDepth(20); this.promptText = scene.add .text(0, 0, '', { fontFamily: 'Caveat, Comic Neue, cursive', - fontSize: '22px', + fontSize: '24px', color: '#1a1a1a', - backgroundColor: '#fbfbf9cc', - padding: { x: 10, y: 6 } + backgroundColor: '#fbfbf9ee', + padding: { x: 12, y: 7 } }) .setOrigin(0.5, 1) .setDepth(40) @@ -73,6 +73,7 @@ export class StickVisualProvider implements RidgeVisualProvider { scene.cameras.main.setBounds(0, 0, this.stageWidth, this.stageHeight); scene.cameras.main.setBackgroundColor(PAPER); + scene.cameras.main.setZoom(1.15); } worldXForProgress(progress: number): number { @@ -82,15 +83,134 @@ export class StickVisualProvider implements RidgeVisualProvider { sync(view: RidgeVisualViewModel): void { if (this.destroyed) return; - this.drawBackground(view.areaId, view.crossingOpen, view.beat); - this.actors.clear(); + // Beat only affects Relay threshold art; elsewhere crossingOpen covers before/after. + const backdropKey = + view.areaId === 'relay' + ? `${view.areaId}|${view.beat}` + : `${view.areaId}|${view.crossingOpen}`; + + if (backdropKey !== this.lastBackdropKey) { + this.lastBackdropKey = backdropKey; + this.bakeBackground(view); + } + + const cam = this.scene.cameras.main; + const overlayKey = `${Math.round(cam.width)}x${Math.round(cam.height)}`; + if (overlayKey !== this.lastOverlayKey) { + this.lastOverlayKey = overlayKey; + this.bakeCrtOverlay(Math.round(cam.width), Math.round(cam.height)); + } + + const actorKey = buildActorKey(view); + if (actorKey !== this.lastActorKey) { + this.lastActorKey = actorKey; + this.redrawActors(view); + } + + const player = view.actors.find((actor) => actor.id === 'player'); + if (player) { + const playerX = this.worldXForProgress(player.progress); + if (this.promptText) { + if (view.nearbyPrompt && view.mode === 'explore') { + const prompt = `[E] ${view.nearbyPrompt}`; + if (prompt !== this.lastPrompt) { + this.lastPrompt = prompt; + this.promptText.setText(prompt); + } + this.promptText.setPosition(playerX, GROUND_Y - 138).setVisible(true); + } else { + if (this.lastPrompt !== '') this.lastPrompt = ''; + this.promptText.setVisible(false); + } + } + cam.centerOn(playerX, GROUND_Y - 80); + } + } + + destroy(): void { + this.destroyed = true; + this.actors.destroy(); + this.bgImage?.destroy(); + this.crtImage?.destroy(); + this.promptText?.destroy(); + this.promptText = undefined; + this.bgImage = undefined; + this.crtImage = undefined; + for (const label of this.nameLabels.values()) label.destroy(); + this.nameLabels.clear(); + this.labelState.clear(); + if (this.scene.textures.exists(BG_TEXTURE_KEY)) this.scene.textures.remove(BG_TEXTURE_KEY); + if (this.scene.textures.exists(CRT_TEXTURE_KEY)) this.scene.textures.remove(CRT_TEXTURE_KEY); + } + private bakeBackground(view: RidgeVisualViewModel): void { + const g = this.scene.make.graphics({ x: 0, y: 0 }); + drawRidgeAreaSet(g, view.areaId, view.crossingOpen, view.beat, { + worldXForProgress: (p) => this.worldXForProgress(p), + tick: 0, + motion: false + }); + + try { + const texture = replaceDynamicTexture( + this.scene, + BG_TEXTURE_KEY, + this.stageWidth, + this.stageHeight + ); + texture.draw(g); + texture.render(); + g.destroy(); + + if (this.bgImage) { + this.bgImage.setTexture(BG_TEXTURE_KEY).setVisible(true); + } else { + this.bgImage = this.scene.add + .image(0, 0, BG_TEXTURE_KEY) + .setOrigin(0, 0) + .setDepth(10); + } + } catch { + // Fallback: keep the Graphics object as a static (never-cleared) layer. + g.setDepth(10); + this.bgImage?.setVisible(false); + } + } + + 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, 0, false); + + const texture = replaceDynamicTexture(this.scene, CRT_TEXTURE_KEY, w, h); + texture.draw(g); + texture.render(); + g.destroy(); + + if (this.crtImage) { + this.crtImage.setTexture(CRT_TEXTURE_KEY); + this.crtImage.setDisplaySize(w, h); + } else { + this.crtImage = this.scene.add + .image(0, 0, CRT_TEXTURE_KEY) + .setOrigin(0, 0) + .setScrollFactor(0) + .setDepth(50) + .setDisplaySize(w, h); + } + } + + private redrawActors(view: RidgeVisualViewModel): void { + 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); @@ -135,217 +255,67 @@ export class StickVisualProvider implements RidgeVisualProvider { } if (actor.id !== 'player' && actor.id !== 'toy-car' && actor.id !== 'guitar') { - this.syncNameLabel(actor.id, actor.label, x, y); + this.syncNameLabel(actor.id, actor.label, x, GROUND_Y - 82); } } for (const [id, label] of this.nameLabels) { if (!visibleIds.has(id)) label.setVisible(false); } - - const player = view.actors.find((actor) => actor.id === 'player'); - if (player && this.promptText) { - if (view.nearbyPrompt && view.mode === 'explore') { - const x = this.worldXForProgress(player.progress); - this.promptText - .setText(`[E] ${view.nearbyPrompt}`) - .setPosition(x, GROUND_Y - 130) - .setVisible(true); - } else { - this.promptText.setVisible(false); - } - this.scene.cameras.main.centerOn( - this.worldXForProgress(player.progress), - GROUND_Y - 80 - ); - } - } - - 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(); } private syncNameLabel(id: string, text: string, x: number, y: number): void { + const roundedX = Math.round(x); + const roundedY = Math.round(y); + const prev = this.labelState.get(id); let label = this.nameLabels.get(id); + if (!label) { label = this.scene.add - .text(0, 0, text, { + .text(roundedX, roundedY, text, { fontFamily: 'Caveat, Comic Neue, cursive', - fontSize: '16px', + fontSize: '17px', color: '#1a1a1a', - backgroundColor: '#fbfbf9aa', - padding: { x: 4, y: 1 } + backgroundColor: '#f4f1eadd', + padding: { x: 6, y: 2 } }) .setOrigin(0.5, 1) .setDepth(35); this.nameLabels.set(id, label); - } - 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); - } - - g.lineStyle(3, INK, 1); - g.lineBetween(0, GROUND_Y, this.stageWidth, GROUND_Y); - - if (areaId === 'bridge') { - this.drawBridgeSet(g, crossingOpen); - } else if (areaId === 'concert') { - this.drawConcertSet(g, crossingOpen); - } else if (areaId === 'danceFestival') { - this.drawDanceSet(g, crossingOpen); - } else { - this.drawRelaySet(g, beat); - } - } - - private drawBridgeSet(g: Phaser.GameObjects.Graphics, bridgeOpen: boolean): void { - g.lineStyle(1, INK, 0.12); - for (let x = 0; x < this.stageWidth; x += 28) { - g.lineBetween(x, 40, x + 18, 90); - } - 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); + this.labelState.set(id, { text, x: roundedX, y: roundedY }); + return; } - 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)); - } - g.strokePath(); + label.setVisible(true); + if (!prev || prev.text !== text) label.setText(text); + if (!prev || prev.x !== roundedX || prev.y !== roundedY) { + label.setPosition(roundedX, roundedY); } - - 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); - } - - g.lineStyle(2, INK, 0.5); - g.strokeCircle(140, 110, 28); + this.labelState.set(id, { text, x: roundedX, y: roundedY }); } +} - private drawConcertSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { - // night wash hatch - g.lineStyle(1, INK, 0.18); - for (let x = 0; x < this.stageWidth; x += 22) { - g.lineBetween(x, 20, x + 10, 120); - } - // storefronts - g.lineStyle(2.5, INK, 0.85); - for (let i = 0; i < 6; i += 1) { - const x = 120 + i * 220; - g.strokeRect(x, GROUND_Y - 160, 140, 160); - g.strokeRect(x + 20, GROUND_Y - 100, 40, 50); - g.strokeRect(x + 80, GROUND_Y - 100, 40, 50); - } - // crossing / crowd lane mark - const gate = this.worldXForProgress(0.55); - g.lineStyle(3, INK, crossingOpen ? 0.25 : 0.9); - g.lineBetween(gate, GROUND_Y - 8, gate, GROUND_Y - 70); - if (!crossingOpen) { - g.lineBetween(gate - 40, GROUND_Y - 40, gate + 40, GROUND_Y - 40); - } - // moon - g.lineStyle(2, INK, 0.55); - g.strokeCircle(this.stageWidth - 160, 100, 26); +function buildActorKey(view: RidgeVisualViewModel): string { + let key = `${view.areaId}|`; + for (const actor of view.actors) { + if (!actor.visible) continue; + key += `${actor.id}:${Math.round(actor.progress * 2880)}:${actor.facing}|`; } + return key; +} - private drawDanceSet(g: Phaser.GameObjects.Graphics, crossingOpen: boolean): void { - // daytime warm hatch - g.lineStyle(1, INK, 0.1); - for (let x = 0; x < this.stageWidth; x += 30) { - g.lineBetween(x, 30, x + 16, 100); - } - // lantern posts - g.lineStyle(2, INK, 0.8); - for (let i = 0; i < 8; i += 1) { - const x = 160 + i * 170; - g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); - g.strokeRect(x - 8, GROUND_Y - 110, 16, 20); - } - // service gate (matches soft-wall progress) - const gate = this.worldXForProgress(0.68); - g.lineStyle(3, INK, crossingOpen ? 0.25 : 1); - g.strokeRect(gate - 36, GROUND_Y - 90, 72, 90); - g.lineStyle(2, INK, crossingOpen ? 0.2 : 0.7); - g.lineBetween(gate - 20, GROUND_Y - 70, gate + 20, GROUND_Y - 70); - g.lineBetween(gate - 20, GROUND_Y - 50, gate + 20, GROUND_Y - 50); - if (crossingOpen) { - g.lineStyle(2, INK, 0.4); - g.lineBetween(gate + 36, GROUND_Y - 90, gate + 80, GROUND_Y - 40); - } - // dance floor edge near teacher - g.lineStyle(2, INK, 0.35); - g.strokeEllipse(this.worldXForProgress(0.4), GROUND_Y - 20, 100, 28); +function replaceDynamicTexture( + scene: Phaser.Scene, + key: string, + width: number, + height: number +): Phaser.Textures.DynamicTexture { + if (scene.textures.exists(key)) { + scene.textures.remove(key); } - - private drawRelaySet( - g: Phaser.GameObjects.Graphics, - beat: RidgeVisualViewModel['beat'] - ): void { - // sunset arcs - g.lineStyle(2, INK, 0.35); - for (let i = 0; i < 5; i += 1) { - g.strokeCircle(this.stageWidth * 0.7, 180, 40 + i * 28); - } - // overlook ledge - g.lineStyle(3, INK, 1); - g.lineBetween(this.worldXForProgress(0.15), GROUND_Y, this.worldXForProgress(0.9), GROUND_Y); - g.lineBetween( - this.worldXForProgress(0.85), - GROUND_Y, - this.worldXForProgress(0.95), - GROUND_Y + 40 - ); - // warm threshold seam - const tx = this.worldXForProgress(0.85); - g.lineStyle(2, INK, beat === 'relay_complete' ? 0.2 : 0.7); - g.strokeCircle(tx, GROUND_Y - 70, 34); - g.lineBetween(tx - 20, GROUND_Y - 70, tx + 20, GROUND_Y - 70); + 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/areaSets.ts b/src/game/scenes/ridge/art/stick/areaSets.ts new file mode 100644 index 0000000..f7e694c --- /dev/null +++ b/src/game/scenes/ridge/art/stick/areaSets.ts @@ -0,0 +1,262 @@ +import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; +import type { RidgeVisualViewModel } from '../types'; +import { + drawBird, + drawCloud, + drawCornStalk, + drawGroundBand, + drawMountainRange, + drawPaperBase, + drawPaperBacking, + drawSunOrMoon, + drawTree, + GROUND_Y, + STAGE_HEIGHT, + STAGE_WIDTH +} from './atmosphere'; +import { INK, PAPER } from './palette'; + +export interface AreaSetContext { + worldXForProgress: (progress: number) => number; + tick: number; + motion: boolean; +} + +/** Keep command counts modest — scenery is baked, but bake cost still matters on area change. */ +export function drawRidgeAreaSet( + g: Phaser.GameObjects.Graphics, + areaId: RidgeAreaId, + crossingOpen: boolean, + beat: RidgeVisualViewModel['beat'], + ctx: AreaSetContext +): void { + drawPaperBase(g, STAGE_WIDTH, STAGE_HEIGHT); + drawGroundBand(g, STAGE_WIDTH); + + if (areaId === 'bridge') { + drawBridgeSet(g, crossingOpen, ctx); + } else if (areaId === 'concert') { + drawConcertSet(g, crossingOpen, ctx); + } else if (areaId === 'danceFestival') { + drawDanceSet(g, crossingOpen, ctx); + } else { + drawRelaySet(g, beat, ctx); + } +} + +function drawBridgeSet( + g: Phaser.GameObjects.Graphics, + bridgeOpen: boolean, + ctx: AreaSetContext +): void { + drawMountainRange( + g, + [ + [0, 340], + [280, 250], + [560, 290], + [860, 220], + [1180, 270], + [STAGE_WIDTH, 240] + ], + 0.12 + ); + + drawSunOrMoon(g, 140, 108, 26, 'sun'); + drawCloud(g, 420, 100, 1.1, 0.3); + drawCloud(g, 980, 120, 1.2, 0.26); + drawBird(g, 620, 150, 0); + + for (let i = 0; i < 8; i += 1) { + const x = 560 + i * 90; + drawTree(g, x, GROUND_Y - 2, i % 2 === 0 ? 'pine' : 'round', 1, 0.4); + } + for (let i = 0; i < 3; i += 1) { + drawTree(g, 220 + i * 80, GROUND_Y - 2, 'bush', 0.75, 0.22); + } + + for (let i = 0; i < 8; i += 1) { + drawCornStalk(g, 80 + i * 36, GROUND_Y, 60 + ((i * 17) % 30), 0); + } + for (let i = 0; i < 3; i += 1) { + drawCornStalk(g, 400 + i * 18, GROUND_Y, 74, 0); + } + + const riverLeft = ctx.worldXForProgress(0.58); + const riverRight = ctx.worldXForProgress(0.78); + g.fillStyle(INK, 0.05); + g.fillRect(riverLeft - 8, GROUND_Y, riverRight - riverLeft + 16, 70); + g.lineStyle(2, INK, 0.35); + for (let y = GROUND_Y + 14; y < GROUND_Y + 64; y += 16) { + g.lineBetween(riverLeft, y, riverRight, y + 2); + } + + if (bridgeOpen) { + g.lineStyle(5, INK, 1); + g.lineBetween(riverLeft, GROUND_Y - 4, riverRight, GROUND_Y - 4); + g.lineStyle(2, INK, 0.5); + g.lineBetween(riverLeft, GROUND_Y - 18, riverRight, GROUND_Y - 18); + } else { + const mid = (riverLeft + riverRight) / 2; + g.lineStyle(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); + } + + const campX = ctx.worldXForProgress(0.48); + g.lineStyle(2.4, INK, 0.75); + g.beginPath(); + g.moveTo(campX - 34, GROUND_Y); + g.lineTo(campX, GROUND_Y - 42); + g.lineTo(campX + 34, GROUND_Y); + g.strokePath(); + g.strokeRect(campX + 42, GROUND_Y - 28, 36, 18); + + // tiny distant city hint + g.lineStyle(1.6, INK, 0.25); + const cityX = ctx.worldXForProgress(0.9); + for (let i = 0; i < 5; i += 1) { + g.strokeRect(cityX + i * 14, 250 - (24 + (i % 3) * 12), 10, 24 + (i % 3) * 12); + } +} + +function drawConcertSet( + g: Phaser.GameObjects.Graphics, + crossingOpen: boolean, + ctx: AreaSetContext +): void { + // night wash — few bands, not 100 hatch lines + g.fillStyle(INK, 0.07); + g.fillRect(0, 0, STAGE_WIDTH, GROUND_Y); + g.lineStyle(1.2, INK, 0.12); + for (let x = 0; x < STAGE_WIDTH; x += 48) { + g.lineBetween(x, 20, x + 12, 110); + } + + drawSunOrMoon(g, STAGE_WIDTH - 170, 96, 26, 'moon'); + drawCloud(g, 360, 80, 1, 0.18); + + for (let i = 0; i < 5; i += 1) { + const x = 140 + i * 260; + g.lineStyle(2.6, INK, 0.9); + g.fillStyle(PAPER, 0.35); + g.fillRect(x, GROUND_Y - 160, 140, 160); + g.strokeRect(x, GROUND_Y - 160, 140, 160); + g.strokeRect(x + 20, GROUND_Y - 110, 40, 48); + g.strokeRect(x + 80, GROUND_Y - 110, 40, 48); + g.strokeRect(x + 50, GROUND_Y - 70, 40, 70); + } + + for (let i = 0; i < 4; i += 1) { + const x = 220 + i * 320; + g.lineStyle(2.4, INK, 0.8); + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 100); + g.strokeCircle(x, GROUND_Y - 112, 9); + } + + const gate = ctx.worldXForProgress(0.55); + g.lineStyle(3.2, INK, crossingOpen ? 0.2 : 0.95); + g.lineBetween(gate, GROUND_Y - 6, gate, GROUND_Y - 78); + if (!crossingOpen) { + g.lineBetween(gate - 44, GROUND_Y - 42, gate + 44, GROUND_Y - 42); + } + + const nook = ctx.worldXForProgress(0.72); + g.fillStyle(INK, 0.08); + g.fillRect(nook - 36, GROUND_Y - 90, 80, 90); + g.lineStyle(2, INK, 0.35); + g.strokeRect(nook - 36, GROUND_Y - 90, 80, 90); +} + +function drawDanceSet( + g: Phaser.GameObjects.Graphics, + crossingOpen: boolean, + ctx: AreaSetContext +): void { + drawSunOrMoon(g, 160, 100, 28, 'sun'); + drawCloud(g, 520, 95, 1, 0.26); + drawCloud(g, 1020, 120, 1.15, 0.22); + drawMountainRange( + g, + [ + [0, 380], + [400, 300], + [800, 340], + [1200, 280], + [STAGE_WIDTH, 320] + ], + 0.08 + ); + + // bunting + g.lineStyle(1.8, INK, 0.4); + g.beginPath(); + g.moveTo(100, GROUND_Y - 120); + for (let x = 100; x < STAGE_WIDTH - 80; x += 120) { + g.lineTo(x + 60, GROUND_Y - 108); + g.lineTo(x + 120, GROUND_Y - 120); + } + g.strokePath(); + + for (let i = 0; i < 6; i += 1) { + const x = 180 + i * 220; + g.lineStyle(2.2, INK, 0.8); + g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); + g.strokeRect(x - 8, GROUND_Y - 108, 16, 18); + } + + g.lineStyle(2, INK, 0.35); + g.strokeEllipse(ctx.worldXForProgress(0.4), GROUND_Y - 18, 110, 28, 10); + + const gate = ctx.worldXForProgress(0.68); + g.lineStyle(3, INK, crossingOpen ? 0.2 : 1); + g.strokeRect(gate - 36, GROUND_Y - 90, 72, 90); + if (crossingOpen) { + g.lineStyle(2, INK, 0.35); + g.lineBetween(gate + 36, GROUND_Y - 90, gate + 80, GROUND_Y - 40); + } +} + +function drawRelaySet( + g: Phaser.GameObjects.Graphics, + beat: RidgeVisualViewModel['beat'], + ctx: AreaSetContext +): void { + drawSunOrMoon(g, STAGE_WIDTH * 0.7, 190, 40, 'sunset'); + drawMountainRange( + g, + [ + [0, 360], + [360, 280], + [760, 320], + [1140, 250], + [STAGE_WIDTH, 290] + ], + 0.12 + ); + + 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 + 44 + ); + + const bench = ctx.worldXForProgress(0.55); + g.lineStyle(2.4, INK, 0.85); + g.lineBetween(bench - 34, GROUND_Y - 18, bench + 34, GROUND_Y - 18); + g.lineBetween(bench - 28, GROUND_Y - 18, bench - 28, GROUND_Y); + g.lineBetween(bench + 28, GROUND_Y - 18, bench + 28, GROUND_Y); + + const tx = ctx.worldXForProgress(0.85); + const complete = beat === 'relay_complete'; + g.lineStyle(2.2, INK, complete ? 0.18 : 0.7); + g.strokeCircle(tx, GROUND_Y - 78, 34); + g.lineBetween(tx - 18, GROUND_Y - 78, tx + 18, GROUND_Y - 78); + + drawCloud(g, 280, 100, 0.9, 0.2); +} 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..377e6a5 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/atmosphere.ts @@ -0,0 +1,295 @@ +import type * as Phaser from 'phaser'; +import { GROUND_Y, INK, PAPER, PAPER_WARM, STAGE_HEIGHT, STAGE_WIDTH, WASH } from './palette'; + +/** Stepped sketchbook clock (~10–12 FPS) so motion feels 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; +} + +/** Soft cream wash. No notebook ruling lines / grain loops. */ +export function drawPaperBase(g: Phaser.GameObjects.Graphics, width: number, height: number): void { + g.fillStyle(PAPER, 1); + g.fillRect(0, 0, width, height); + g.fillStyle(PAPER_WARM, 0.5); + g.fillRect(0, 0, width, 40); + g.fillRect(0, height - 48, width, 48); +} + +/** CRT-ish vignette + tape corners. No per-scanline loops. */ +export function drawCrtAtmosphere( + g: Phaser.GameObjects.Graphics, + width: number, + height: number, + _tick: number, + _motion: boolean +): 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 { + // tiltHint only shifts one corner slightly for imperfect tape + 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.14 +): void { + g.fillStyle(INK, alpha); + g.fillRect(x - width * 0.5, y + 2, width, 4); +} + +/** Soft ground band + a light grass scribble. */ +export function drawGroundBand( + g: Phaser.GameObjects.Graphics, + width: number, + groundY = GROUND_Y +): void { + g.fillStyle(INK, 0.04); + g.fillRect(0, groundY, width, STAGE_HEIGHT - groundY); + g.lineStyle(4, INK, 1); + g.lineBetween(0, groundY, width, groundY); + + g.lineStyle(1.6, INK, 0.3); + for (let x = 16; x < width; x += 64) { + const h = 7 + ((x * 3) % 8); + g.lineBetween(x, groundY, x - 2, groundY - h); + g.lineBetween(x, groundY, x + 3, groundY - h * 0.7); + } +} + +export function drawCloud( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + scale = 1, + alpha = 0.35 +): void { + g.lineStyle(1.8, INK, alpha); + g.fillStyle(PAPER, 0.65); + const s = 16 * scale; + // Circles instead of default 32-point ellipses + g.fillCircle(x, y, s * 0.85); + g.fillCircle(x - s * 0.55, y + 2, s * 0.55); + g.fillCircle(x + s * 0.6, y + 1, s * 0.6); + g.strokeCircle(x, y, s * 0.85); + g.strokeCircle(x - s * 0.55, y + 2, s * 0.55); + g.strokeCircle(x + s * 0.6, y + 1, s * 0.6); +} + +export function drawBird( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + wingPhase: number +): void { + const flap = wingPhase % 2 === 0 ? -4 : 3; + g.lineStyle(2, INK, 0.45); + g.beginPath(); + g.moveTo(x - 8, y + flap); + g.lineTo(x, y); + g.lineTo(x + 8, y + flap); + g.strokePath(); +} + +/** Distant mountain silhouette (solid fill, not translucent). */ +export function drawMountainRange( + g: Phaser.GameObjects.Graphics, + points: ReadonlyArray, + alpha = 0.12 +): void { + if (points.length < 2) return; + g.fillStyle(INK, alpha); + 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.lineTo(points[points.length - 1]![0], GROUND_Y); + g.lineTo(points[0]![0], GROUND_Y); + g.closePath(); + g.fillPath(); + + g.lineStyle(1.5, INK, alpha + 0.15); + 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(); +} + +/** Varied woodland mark — pine or rounded deciduous blob. */ +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, INK, alpha); + g.fillStyle(PAPER, 0.35); + + if (kind === 'pine') { + g.lineBetween(x, groundY, x, groundY - s * 0.4); + g.beginPath(); + g.moveTo(x, groundY - s * 2.4); + 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.7); + g.lineTo(x - s * 0.95, groundY - s * 0.15); + g.lineTo(x + s * 0.95, groundY - s * 0.15); + g.closePath(); + g.strokePath(); + } else if (kind === 'bush') { + g.fillCircle(x, groundY - s * 0.55, s * 0.7); + g.strokeCircle(x, groundY - s * 0.55, s * 0.7); + } else { + g.lineBetween(x, groundY, x, groundY - s * 0.7); + g.fillCircle(x, groundY - s * 1.35, s * 0.85); + g.strokeCircle(x, groundY - s * 1.35, s * 0.85); + g.fillCircle(x - s * 0.45, groundY - s * 1.15, s * 0.5); + g.strokeCircle(x - s * 0.45, groundY - s * 1.15, s * 0.5); + g.fillCircle(x + s * 0.4, groundY - s * 1.2, s * 0.55); + g.strokeCircle(x + s * 0.4, groundY - s * 1.2, s * 0.55); + } +} + +/** Corn stalk silhouette for Bridge field. */ +export function drawCornStalk( + g: Phaser.GameObjects.Graphics, + x: number, + groundY: number, + height: number, + sway = 0 +): void { + g.lineStyle(2.2, INK, 0.75); + g.lineBetween(x, groundY, x + sway, groundY - height); + g.lineBetween(x + sway, groundY - height, x + sway + 7, groundY - height - 9); + g.lineStyle(1.5, INK, 0.4); + g.lineBetween(x + sway * 0.5, groundY - height * 0.55, x + sway * 0.5 - 10, groundY - height * 0.45); + g.lineBetween(x + sway * 0.5, groundY - height * 0.4, x + sway * 0.5 + 9, groundY - height * 0.32); +} + +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.25); + 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); + // crescent hint + 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) + ); + } +} + +/** Tiny margin caption — storytelling scrap, not UI. */ +export function drawMarginNote( + g: Phaser.GameObjects.Graphics, + x: number, + y: number, + width: number +): void { + g.lineStyle(1.5, INK, 0.28); + g.strokeRect(x, y, width, 22); + g.lineBetween(x + 6, y + 8, x + width - 8, y + 8); + g.lineBetween(x + 6, y + 14, x + width * 0.55, y + 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); + // hard offset shadow + 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); +} + +export { STAGE_WIDTH, STAGE_HEIGHT, GROUND_Y }; 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..1be7898 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -0,0 +1,10 @@ +/** Shared Digital Sketchbook ink values for Ridge stick presentation. */ +export const PAPER = 0xfbfbf9; +export const PAPER_WARM = 0xf4f1ea; +export const INK = 0x1a1a1a; +export const FAINT = 0x4b4337; +export const WASH = 0x2a241c; + +export const STAGE_WIDTH = 1600; +export const STAGE_HEIGHT = 720; +export const GROUND_Y = 520; diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 6fd0935..cd36abe 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -40,8 +40,8 @@ export function drawStickCicka( const s = 12 * scale; g.lineStyle(2.5, 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.fillEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); + g.strokeEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); 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); @@ -169,7 +169,7 @@ export function drawStickGuitarist( 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.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.6, s * 0.95, 8); 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); @@ -195,7 +195,7 @@ 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.strokeEllipse(x, y - s * 0.2, s * 0.7, s, 8); 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); } diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index 9bb193e..b12d05a 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -53,6 +53,7 @@ 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?: { @@ -99,7 +100,7 @@ export class RidgeScene extends Phaser.Scene { relay: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR) }; } - this.cameras.main.setZoom(1); + this.cameras.main.setZoom(1.15); this.syncPresentation(); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.cleanup()); @@ -246,13 +247,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 + }); + } } } diff --git a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx index 4e9896b..c080478 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -132,10 +132,11 @@ function PortraitFrame({ return (
- +
+ {portrait === 'cicka' ? : null} {portrait === 'draftsperson' ? : null} {portrait === 'player' ? : null} From d9f1b9bce88269ff1a37fb5711f2d6b1d9b0a6fa Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Wed, 5 Aug 2026 09:53:31 +0200 Subject: [PATCH 2/7] feat(ridge): JRPG dialogue panel without clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typewriter choices and emotion portraits stay in-flow under SceneUiHost, free 1–9 from area warps, and keep [] for dev skip. Co-authored-by: Cursor --- src/game/core/ridge/content/bridgeStage.ts | 12 +- src/game/core/ridge/content/concertStage.ts | 18 +- src/game/core/ridge/content/danceStage.ts | 22 +- src/game/core/ridge/content/relayStage.ts | 6 +- .../core/ridge/content/testBridgeCatalog.ts | 7 +- .../core/ridge/content/testRouteCatalog.ts | 18 + src/game/core/ridge/observe.ts | 1 + src/game/core/ridge/types.ts | 11 + .../ridge/art/stick/StickVisualProvider.ts | 14 +- .../scenes/ridge/art/stick/stickFigures.ts | 255 ++++++++++--- src/game/scenes/ridge/runtime/RidgeScene.ts | 62 ++-- .../ridge/sceneUi/RidgeConversationPanel.tsx | 339 ++++++++++++++---- src/game/shell/InteractiveApp.test.tsx | 34 ++ src/shared/i18n/messages/en/scenes.ts | 206 ++++++----- 14 files changed, 701 insertions(+), 304 deletions(-) diff --git a/src/game/core/ridge/content/bridgeStage.ts b/src/game/core/ridge/content/bridgeStage.ts index 5cc9fee..c48af30 100644 --- a/src/game/core/ridge/content/bridgeStage.ts +++ b/src/game/core/ridge/content/bridgeStage.ts @@ -133,12 +133,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 +146,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 +159,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 +167,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/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/types.ts b/src/game/core/ridge/types.ts index 5666a4a..92785a9 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 { @@ -177,6 +187,7 @@ export interface RidgeObservation { speaker: string; speakerId: string; text: string; + emotion?: RidgeEmotion; lineId: string; lineIndex: number; lineCount: number; diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index 25f447a..1fdb1de 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -61,11 +61,11 @@ export class StickVisualProvider implements RidgeVisualProvider { this.promptText = scene.add .text(0, 0, '', { - fontFamily: 'Caveat, Comic Neue, cursive', - fontSize: '24px', - color: '#1a1a1a', - backgroundColor: '#fbfbf9ee', - padding: { x: 12, y: 7 } + fontFamily: 'Comic Neue, sans-serif', + fontSize: '15px', + color: '#fbfbf9', + backgroundColor: '#1a1a1a', + padding: { x: 10, y: 5 } }) .setOrigin(0.5, 1) .setDepth(40) @@ -112,12 +112,12 @@ export class StickVisualProvider implements RidgeVisualProvider { const playerX = this.worldXForProgress(player.progress); if (this.promptText) { if (view.nearbyPrompt && view.mode === 'explore') { - const prompt = `[E] ${view.nearbyPrompt}`; + const prompt = `💬 ${view.nearbyPrompt}`; if (prompt !== this.lastPrompt) { this.lastPrompt = prompt; this.promptText.setText(prompt); } - this.promptText.setPosition(playerX, GROUND_Y - 138).setVisible(true); + this.promptText.setPosition(playerX, GROUND_Y - 108).setVisible(true); } else { if (this.lastPrompt !== '') this.lastPrompt = ''; this.promptText.setVisible(false); diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index cd36abe..b4cae14 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -15,20 +15,29 @@ export function drawStickPlayer( ): void { const s = 18 * scale; const dir = facing === 'left' ? -1 : 1; - g.lineStyle(3, INK, 1); + + drawBasePerson(g, x, y, facing, scale, { + hair: 'messy', + scarf: true, + eyes: 'determined' + }); + + // Signature travel backpack with strap detail + g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); + g.fillRect(x - dir * s * 0.6, y - s * 1.1, s * 0.38, s * 0.55); + g.strokeRect(x - dir * s * 0.6, y - s * 1.1, s * 0.38, s * 0.55); + // Backpack flap & buckle + g.lineBetween(x - dir * s * 0.6, y - s * 0.95, x - dir * s * 0.22, y - s * 0.95); + g.strokeCircle(x - dir * s * 0.41, y - s * 0.75, s * 0.05); - 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); + // Scarf tail trailing behind + g.lineStyle(3, INK, 1); + g.beginPath(); + g.moveTo(x, y - s * 1.25); + g.lineTo(x - dir * s * 0.4, y - s * 1.1); + g.lineTo(x - dir * s * 0.65, y - s * 0.95); + g.strokePath(); } export function drawStickCicka( @@ -40,18 +49,49 @@ export function drawStickCicka( const s = 12 * scale; g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); + + // Body g.fillEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); g.strokeEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); - 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); + + // Head + g.fillCircle(x + s * 0.7, y - s * 0.75, s * 0.48); + g.strokeCircle(x + s * 0.7, y - s * 0.75, s * 0.48); + + // Pointy ears + g.beginPath(); + g.moveTo(x + s * 0.45, y - s * 1.05); + g.lineTo(x + s * 0.35, y - s * 1.5); + g.lineTo(x + s * 0.65, y - s * 1.12); + g.strokePath(); + + g.beginPath(); + g.moveTo(x + s * 0.85, y - s * 1.05); + g.lineTo(x + s * 0.98, y - s * 1.5); + g.lineTo(x + s * 0.72, y - s * 1.12); + g.strokePath(); + + // Expressive cat eyes & nose + g.fillStyle(INK, 1); + g.fillCircle(x + s * 0.85, y - s * 0.8, s * 0.08); + g.lineStyle(1.5, INK, 1); + g.lineBetween(x + s * 0.95, y - s * 0.75, x + s * 1.02, y - s * 0.72); + + // Whiskers + g.lineBetween(x + s * 0.92, y - s * 0.7, x + s * 1.25, y - s * 0.8); + g.lineBetween(x + s * 0.92, y - s * 0.65, x + s * 1.25, y - s * 0.6); + + // Expressive curling cat tail + g.lineStyle(2.5, INK, 1); g.beginPath(); g.moveTo(x - s * 0.75, y - s * 0.35); - g.lineTo(x - s * 1.2, y - s * 0.9); + g.lineTo(x - s * 1.1, y - s * 0.8); + g.lineTo(x - s * 0.95, y - s * 1.25); g.strokePath(); + + // Cozy paws + g.fillCircle(x - s * 0.3, y + s * 0.1, s * 0.12); + g.fillCircle(x + s * 0.3, y + s * 0.1, s * 0.12); } export function drawStickDraftsperson( @@ -63,11 +103,25 @@ export function drawStickDraftsperson( ): void { const s = 17 * scale; const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + drawBasePerson(g, x, y, facing, scale, { + hair: 'messy', + glasses: true, + eyes: 'thoughtful' + }); + + // Blueprint roll under arm 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); + g.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.4, y - s * 1.05, s * 0.9, s * 0.55); + g.strokeRect(x + dir * s * 0.4, y - s * 1.05, s * 0.9, s * 0.55); + g.strokeEllipse(x + dir * s * 0.85, y - s * 0.78, s * 0.25, s * 0.55); + // Grid lines on blueprint + g.lineStyle(1.5, INK, 0.4); + g.lineBetween(x + dir * s * 0.5, y - s * 0.85, x + dir * s * 1.15, y - s * 0.85); + + // Pencil behind ear + g.lineStyle(2, INK, 1); + g.lineBetween(x - dir * s * 0.15, y - s * 1.85, x + dir * s * 0.35, y - s * 1.95); } export function drawStickToyCar( @@ -77,20 +131,26 @@ 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); + // Toy car windshield & spoiler + 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'; } function drawBasePerson( @@ -106,22 +166,50 @@ function drawBasePerson( g.lineStyle(3, INK, 1); g.fillStyle(PAPER, 1); - g.fillCircle(x, y - s * 1.65, s * 0.4); - g.strokeCircle(x, y - s * 1.65, s * 0.4); + // Head + g.fillCircle(x, y - s * 1.65, s * 0.42); + g.strokeCircle(x, y - s * 1.65, s * 0.42); + // Facial features (eyes & expression) + g.fillStyle(INK, 1); + if (style.eyes === 'happy') { + g.lineStyle(1.8, INK, 1); + g.lineBetween(x + dir * s * 0.1, y - s * 1.75, x + dir * s * 0.25, y - s * 1.75); + } else if (style.eyes === 'thoughtful') { + g.lineStyle(1.8, INK, 1); + g.lineBetween(x + dir * s * 0.05, y - s * 1.8, x + dir * s * 0.25, y - s * 1.75); + g.fillCircle(x + dir * s * 0.18, y - s * 1.65, s * 0.06); + } else { + // Standard eye dot facing direction + g.fillCircle(x + dir * s * 0.18, y - s * 1.68, s * 0.07); + } + + // Glasses option + if (style.glasses) { + g.lineStyle(2, INK, 1); + g.strokeCircle(x + dir * s * 0.18, y - s * 1.68, s * 0.14); + g.lineBetween(x, y - s * 1.68, x + dir * s * 0.08, y - s * 1.68); + } + + // Hair & Hats + g.lineStyle(3, INK, 1); 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); + g.fillCircle(x, y - s * 2.08, s * 0.22); + g.strokeCircle(x, y - s * 2.08, s * 0.22); + } else if (style.hair === 'beanie') { + g.fillStyle(INK, 0.15); + g.fillRect(x - s * 0.4, y - s * 2.1, s * 0.8, s * 0.4); + g.strokeRect(x - s * 0.4, y - s * 2.1, s * 0.8, s * 0.4); } 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); + g.lineBetween(x - s * 0.5, y - s * 1.75, x + dir * s * 0.65, y - s * 1.75); + g.strokeRect(x - s * 0.35, y - s * 2.08, s * 0.7, s * 0.33); } 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); + g.strokeRect(x - s * 0.28, y - s * 2.18, s * 0.56, s * 0.38); + g.lineBetween(x - s * 0.55, y - s * 1.8, x + s * 0.55, y - s * 1.8); } else if (style.hair === 'ponytail') { g.beginPath(); g.moveTo(x - dir * s * 0.25, y - s * 1.7); @@ -129,8 +217,18 @@ function drawBasePerson( g.strokePath(); } + // Torso / Body line + g.lineStyle(3, INK, 1); g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); + // Scarf around neck + if (style.scarf) { + g.fillStyle(INK, 0.2); + g.fillRect(x - s * 0.25, y - s * 1.32, s * 0.5, s * 0.18); + g.strokeRect(x - s * 0.25, y - s * 1.32, s * 0.5, s * 0.18); + } + + // Arms 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); @@ -139,6 +237,7 @@ function drawBasePerson( g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); } + // Legs / Skirt 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); @@ -167,12 +266,31 @@ export function drawStickGuitarist( ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale, { hair: 'messy' }); + + drawBasePerson(g, x, y, facing, scale, { + hair: 'beanie', + eyes: 'thoughtful' + }); + + // Acoustic Guitar held across body g.lineStyle(2.5, INK, 1); - g.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.6, s * 0.95, 8); - 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.fillStyle(PAPER, 1); + g.fillEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.65, s * 0.95, 12); + g.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.65, s * 0.95, 12); + // Soundhole + g.fillCircle(x + dir * s * 0.55, y - s * 0.5, s * 0.12); + g.strokeCircle(x + dir * s * 0.55, y - s * 0.5, s * 0.12); + + // Guitar neck & headstock + g.lineBetween(x + dir * s * 0.55, y - s * 0.95, x + dir * s * 0.55, y - s * 1.5); + g.strokeRect(x + dir * s * 0.45, y - s * 1.68, s * 0.2, s * 0.18); + + // Guitar strap across torso + g.lineStyle(1.8, INK, 0.7); + g.lineBetween(x - dir * s * 0.3, y - s * 1.15, x + dir * s * 0.6, y - s * 0.35); + + // Wrist wrap + g.lineStyle(3, INK, 0.8); g.lineBetween(x + dir * s * 0.35, y - s * 0.7, x + dir * s * 0.55, y - s * 0.55); } @@ -183,7 +301,7 @@ export function drawStickCrowd( scale = 1 ): void { drawBasePerson(g, x - 18 * scale, y, 'left', scale * 0.8, { hair: 'cap' }); - drawBasePerson(g, x, y, 'right', scale * 0.9, { hair: 'messy' }); + drawBasePerson(g, x, y, 'right', scale * 0.9, { hair: 'messy', eyes: 'happy' }); drawBasePerson(g, x + 20 * scale, y, 'left', scale * 0.75, { hair: 'ponytail' }); } @@ -195,9 +313,12 @@ 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, 8); - 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); + g.fillEllipse(x, y - s * 0.2, s * 0.75, s * 1.1, 8); + g.strokeEllipse(x, y - s * 0.2, s * 0.75, s * 1.1, 8); + 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( @@ -209,9 +330,15 @@ export function drawStickTraveler( ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', walkingStick: true }); - // travel pack + drawBasePerson(g, x, y, facing, scale, { + hair: 'ponytail', + walkingStick: true, + eyes: 'happy' + }); + // Travel backpack g.lineStyle(2.5, INK, 1); + g.fillStyle(PAPER, 1); + g.fillRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); g.strokeRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); } @@ -224,12 +351,17 @@ export function drawStickDriver( ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale, { hair: 'cap' }); + drawBasePerson(g, x, y, facing, scale, { + hair: 'cap', + eyes: 'focused' + }); g.lineStyle(2.5, INK, 1); - // big clipboard + // Big clipboard with clip 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.fillRect(x + dir * s * 0.55, y - s * 1.2, s * 0.25, s * 0.12); + g.strokeRect(x + dir * s * 0.55, y - s * 1.2, s * 0.25, s * 0.12); 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); } @@ -243,15 +375,25 @@ export function drawStickOperationsHelper( ): void { const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; - drawBasePerson(g, x, y, facing, scale, { hair: 'ponytail', apron: true }); - // lantern held high + drawBasePerson(g, x, y, facing, scale, { + hair: 'ponytail', + apron: true, + eyes: 'happy' + }); + // Lantern held high with warm light rays 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.fillStyle(PAPER, 1); + g.fillRect(x + dir * s * 0.52, y - s * 1.5, s * 0.42, s * 0.42); + g.strokeRect(x + dir * s * 0.52, y - s * 1.5, s * 0.42, s * 0.42); + g.lineBetween(x + dir * s * 0.73, y - s * 1.5, x + dir * s * 0.73, y - s * 1.68); + // 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); + g.lineBetween(x + dir * s * 0.6, y - s * 1.4, x + dir * s * 0.86, y - s * 1.18); + // Light rays + g.lineStyle(1.2, INK, 0.35); + g.lineBetween(x + dir * s * 0.98, y - s * 1.3, x + dir * s * 1.3, y - s * 1.4); + g.lineBetween(x + dir * s * 0.98, y - s * 1.1, x + dir * s * 1.3, y - s * 1.0); } export function drawStickDanceTeacher( @@ -264,7 +406,8 @@ export function drawStickDanceTeacher( drawBasePerson(g, x, y, facing, scale * 1.08, { hair: 'bun', skirt: true, - raisedArm: true + raisedArm: true, + eyes: 'happy' }); } @@ -278,7 +421,7 @@ export function drawStickSteward( const s = 16 * scale; const dir = facing === 'left' ? -1 : 1; drawBasePerson(g, x, y, facing, scale * 1.05, { hair: 'hat' }); - // key on belt + // Key on belt g.lineStyle(2, INK, 1); g.strokeCircle(x + dir * s * 0.35, y - s * 0.2, s * 0.12); g.lineBetween(x + dir * s * 0.35, y - s * 0.08, x + dir * s * 0.35, y + s * 0.15); @@ -298,7 +441,7 @@ export function drawStickShuttle( 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); + // "LAST SHUTTLE" sign mark on side + g.lineStyle(2, INK, 0.65); g.lineBetween(x - s * 0.2, y - s * 0.55, x + s * 0.9, y - s * 0.55); } diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index b12d05a..70ef9cc 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -55,13 +55,8 @@ export class RidgeScene extends Phaser.Scene { 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); @@ -92,13 +87,8 @@ export class RidgeScene extends Phaser.Scene { this.visuals = new StickVisualProvider(this); this.keys = bindSideViewKeyboard(this.input.keyboard, { includeEscapeKey: true }); if (import.meta.env.DEV && this.input.keyboard) { - this.skipKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.CLOSED_BRACKET); - this.warpKeys = { - bridge: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), - concert: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), - dance: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), - relay: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR) - }; + this.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.15); @@ -161,30 +151,21 @@ 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; + const prevAreaMap: Record = { + bridge: 'relay', + concert: 'bridge', + danceFestival: 'concert', + relay: 'danceFestival' + }; + this.applyResult(this.session.exec(`warp ${prevAreaMap[currentArea]}`)); this.syncPresentation(); } } @@ -282,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, @@ -301,8 +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; } } @@ -310,10 +292,12 @@ export class RidgeScene extends Phaser.Scene { function portraitForSpeaker( speakerId: string ): RidgeConversationPanelView['portrait'] { - if (speakerId === 'cicka') return 'cicka'; - if (speakerId === 'bridgeDraftsperson' || speakerId === 'injuredGuitarist') { - return 'draftsperson'; - } + if (speakerId === 'cicka' || speakerId === 'counterpart-cat') return 'cicka'; + if (speakerId === 'bridgeDraftsperson' || speakerId === 'draftsperson') return 'draftsperson'; + if (speakerId === 'injuredGuitarist' || speakerId === 'guitarist') return 'guitarist'; + if (speakerId === 'danceDriver' || speakerId === 'driver' || speakerId === 'operationsHelper') return 'driver'; + if (speakerId === 'traveler' || speakerId === 'steward') return 'traveler'; + if (speakerId === 'danceTeacher') return 'teacher'; 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 c080478..fe74a5b 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -1,6 +1,6 @@ -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'; export interface RidgeConversationChoiceView { id: string; @@ -16,8 +16,8 @@ export interface RidgeConversationPanelView { lineCount: number; awaitingChoice: boolean; choices: readonly RidgeConversationChoiceView[]; - /** Stick silhouette key for portrait frame. */ - portrait: 'player' | 'cicka' | 'draftsperson' | 'prompt'; + portrait: 'player' | 'cicka' | 'draftsperson' | 'guitarist' | 'driver' | 'traveler' | 'teacher' | 'prompt'; + emotion?: RidgeEmotion; } export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfaceProps) { @@ -25,14 +25,53 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac const titleId = useId(); const textId = useId(); + const [typedLength, setTypedLength] = useState(0); + + useEffect(() => { + if (!view) return; + setTypedLength(0); + const targetLen = view.text.length; + if (targetLen === 0) return; + + const interval = setInterval(() => { + setTypedLength((prev) => { + if (prev < targetLen) return prev + 1; + clearInterval(interval); + return prev; + }); + }, 16); + + return () => clearInterval(interval); + }, [view?.text, view?.lineIndex]); + + 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 (view.awaitingChoice && view.choices.length > 0) { + if (event.key >= '1' && event.key <= '9') { + const index = parseInt(event.key, 10) - 1; + if (index < view.choices.length) { + event.preventDefault(); + dispatchAction('ridgeConversationChoose', { choiceId: view.choices[index].id }); + return; + } + } + } + + if (!view.awaitingChoice) { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'z' || event.key === 'Z') { + event.preventDefault(); + if (isTyping) { + setTypedLength(view.text.length); + } else { + dispatchAction('ridgeConversationAdvance'); + } + } } + if (event.key === 'Escape') { event.preventDefault(); dispatchAction('ridgeConversationLeave'); @@ -41,127 +80,188 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac 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); + + const handleBoxClick = () => { + if (isTyping) { + setTypedLength(view.text.length); + } + }; + + // 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}
)}
- +
); } 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' ? ( + + ) : ( + + )} @@ -171,16 +271,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..163bace 100644 --- a/src/shared/i18n/messages/en/scenes.ts +++ b/src/shared/i18n/messages/en/scenes.ts @@ -44,31 +44,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 +85,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 +128,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 +209,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.", }, From 3b876781d1584dc8feea0d5fa66572c512658075 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Wed, 5 Aug 2026 10:59:52 +0200 Subject: [PATCH 3/7] feat(ridge): JRPG presence layer and parallax stick scenery Splits Ridge scenery into baked far/near/foreground parallax bands and gives each actor its own sprite so poses, idle breathing, and the walk cycle redraw only on the stepped sketch clock rather than per frame. Adds the presence layer residents need to feel inhabited: nameplates with role tags, an interact pip anchored to the focused target, and ambient bark bubbles scheduled by a pure director, with role and bark copy authored in i18n. Co-authored-by: Cursor --- src/game/core/ridge/content/bridgeStage.ts | 4 +- .../core/ridge/content/dialogueHelpers.ts | 4 +- src/game/core/ridge/types.ts | 4 + .../ridge/art/stick/StickVisualProvider.ts | 520 ++++++++------- .../scenes/ridge/art/stick/actorSprites.ts | 201 ++++++ .../scenes/ridge/art/stick/ambientLayer.ts | 129 ++++ src/game/scenes/ridge/art/stick/areaSets.ts | 582 ++++++++++++----- src/game/scenes/ridge/art/stick/atmosphere.ts | 255 ++++++-- .../scenes/ridge/art/stick/barkDirector.ts | 98 +++ src/game/scenes/ridge/art/stick/palette.ts | 45 +- .../scenes/ridge/art/stick/presenceLayer.ts | 307 +++++++++ .../scenes/ridge/art/stick/stickFigures.ts | 594 ++++++++++++------ src/game/scenes/ridge/art/types.ts | 64 +- .../scenes/ridge/content/presenceCatalog.ts | 15 + src/game/scenes/ridge/runtime/RidgeScene.ts | 8 +- src/shared/i18n/messages/en/scenes.ts | 58 ++ 16 files changed, 2246 insertions(+), 642 deletions(-) create mode 100644 src/game/scenes/ridge/art/stick/actorSprites.ts create mode 100644 src/game/scenes/ridge/art/stick/ambientLayer.ts create mode 100644 src/game/scenes/ridge/art/stick/barkDirector.ts create mode 100644 src/game/scenes/ridge/art/stick/presenceLayer.ts create mode 100644 src/game/scenes/ridge/content/presenceCatalog.ts diff --git a/src/game/core/ridge/content/bridgeStage.ts b/src/game/core/ridge/content/bridgeStage.ts index c48af30..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 }); } 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/types.ts b/src/game/core/ridge/types.ts index 92785a9..54d3d55 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -125,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 { diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index 1fdb1de..f766e7b 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -1,79 +1,106 @@ import type * as Phaser from 'phaser'; +import type { RidgeActorId, RidgeFacing } from '@/game/core/ridge'; import type { RidgeVisualProvider, RidgeVisualViewModel } from '../types'; -import { drawRidgeAreaSet } from './areaSets'; -import { drawCrtAtmosphere } from './atmosphere'; -import { GROUND_Y, PAPER, STAGE_HEIGHT, STAGE_WIDTH } from './palette'; +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 BG_TEXTURE_KEY = 'ridge-stick-bg'; + 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; } /** * Stick-figure Ridge presentation. * * Performance rules: - * - Scenery bakes once to a WebGL DynamicTexture (1 quad/frame). Never use - * Graphics#generateTexture (Canvas + willReadFrequently) for full stages. - * - Stick Graphics redraw only when actors move. - * - Text labels update only when text/position actually changes. + * - 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 actors: Phaser.GameObjects.Graphics; - private bgImage?: Phaser.GameObjects.Image; + 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 promptText?: Phaser.GameObjects.Text; - private readonly nameLabels = new Map(); - private readonly labelState = new Map(); - private lastBackdropKey = ''; - private lastOverlayKey = ''; - private lastActorKey = ''; - private lastPrompt = ''; + private lastSceneryKey = ''; + private lastViewportKey = ''; + private lastAmbientKey = ''; + private lastPlayerProgress = Number.NaN; + private lastMovedAt = -Infinity; private destroyed = false; 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.actors = scene.add.graphics().setDepth(20); + this.ambientFar = scene.add + .graphics() + .setDepth(DEPTH.ambientFar) + .setScrollFactor(LAYERS.far.scrollFactor, 1); + this.ambientNear = scene.add.graphics().setDepth(DEPTH.ambientNear); - this.promptText = scene.add - .text(0, 0, '', { - fontFamily: 'Comic Neue, sans-serif', - fontSize: '15px', - color: '#fbfbf9', - backgroundColor: '#1a1a1a', - padding: { x: 10, y: 5 } - }) - .setOrigin(0.5, 1) - .setDepth(40) - .setVisible(false); + 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); - scene.cameras.main.setZoom(1.15); } worldXForProgress(progress: number): number { @@ -83,97 +110,100 @@ export class StickVisualProvider implements RidgeVisualProvider { sync(view: RidgeVisualViewModel): void { if (this.destroyed) return; - // Beat only affects Relay threshold art; elsewhere crossingOpen covers before/after. - const backdropKey = - view.areaId === 'relay' - ? `${view.areaId}|${view.beat}` - : `${view.areaId}|${view.crossingOpen}`; + 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; - if (backdropKey !== this.lastBackdropKey) { - this.lastBackdropKey = backdropKey; - this.bakeBackground(view); - } - - const cam = this.scene.cameras.main; - const overlayKey = `${Math.round(cam.width)}x${Math.round(cam.height)}`; - if (overlayKey !== this.lastOverlayKey) { - this.lastOverlayKey = overlayKey; - this.bakeCrtOverlay(Math.round(cam.width), Math.round(cam.height)); - } - - const actorKey = buildActorKey(view); - if (actorKey !== this.lastActorKey) { - this.lastActorKey = actorKey; - this.redrawActors(view); - } + this.syncViewport(); + this.syncScenery(view); + this.syncAmbient(view, tick); const player = view.actors.find((actor) => actor.id === 'player'); - if (player) { - const playerX = this.worldXForProgress(player.progress); - if (this.promptText) { - if (view.nearbyPrompt && view.mode === 'explore') { - const prompt = `💬 ${view.nearbyPrompt}`; - if (prompt !== this.lastPrompt) { - this.lastPrompt = prompt; - this.promptText.setText(prompt); - } - this.promptText.setPosition(playerX, GROUND_Y - 108).setVisible(true); - } else { - if (this.lastPrompt !== '') this.lastPrompt = ''; - this.promptText.setVisible(false); - } - } - cam.centerOn(playerX, 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.actors.destroy(); - this.bgImage?.destroy(); + this.actorPool.destroy(); + this.presence.destroy(); + this.ambientFar.destroy(); + this.ambientNear.destroy(); this.crtImage?.destroy(); - this.promptText?.destroy(); - this.promptText = undefined; - this.bgImage = undefined; this.crtImage = undefined; - for (const label of this.nameLabels.values()) label.destroy(); - this.nameLabels.clear(); - this.labelState.clear(); - if (this.scene.textures.exists(BG_TEXTURE_KEY)) this.scene.textures.remove(BG_TEXTURE_KEY); - if (this.scene.textures.exists(CRT_TEXTURE_KEY)) this.scene.textures.remove(CRT_TEXTURE_KEY); + + for (const image of this.sceneryImages.values()) image.destroy(); + this.sceneryImages.clear(); + + 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); + } + } + + /** 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; + + cam.setZoom(clamp(cam.height / VIEW_HEIGHT, MIN_ZOOM, MAX_ZOOM)); + this.bakeCrtOverlay(Math.round(cam.width), Math.round(cam.height)); + } + + 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 bakeBackground(view: RidgeVisualViewModel): void { + private bakeSceneryLayer(layer: SceneryLayer, view: RidgeVisualViewModel): void { + const spec = LAYERS[layer]; const g = this.scene.make.graphics({ x: 0, y: 0 }); - drawRidgeAreaSet(g, view.areaId, view.crossingOpen, view.beat, { - worldXForProgress: (p) => this.worldXForProgress(p), - tick: 0, - motion: false + drawRidgeAreaLayer(g, layer, view.areaId, view.crossingOpen, view.beat, { + worldXForProgress: (progress) => this.worldXForProgress(progress) }); + const key = SCENERY_TEXTURE_KEY[layer]; try { - const texture = replaceDynamicTexture( - this.scene, - BG_TEXTURE_KEY, - this.stageWidth, - this.stageHeight - ); + const texture = replaceDynamicTexture(this.scene, key, spec.width, spec.height); texture.draw(g); texture.render(); - g.destroy(); - if (this.bgImage) { - this.bgImage.setTexture(BG_TEXTURE_KEY).setVisible(true); + let image = this.sceneryImages.get(layer); + if (image) { + image.setTexture(key); } else { - this.bgImage = this.scene.add - .image(0, 0, BG_TEXTURE_KEY) + image = this.scene.add + .image(0, spec.top, key) .setOrigin(0, 0) - .setDepth(10); + .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); } - } catch { - // Fallback: keep the Graphics object as a static (never-cleared) layer. - g.setDepth(10); - this.bgImage?.setVisible(false); + image.setVisible(true); + } finally { + g.destroy(); } } @@ -181,7 +211,7 @@ export class StickVisualProvider implements RidgeVisualProvider { 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, 0, false); + drawCrtAtmosphere(g, w, h); const texture = replaceDynamicTexture(this.scene, CRT_TEXTURE_KEY, w, h); texture.draw(g); @@ -189,119 +219,185 @@ export class StickVisualProvider implements RidgeVisualProvider { g.destroy(); if (this.crtImage) { - this.crtImage.setTexture(CRT_TEXTURE_KEY); - this.crtImage.setDisplaySize(w, h); - } else { - this.crtImage = this.scene.add - .image(0, 0, CRT_TEXTURE_KEY) - .setOrigin(0, 0) - .setScrollFactor(0) - .setDepth(50) - .setDisplaySize(w, h); + 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); } - private redrawActors(view: RidgeVisualViewModel): void { - this.actors.clear(); - const visibleIds = new Set(); + 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); + } + + /** 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; + } + if (Math.abs(progress - this.lastPlayerProgress) > 0.0001) { + this.lastPlayerProgress = progress; + this.lastMovedAt = now; + } + return now - this.lastMovedAt < WALK_RELEASE_MS; + } + + private syncActors( + view: RidgeVisualViewModel, + playerProgress: number, + tick: number, + stepTick: number, + motion: boolean, + walking: boolean + ): void { + const requests: ActorRenderRequest[] = []; + const playerX = this.worldXForProgress(playerProgress); + + view.actors.forEach((actor, index) => { + if (!actor.visible) return; - 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; - } + const isPlayer = actor.id === 'player'; + const isWalking = isPlayer && walking; + const isTalking = view.speakingActorId === actor.id; - if (actor.id !== 'player' && actor.id !== 'toy-car' && actor.id !== 'guitar') { - this.syncNameLabel(actor.id, actor.label, x, GROUND_Y - 82); - } + // 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'; + const plates: PlacedPresence[] = []; + const barkCandidates: string[] = []; + const focusActorId = view.focus?.actorId; + + for (const actor of view.actors) { + if (!actor.visible || actor.id === 'player') continue; + if (actor.id === 'toy-car' || actor.id === 'guitar') continue; + + const distance = Math.abs(actor.progress - playerProgress); + const alpha = inConversation ? 0 : presenceAlpha(distance); + 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 + }); + + // The focused resident gets the interact pip instead of small talk. + if (actor.id !== focusActorId) barkCandidates.push(actor.id); } - for (const [id, label] of this.nameLabels) { - if (!visibleIds.has(id)) label.setVisible(false); + this.presence.syncNameplates(plates); + + if (inConversation) { + this.barkDirector.interrupt(now); + this.presence.syncBarks([]); + this.presence.syncFocus(null, 0); + return; } - } - private syncNameLabel(id: string, text: string, x: number, y: number): void { - const roundedX = Math.round(x); - const roundedY = Math.round(y); - const prev = this.labelState.get(id); - let label = this.nameLabels.get(id); - - if (!label) { - label = this.scene.add - .text(roundedX, roundedY, text, { - fontFamily: 'Caveat, Comic Neue, cursive', - fontSize: '17px', - color: '#1a1a1a', - backgroundColor: '#f4f1eadd', - padding: { x: 6, y: 2 } - }) - .setOrigin(0.5, 1) - .setDepth(35); - this.nameLabels.set(id, label); - this.labelState.set(id, { text, x: roundedX, y: roundedY }); + const bark = this.barkDirector.update(now, barkCandidates); + const barks: ActiveBark[] = []; + 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 + }); + } + } + this.presence.syncBarks(barks); + + const focus = view.focus; + if (!focus) { + this.presence.syncFocus(null, 0); return; } - label.setVisible(true); - if (!prev || prev.text !== text) label.setText(text); - if (!prev || prev.x !== roundedX || prev.y !== roundedY) { - label.setPosition(roundedX, roundedY); + 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); + } + + /** 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; + } } - this.labelState.set(id, { text, x: roundedX, y: roundedY }); + + this.scene.cameras.main.centerOn( + centerX, + GROUND_Y - (VIEW_ABOVE_GROUND - VIEW_BELOW_GROUND) / 2 + ); } } -function buildActorKey(view: RidgeVisualViewModel): string { - let key = `${view.areaId}|`; - for (const actor of view.actors) { - if (!actor.visible) continue; - key += `${actor.id}:${Math.round(actor.progress * 2880)}:${actor.facing}|`; - } - return key; +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( 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..e91e866 --- /dev/null +++ b/src/game/scenes/ridge/art/stick/actorSprites.ts @@ -0,0 +1,201 @@ +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. + */ +const HEAD_TOP: Record = { + player: -78, + cicka: -44, + 'counterpart-cat': -40, + draftsperson: -76, + 'toy-car': -22, + guitarist: -74, + crowd: -66, + guitar: -38, + traveler: -70, + driver: -74, + 'operations-helper': -74, + 'dance-teacher': -80, + steward: -76, + shuttle: -46 +}; + +export function headTopFor(id: RidgeActorId): number { + return HEAD_TOP[id] ?? -70; +} + +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; +} + +/** 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 { + const s = FIGURE_SCALE; + switch (id) { + case 'player': + drawStickPlayer(g, 0, 0, facing, 1.15 * s, pose); + return; + case 'cicka': + drawStickCicka(g, 0, 0, 1.1 * s, pose); + return; + case 'counterpart-cat': + drawStickCicka(g, 0, 0, 0.95 * s, pose); + return; + case 'draftsperson': + drawStickDraftsperson(g, 0, 0, facing, 1.05 * s, pose); + return; + case 'toy-car': + drawStickToyCar(g, 22, 4, 1.1 * s); + return; + case 'guitarist': + drawStickGuitarist(g, 0, 0, facing, 1.05 * s, pose); + return; + case 'crowd': + drawStickCrowd(g, 0, 0, s, pose); + return; + case 'guitar': + drawStickGuitar(g, 20, 2, 1.1 * s); + return; + case 'traveler': + drawStickTraveler(g, 0, 0, facing, s, pose); + return; + case 'driver': + drawStickDriver(g, 0, 0, facing, 1.05 * s, pose); + return; + case 'operations-helper': + drawStickOperationsHelper(g, 0, 0, facing, 1.05 * s, pose); + return; + case 'dance-teacher': + drawStickDanceTeacher(g, 0, 0, facing, 1.05 * s, pose); + return; + case 'steward': + drawStickSteward(g, 0, 0, facing, s, pose); + return; + case 'shuttle': + drawStickShuttle(g, 0, 0, 1.1 * s); + return; + } +} 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..c0021bc --- /dev/null +++ b/src/game/scenes/ridge/art/stick/ambientLayer.ts @@ -0,0 +1,129 @@ +import type * as Phaser from 'phaser'; +import type { RidgeAreaId } from '@/game/core/ridge'; +import { drawBird, drawCloud, jitter } from './atmosphere'; +import { 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') { + drawDriftingSeeds(g, tick); + return; + } + if (areaId === 'concert') { + drawRisingEmbers(g, tick); + return; + } + if (areaId === 'danceFestival') { + drawFallingPetals(g, tick); + return; + } + drawSlowMotes(g, tick); +} + +/** 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 index f7e694c..4b38137 100644 --- a/src/game/scenes/ridge/art/stick/areaSets.ts +++ b/src/game/scenes/ridge/art/stick/areaSets.ts @@ -1,262 +1,542 @@ +// 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 { - drawBird, - drawCloud, drawCornStalk, drawGroundBand, + drawHatch, + drawInkBlob, drawMountainRange, drawPaperBase, drawPaperBacking, drawSunOrMoon, drawTree, GROUND_Y, - STAGE_HEIGHT, - STAGE_WIDTH + jitter, + STAGE_WIDTH, + strokeLeaf } from './atmosphere'; -import { INK, PAPER } from './palette'; +import { HORIZON_Y, INK, LAYERS, PAPER, PAPER_WARM, SKY_TOP } 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; - tick: number; - motion: boolean; } -/** Keep command counts modest — scenery is baked, but bake cost still matters on area change. */ -export function drawRidgeAreaSet( +export function drawRidgeAreaLayer( g: Phaser.GameObjects.Graphics, + layer: SceneryLayer, areaId: RidgeAreaId, crossingOpen: boolean, beat: RidgeVisualViewModel['beat'], ctx: AreaSetContext ): void { - drawPaperBase(g, STAGE_WIDTH, STAGE_HEIGHT); + 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 ------------------------------------------------------------- + +function drawFarBand(g: Phaser.GameObjects.Graphics, areaId: RidgeAreaId): void { + if (areaId === 'concert') { + g.fillStyle(INK, 0.1); + g.fillRect(0, 0, STAGE_WIDTH, HORIZON_Y); + drawHatch(g, 0, SKY_TOP, STAGE_WIDTH, 90, 44, 0.08, 0.7); + drawSunOrMoon(g, STAGE_WIDTH - 240, SKY_TOP + 46, 28, 'moon'); + g.fillStyle(INK, 0.4); + for (let i = 0; i < 26; i += 1) { + const sx = jitter(i * 3.1) * STAGE_WIDTH; + const sy = SKY_TOP + 10 + jitter(i * 7.7) * 150; + g.fillRect(sx, sy, 2, 2); + } + drawMountainRange(g, ridgeLine(areaId), 0.14, HORIZON_Y); + return; + } + + if (areaId === 'relay') { + drawSunOrMoon(g, STAGE_WIDTH * 0.66, SKY_TOP + 100, 42, 'sunset'); + drawMountainRange(g, ridgeLine(areaId), 0.12, HORIZON_Y); + return; + } + + drawSunOrMoon(g, areaId === 'bridge' ? 210 : 250, SKY_TOP + 56, 27, 'sun'); + drawMountainRange(g, ridgeLine(areaId), areaId === 'bridge' ? 0.09 : 0.075, HORIZON_Y); if (areaId === 'bridge') { - drawBridgeSet(g, crossingOpen, ctx); - } else if (areaId === 'concert') { - drawConcertSet(g, crossingOpen, ctx); - } else if (areaId === 'danceFestival') { - drawDanceSet(g, crossingOpen, ctx); - } else { - drawRelaySet(g, beat, ctx); + // Distant town on the far ridge — a promise of somewhere to walk toward. + g.lineStyle(1.6, INK, 0.3); + const townY = HORIZON_Y - 58; + for (let i = 0; i < 7; i += 1) { + const h = 22 + (i % 3) * 12; + g.strokeRect(1180 + i * 16, townY - h, 11, h); + } + g.lineBetween(1170, townY, 1310, townY); + } +} + +/** Peaks live in the upper half of the visible sky, never above it. */ +function ridgeLine(areaId: RidgeAreaId): ReadonlyArray { + const crest = SKY_TOP + 74; + switch (areaId) { + case 'bridge': + return [ + [0, crest + 46], + [280, crest], + [560, crest + 34], + [860, crest - 22], + [1180, crest + 28], + [STAGE_WIDTH, crest + 6] + ]; + case 'concert': + return [ + [0, crest + 58], + [340, crest + 12], + [700, crest + 48], + [1080, crest + 4], + [STAGE_WIDTH, crest + 40] + ]; + case 'relay': + return [ + [0, crest + 44], + [360, crest - 18], + [760, crest + 26], + [1140, crest - 32], + [STAGE_WIDTH, crest + 10] + ]; + default: + return [ + [0, crest + 62], + [400, crest + 18], + [800, crest + 52], + [1200, crest + 2], + [STAGE_WIDTH, crest + 36] + ]; } } -function drawBridgeSet( +/** + * Two bands of massed canopy stitching the horizon to the playable lane. + * Individual little trees at this distance read as specks; a silhouette reads + * as woodland. + */ +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, - bridgeOpen: boolean, - ctx: AreaSetContext + baseY: number, + crown: number, + spacing: number, + alpha: number, + seed: number ): void { - drawMountainRange( - g, - [ - [0, 340], - [280, 250], - [560, 290], - [860, 220], - [1180, 270], - [STAGE_WIDTH, 240] - ], - 0.12 - ); + g.fillStyle(INK, alpha); + g.beginPath(); + g.moveTo(-spacing, baseY); + for (let x = -spacing; x <= STAGE_WIDTH + spacing; x += spacing) { + const lift = crown * (0.45 + jitter(x * 0.11 + seed) * 0.75); + const radius = spacing * (0.52 + jitter(x * 0.07 + seed) * 0.3); + g.arc(x, baseY - lift, radius, Math.PI, 0); + } + g.lineTo(STAGE_WIDTH + spacing, baseY); + g.closePath(); + g.fillPath(); +} - drawSunOrMoon(g, 140, 108, 26, 'sun'); - drawCloud(g, 420, 100, 1.1, 0.3); - drawCloud(g, 980, 120, 1.2, 0.26); - drawBird(g, 620, 150, 0); +// --- near band ------------------------------------------------------------ - for (let i = 0; i < 8; i += 1) { - const x = 560 + i * 90; - drawTree(g, x, GROUND_Y - 2, i % 2 === 0 ? 'pine' : 'round', 1, 0.4); +function drawBridgeNear( + g: Phaser.GameObjects.Graphics, + bridgeOpen: boolean, + ctx: AreaSetContext +): void { + 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 < 3; i += 1) { - drawTree(g, 220 + i * 80, GROUND_Y - 2, 'bush', 0.75, 0.22); + for (let i = 0; i < 4; i += 1) { + drawTree(g, 214 + i * 74, GROUND_Y - 2, 'bush', 0.8, 0.26); } - for (let i = 0; i < 8; i += 1) { - drawCornStalk(g, 80 + i * 36, GROUND_Y, 60 + ((i * 17) % 30), 0); - } - for (let i = 0; i < 3; i += 1) { - drawCornStalk(g, 400 + i * 18, GROUND_Y, 74, 0); - } + drawCornField(g, 30, 470); const riverLeft = ctx.worldXForProgress(0.58); const riverRight = ctx.worldXForProgress(0.78); - g.fillStyle(INK, 0.05); - g.fillRect(riverLeft - 8, GROUND_Y, riverRight - riverLeft + 16, 70); - g.lineStyle(2, INK, 0.35); - for (let y = GROUND_Y + 14; y < GROUND_Y + 64; y += 16) { - g.lineBetween(riverLeft, y, riverRight, y + 2); + 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, INK, 1); + 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 - 18, riverRight, GROUND_Y - 18); + 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, INK, 1); + 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 = ctx.worldXForProgress(0.48); - g.lineStyle(2.4, INK, 0.75); + const campX = ctx.worldXForProgress(0.46); + g.fillStyle(PAPER_WARM, 0.9); + g.lineStyle(2.6, INK, 0.8); g.beginPath(); - g.moveTo(campX - 34, GROUND_Y); - g.lineTo(campX, GROUND_Y - 42); - g.lineTo(campX + 34, GROUND_Y); + g.moveTo(campX - 36, GROUND_Y); + g.lineTo(campX, GROUND_Y - 46); + g.lineTo(campX + 36, GROUND_Y); + g.closePath(); + g.fillPath(); g.strokePath(); - g.strokeRect(campX + 42, GROUND_Y - 28, 36, 18); + drawHatch(g, campX + 6, GROUND_Y - 30, 28, 30, 8, 0.28, 0.9); + g.lineStyle(2.2, INK, 0.7); + g.strokeRect(campX + 48, GROUND_Y - 30, 38, 20); +} - // tiny distant city hint - g.lineStyle(1.6, INK, 0.25); - const cityX = ctx.worldXForProgress(0.9); - for (let i = 0; i < 5; i += 1) { - g.strokeRect(cityX + i * 14, 250 - (24 + (i % 3) * 12), 10, 24 + (i % 3) * 12); +/** + * 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 drawConcertSet( +function drawConcertNear( g: Phaser.GameObjects.Graphics, crossingOpen: boolean, ctx: AreaSetContext ): void { - // night wash — few bands, not 100 hatch lines - g.fillStyle(INK, 0.07); - g.fillRect(0, 0, STAGE_WIDTH, GROUND_Y); - g.lineStyle(1.2, INK, 0.12); - for (let x = 0; x < STAGE_WIDTH; x += 48) { - g.lineBetween(x, 20, x + 12, 110); + 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); } - drawSunOrMoon(g, STAGE_WIDTH - 170, 96, 26, 'moon'); - drawCloud(g, 360, 80, 1, 0.18); - + // Street lamps with a hatched pool of light beneath each. for (let i = 0; i < 5; i += 1) { - const x = 140 + i * 260; - g.lineStyle(2.6, INK, 0.9); - g.fillStyle(PAPER, 0.35); - g.fillRect(x, GROUND_Y - 160, 140, 160); - g.strokeRect(x, GROUND_Y - 160, 140, 160); - g.strokeRect(x + 20, GROUND_Y - 110, 40, 48); - g.strokeRect(x + 80, GROUND_Y - 110, 40, 48); - g.strokeRect(x + 50, GROUND_Y - 70, 40, 70); + 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); } - for (let i = 0; i < 4; i += 1) { - const x = 220 + i * 320; - g.lineStyle(2.4, INK, 0.8); - g.lineBetween(x, GROUND_Y, x, GROUND_Y - 100); - g.strokeCircle(x, GROUND_Y - 112, 9); + 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.2, INK, crossingOpen ? 0.2 : 0.95); - g.lineBetween(gate, GROUND_Y - 6, gate, GROUND_Y - 78); + 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 - 44, GROUND_Y - 42, gate + 44, GROUND_Y - 42); + 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.08); - g.fillRect(nook - 36, GROUND_Y - 90, 80, 90); - g.lineStyle(2, INK, 0.35); - g.strokeRect(nook - 36, GROUND_Y - 90, 80, 90); + 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 drawDanceSet( +function drawDanceNear( g: Phaser.GameObjects.Graphics, crossingOpen: boolean, ctx: AreaSetContext ): void { - drawSunOrMoon(g, 160, 100, 28, 'sun'); - drawCloud(g, 520, 95, 1, 0.26); - drawCloud(g, 1020, 120, 1.15, 0.22); - drawMountainRange( - g, - [ - [0, 380], - [400, 300], - [800, 340], - [1200, 280], - [STAGE_WIDTH, 320] - ], - 0.08 - ); - - // bunting - g.lineStyle(1.8, INK, 0.4); + // Bunting strung between poles, sagging between each pair. + g.lineStyle(1.9, INK, 0.45); g.beginPath(); - g.moveTo(100, GROUND_Y - 120); - for (let x = 100; x < STAGE_WIDTH - 80; x += 120) { - g.lineTo(x + 60, GROUND_Y - 108); - g.lineTo(x + 120, GROUND_Y - 120); + 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 = 180 + i * 220; - g.lineStyle(2.2, INK, 0.8); - g.lineBetween(x, GROUND_Y, x, GROUND_Y - 90); - g.strokeRect(x - 8, GROUND_Y - 108, 16, 18); + 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); } - g.lineStyle(2, INK, 0.35); - g.strokeEllipse(ctx.worldXForProgress(0.4), GROUND_Y - 18, 110, 28, 10); + // 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, INK, crossingOpen ? 0.2 : 1); - g.strokeRect(gate - 36, GROUND_Y - 90, 72, 90); + 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 + 36, GROUND_Y - 90, gate + 80, GROUND_Y - 40); + 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 drawRelaySet( +function drawRelayNear( g: Phaser.GameObjects.Graphics, beat: RidgeVisualViewModel['beat'], ctx: AreaSetContext ): void { - drawSunOrMoon(g, STAGE_WIDTH * 0.7, 190, 40, 'sunset'); - drawMountainRange( - g, - [ - [0, 360], - [360, 280], - [760, 320], - [1140, 250], - [STAGE_WIDTH, 290] - ], - 0.12 - ); - 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 + 44 + 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.4, INK, 0.85); - g.lineBetween(bench - 34, GROUND_Y - 18, bench + 34, GROUND_Y - 18); - g.lineBetween(bench - 28, GROUND_Y - 18, bench - 28, GROUND_Y); - g.lineBetween(bench + 28, GROUND_Y - 18, bench + 28, GROUND_Y); + 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 tx = ctx.worldXForProgress(0.85); + const spire = ctx.worldXForProgress(0.85); const complete = beat === 'relay_complete'; - g.lineStyle(2.2, INK, complete ? 0.18 : 0.7); - g.strokeCircle(tx, GROUND_Y - 78, 34); - g.lineBetween(tx - 18, GROUND_Y - 78, tx + 18, GROUND_Y - 78); + 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; - drawCloud(g, 280, 100, 0.9, 0.2); + 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) { + // An arm up in the air, holding the moment. + 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 index 377e6a5..7469898 100644 --- a/src/game/scenes/ridge/art/stick/atmosphere.ts +++ b/src/game/scenes/ridge/art/stick/atmosphere.ts @@ -1,7 +1,7 @@ import type * as Phaser from 'phaser'; import { GROUND_Y, INK, PAPER, PAPER_WARM, STAGE_HEIGHT, STAGE_WIDTH, WASH } from './palette'; -/** Stepped sketchbook clock (~10–12 FPS) so motion feels hand-drawn. */ +/** Stepped sketchbook clock (~11 FPS) so motion reads as hand-drawn. */ export function sketchTick(timeMs: number): number { return Math.floor(timeMs / 90); } @@ -13,8 +13,18 @@ export function prefersReducedMotion(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } -/** Soft cream wash. No notebook ruling lines / grain loops. */ -export function drawPaperBase(g: Phaser.GameObjects.Graphics, width: number, height: number): void { +/** 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 with a warm band top and bottom. */ +export function drawPaperBase( + g: Phaser.GameObjects.Graphics, + width: number, + height: number +): void { g.fillStyle(PAPER, 1); g.fillRect(0, 0, width, height); g.fillStyle(PAPER_WARM, 0.5); @@ -22,13 +32,63 @@ export function drawPaperBase(g: Phaser.GameObjects.Graphics, width: number, hei g.fillRect(0, height - 48, width, 48); } +/** + * 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, - _tick: number, - _motion: boolean + height: number ): void { g.fillStyle(WASH, 0.1); g.fillRect(0, 0, width, 16); @@ -58,7 +118,6 @@ function drawTape( h: number, tiltHint: number ): void { - // tiltHint only shifts one corner slightly for imperfect tape g.fillRect(x, y, w, h); g.strokeRect(x, y, w, h); g.lineStyle(1, INK, 0.2); @@ -72,31 +131,50 @@ export function drawContactShadow( x: number, y: number, width = 28, - alpha = 0.14 + 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); } -/** Soft ground band + a light grass scribble. */ +/** Hatched earth band, ink horizon, and a light grass scribble. */ export function drawGroundBand( g: Phaser.GameObjects.Graphics, width: number, groundY = GROUND_Y ): void { - g.fillStyle(INK, 0.04); - g.fillRect(0, groundY, width, STAGE_HEIGHT - groundY); - g.lineStyle(4, INK, 1); + // Trodden lane, then heavier earth below it. + g.fillStyle(INK, 0.045); + g.fillRect(0, groundY, width, 34); + g.fillStyle(INK, 0.09); + g.fillRect(0, groundY + 34, width, STAGE_HEIGHT - groundY - 34); + drawHatch(g, 0, groundY + 34, width, 60, 38, 0.06, 0.9); + + g.lineStyle(5, INK, 1); g.lineBetween(0, groundY, width, groundY); + g.lineStyle(1.8, INK, 0.3); + g.lineBetween(0, groundY + 34, width, groundY + 33); - g.lineStyle(1.6, INK, 0.3); - for (let x = 16; x < width; x += 64) { - const h = 7 + ((x * 3) % 8); - g.lineBetween(x, groundY, x - 2, groundY - h); - g.lineBetween(x, groundY, x + 3, groundY - h * 0.7); + // Pebbles and scuffs along the lane. + g.fillStyle(INK, 0.3); + for (let x = 24; x < width; x += 47) { + g.fillRect(x, groundY + 10 + jitter(x * 1.7) * 18, 3 + jitter(x) * 4, 2); + } + + g.lineStyle(1.8, INK, 0.38); + for (let x = 16; x < width; x += 44) { + const h = 8 + jitter(x) * 11; + g.lineBetween(x, groundY, x - 3, groundY - h); + g.lineBetween(x, groundY, x + 4, groundY - h * 0.7); } } +/** + * Single bumpy outline rather than stacked circles — overlapping strokes on + * pale fill read as a Venn diagram, not a cloud. + */ export function drawCloud( g: Phaser.GameObjects.Graphics, x: number, @@ -104,26 +182,28 @@ export function drawCloud( scale = 1, alpha = 0.35 ): void { - g.lineStyle(1.8, INK, alpha); - g.fillStyle(PAPER, 0.65); const s = 16 * scale; - // Circles instead of default 32-point ellipses - g.fillCircle(x, y, s * 0.85); - g.fillCircle(x - s * 0.55, y + 2, s * 0.55); - g.fillCircle(x + s * 0.6, y + 1, s * 0.6); - g.strokeCircle(x, y, s * 0.85); - g.strokeCircle(x - s * 0.55, y + 2, s * 0.55); - g.strokeCircle(x + s * 0.6, y + 1, s * 0.6); + g.fillStyle(PAPER, 1); + g.lineStyle(1.8, INK, alpha); + g.beginPath(); + g.arc(x - s * 0.72, y + 3, s * 0.5, Math.PI, 0); + g.arc(x, y - 4, s * 0.8, Math.PI, 0); + g.arc(x + s * 0.78, y + 2, s * 0.55, Math.PI, 0); + g.lineTo(x - s * 1.22, y + 3); + g.closePath(); + g.fillPath(); + g.strokePath(); } export function drawBird( g: Phaser.GameObjects.Graphics, x: number, y: number, - wingPhase: number + wingPhase: number, + alpha = 0.45 ): void { const flap = wingPhase % 2 === 0 ? -4 : 3; - g.lineStyle(2, INK, 0.45); + g.lineStyle(2, INK, alpha); g.beginPath(); g.moveTo(x - 8, y + flap); g.lineTo(x, y); @@ -131,34 +211,55 @@ export function drawBird( g.strokePath(); } -/** Distant mountain silhouette (solid fill, not translucent). */ +/** + * 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 + alpha = 0.12, + baseY = GROUND_Y ): void { if (points.length < 2) return; - g.fillStyle(INK, alpha); + + // Paler crest behind, offset upward. Two flat tones read as depth; loose + // hatching this far away just looks like dirt on the page. + fillRidge(g, points, baseY, INK, alpha * 0.6, -30); + fillRidge(g, points, baseY, INK, alpha, 0); + + g.lineStyle(2, INK, Math.min(0.7, alpha + 0.4)); 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.lineTo(points[points.length - 1]![0], GROUND_Y); - g.lineTo(points[0]![0], GROUND_Y); - g.closePath(); - g.fillPath(); + g.strokePath(); +} - g.lineStyle(1.5, INK, alpha + 0.15); +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]); + 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]); + g.lineTo(points[i]![0], points[i]![1] + offsetY); } - g.strokePath(); + g.lineTo(points[points.length - 1]![0], baseY); + g.lineTo(points[0]![0], baseY); + g.closePath(); + g.fillPath(); } -/** Varied woodland mark — pine or rounded deciduous blob. */ +/** Varied woodland mark — pine, rounded deciduous, or low bush. */ export function drawTree( g: Phaser.GameObjects.Graphics, x: number, @@ -168,52 +269,82 @@ export function drawTree( alpha = 0.55 ): void { const s = 14 * scale; - g.lineStyle(2, INK, alpha); - g.fillStyle(PAPER, 0.35); + 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.4); + 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.7); + 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') { - g.fillCircle(x, groundY - s * 0.55, s * 0.7); - g.strokeCircle(x, groundY - s * 0.55, s * 0.7); + 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); - g.fillCircle(x, groundY - s * 1.35, s * 0.85); - g.strokeCircle(x, groundY - s * 1.35, s * 0.85); - g.fillCircle(x - s * 0.45, groundY - s * 1.15, s * 0.5); - g.strokeCircle(x - s * 0.45, groundY - s * 1.15, s * 0.5); - g.fillCircle(x + s * 0.4, groundY - s * 1.2, s * 0.55); - g.strokeCircle(x + s * 0.4, groundY - s * 1.2, s * 0.55); + 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); } } -/** Corn stalk silhouette for Bridge field. */ +/** 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 + sway = 0, + alpha = 0.75 ): void { - g.lineStyle(2.2, INK, 0.75); - g.lineBetween(x, groundY, x + sway, groundY - height); - g.lineBetween(x + sway, groundY - height, x + sway + 7, groundY - height - 9); - g.lineStyle(1.5, INK, 0.4); - g.lineBetween(x + sway * 0.5, groundY - height * 0.55, x + sway * 0.5 - 10, groundY - height * 0.45); - g.lineBetween(x + sway * 0.5, groundY - height * 0.4, x + sway * 0.5 + 9, groundY - height * 0.32); + 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(); } export function drawSunOrMoon( @@ -224,7 +355,7 @@ export function drawSunOrMoon( mode: 'sun' | 'moon' | 'sunset' ): void { if (mode === 'sunset') { - g.lineStyle(2, INK, 0.25); + g.lineStyle(2, INK, 0.22); for (let i = 0; i < 5; i += 1) { g.strokeCircle(x, y, radius + i * 26); } @@ -243,7 +374,6 @@ export function drawSunOrMoon( 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); - // crescent hint g.fillStyle(PAPER_WARM, 0.5); g.fillCircle(x + radius * 0.35, y - radius * 0.1, radius * 0.72); return; @@ -286,7 +416,6 @@ export function drawPaperBacking( 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); - // hard offset shadow 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..3e7d15b --- /dev/null +++ b/src/game/scenes/ridge/art/stick/barkDirector.ts @@ -0,0 +1,98 @@ +/** Ambient chatter scheduling. No Phaser or DOM, so it stays testable. */ + +export type BarkLines = Readonly>; + +export interface BarkPerformance { + actorId: string; + 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: string; 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 string[]): 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 string[]): { actorId: string; 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 index 1be7898..3ca01a0 100644 --- a/src/game/scenes/ridge/art/stick/palette.ts +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -1,4 +1,3 @@ -/** Shared Digital Sketchbook ink values for Ridge stick presentation. */ export const PAPER = 0xfbfbf9; export const PAPER_WARM = 0xf4f1ea; export const INK = 0x1a1a1a; @@ -8,3 +7,47 @@ export const WASH = 0x2a241c; 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. + */ +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 VIEW_BOTTOM = GROUND_Y + VIEW_BELOW_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 }, + // 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; + +export type RidgeLayerId = keyof typeof LAYERS; + +export const DEPTH = { + ambientFar: 8, + ambientNear: 16, + actor: 20, + presence: 36, + crt: 50 +} as const; + +/** + * Nameplate fade window. The ramp is deliberately short: a plate lingering at + * half opacity looks like a rendering fault rather than a deliberate fade. + */ +export const PRESENCE_NEAR = 0.155; +export const PRESENCE_FAR = 0.19; 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..1a4922c --- /dev/null +++ b/src/game/scenes/ridge/art/stick/presenceLayer.ts @@ -0,0 +1,307 @@ +import type * as Phaser from 'phaser'; +import { createUiText } from '@/game/sharedSceneRuntime/text/createUiText'; +import { DEPTH, INK, PAPER, PAPER_WARM } from './palette'; + +const INK_CSS = '#1a1a1a'; +const PAPER_CSS = '#fbfbf9'; + +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 focus?: FocusPip; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + } + + syncNameplates(entries: readonly PlacedPresence[]): void { + const seen = new Set(); + + for (const entry of entries) { + seen.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 (!seen.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 { + const seen = new Set(); + + for (const bark of active) { + seen.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 (!seen.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 b4cae14..1f4714c 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -2,41 +2,74 @@ // 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'; + +/** 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 }; -const INK = 0x1a1a1a; -const PAPER = 0xfbfbf9; +/** 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; + const lift = bodyLiftOf(pose, s); - drawBasePerson(g, x, y, facing, scale, { + drawContactShadow(g, x, y, 34 * scale, 0.18); + drawBasePerson(g, x, y + lift, facing, scale, pose, { hair: 'messy', scarf: true, eyes: 'determined' }); - // Signature travel backpack with strap detail - g.lineStyle(2.5, INK, 1); - g.fillStyle(PAPER, 1); - g.fillRect(x - dir * s * 0.6, y - s * 1.1, s * 0.38, s * 0.55); - g.strokeRect(x - dir * s * 0.6, y - s * 1.1, s * 0.38, s * 0.55); - // Backpack flap & buckle - g.lineBetween(x - dir * s * 0.6, y - s * 0.95, x - dir * s * 0.22, y - s * 0.95); - g.strokeCircle(x - dir * s * 0.41, y - s * 0.75, s * 0.05); - - // Scarf tail trailing behind - g.lineStyle(3, INK, 1); + 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, y - s * 1.25); - g.lineTo(x - dir * s * 0.4, y - s * 1.1); - g.lineTo(x - dir * s * 0.65, y - s * 0.95); + 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(); } @@ -44,54 +77,54 @@ 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); - // Body - g.fillEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); - g.strokeEllipse(x, y - s * 0.35, s * 1.5, s * 0.9, 8); + drawInkBlob(g, x, y - s * 0.4, s * 0.82, s * 0.5, 11, 10); + g.fillPath(); + g.strokePath(); - // Head - g.fillCircle(x + s * 0.7, y - s * 0.75, s * 0.48); - g.strokeCircle(x + s * 0.7, y - s * 0.75, s * 0.48); + 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); - // Pointy ears + // 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.45, y - s * 1.05); - g.lineTo(x + s * 0.35, y - s * 1.5); - g.lineTo(x + s * 0.65, y - s * 1.12); + 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.85, y - s * 1.05); - g.lineTo(x + s * 0.98, y - s * 1.5); - g.lineTo(x + s * 0.72, y - s * 1.12); + 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(); - // Expressive cat eyes & nose g.fillStyle(INK, 1); - g.fillCircle(x + s * 0.85, y - s * 0.8, s * 0.08); - g.lineStyle(1.5, INK, 1); - g.lineBetween(x + s * 0.95, y - s * 0.75, x + s * 1.02, y - s * 0.72); - - // Whiskers - g.lineBetween(x + s * 0.92, y - s * 0.7, x + s * 1.25, y - s * 0.8); - g.lineBetween(x + s * 0.92, y - s * 0.65, x + s * 1.25, y - s * 0.6); - - // Expressive curling cat tail - g.lineStyle(2.5, 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.75, y - s * 0.35); - g.lineTo(x - s * 1.1, y - s * 0.8); - g.lineTo(x - s * 0.95, y - s * 1.25); + 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(); - // Cozy paws - g.fillCircle(x - s * 0.3, y + s * 0.1, s * 0.12); - g.fillCircle(x + s * 0.3, y + s * 0.1, s * 0.12); + 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( @@ -99,29 +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, { + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { hair: 'messy', glasses: true, eyes: 'thoughtful' }); - // Blueprint roll under arm - g.lineStyle(2.5, INK, 1); + // Blueprint roll tucked under the arm. + g.lineStyle(CONTOUR - 0.7, INK, 1); g.fillStyle(PAPER, 1); - g.fillRect(x + dir * s * 0.4, y - s * 1.05, s * 0.9, s * 0.55); - g.strokeRect(x + dir * s * 0.4, y - s * 1.05, s * 0.9, s * 0.55); - g.strokeEllipse(x + dir * s * 0.85, y - s * 0.78, s * 0.25, s * 0.55); - // Grid lines on blueprint - g.lineStyle(1.5, INK, 0.4); - g.lineBetween(x + dir * s * 0.5, y - s * 0.85, x + dir * s * 1.15, y - s * 0.85); - - // Pencil behind ear + 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.85, x + dir * s * 0.35, y - s * 1.95); + g.lineBetween(x - dir * s * 0.15, y - s * 1.86, x + dir * s * 0.35, y - s * 1.96); } export function drawStickToyCar( @@ -137,7 +170,6 @@ export function drawStickToyCar( 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); - // Toy car windshield & spoiler 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); } @@ -159,102 +191,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); - // Head - g.fillCircle(x, y - s * 1.65, s * 0.42); - g.strokeCircle(x, y - s * 1.65, s * 0.42); + // Torso + g.lineStyle(CONTOUR, INK, 1); + g.lineBetween(x, y - s * 1.26, x, y - s * 0.15); - // Facial features (eyes & expression) + 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, 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); + } + + 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.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(1.8, INK, 1); - g.lineBetween(x + dir * s * 0.1, y - s * 1.75, x + dir * s * 0.25, y - s * 1.75); + 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(1.8, INK, 1); - g.lineBetween(x + dir * s * 0.05, y - s * 1.8, x + dir * s * 0.25, y - s * 1.75); - g.fillCircle(x + dir * s * 0.18, y - s * 1.65, s * 0.06); + 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.fillCircle(x + dir * s * 0.19, headY - s * 0.03, s * 0.075); + } + + // 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 { - // Standard eye dot facing direction - g.fillCircle(x + dir * s * 0.18, y - s * 1.68, s * 0.07); + g.lineBetween(x + dir * s * 0.14, headY + s * 0.2, x + dir * s * 0.3, headY + s * 0.19); } - // Glasses option if (style.glasses) { g.lineStyle(2, INK, 1); - g.strokeCircle(x + dir * s * 0.18, y - s * 1.68, s * 0.14); - g.lineBetween(x, y - s * 1.68, x + dir * s * 0.08, y - s * 1.68); + 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); } +} - // Hair & Hats - g.lineStyle(3, INK, 1); - 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.08, s * 0.22); - g.strokeCircle(x, y - s * 2.08, s * 0.22); - } else if (style.hair === 'beanie') { - g.fillStyle(INK, 0.15); - g.fillRect(x - s * 0.4, y - s * 2.1, s * 0.8, s * 0.4); - g.strokeRect(x - s * 0.4, y - s * 2.1, s * 0.8, s * 0.4); - } else if (style.hair === 'cap') { - g.lineBetween(x - s * 0.5, y - s * 1.75, x + dir * s * 0.65, y - s * 1.75); - g.strokeRect(x - s * 0.35, y - s * 2.08, s * 0.7, s * 0.33); - } else if (style.hair === 'hat') { - g.strokeRect(x - s * 0.28, y - s * 2.18, s * 0.56, s * 0.38); - g.lineBetween(x - s * 0.55, y - s * 1.8, x + s * 0.55, y - s * 1.8); - } else if (style.hair === 'ponytail') { +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 - dir * s * 0.25, y - s * 1.7); - g.lineTo(x - dir * s * 0.7, y - s * 1.35); + 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(); } +} - // Torso / Body line - g.lineStyle(3, INK, 1); - g.lineBetween(x, y - s * 1.25, x, y - s * 0.15); - - // Scarf around neck - if (style.scarf) { - g.fillStyle(INK, 0.2); - g.fillRect(x - s * 0.25, y - s * 1.32, s * 0.5, s * 0.18); - g.strokeRect(x - s * 0.25, y - s * 1.32, s * 0.5, s * 0.18); - } +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); - // Arms if (style.raisedArm) { - g.lineBetween(x, y - s * 0.95, x + dir * s * 0.55, y - s * 1.45); - g.lineBetween(x, y - s * 0.95, x - dir * s * 0.55, y - s * 0.5); - } else { - g.lineBetween(x, y - s * 0.95, x + dir * s * 0.7, y - s * 0.55); - g.lineBetween(x, y - s * 0.95, x - dir * s * 0.5, y - s * 0.5); - } - - // Legs / Skirt - if (style.skirt) { - g.lineBetween(x, y - s * 0.15, x - s * 0.55, y + s * 0.55); - g.lineBetween(x, y - s * 0.15, x + s * 0.55, y + s * 0.55); - g.lineBetween(x - s * 0.55, y + s * 0.55, x + s * 0.55, y + s * 0.55); - } else { - g.lineBetween(x, y - s * 0.15, x - s * 0.35, y + s * 0.55); - g.lineBetween(x, y - s * 0.15, x + s * 0.35, y + s * 0.55); + 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 (style.apron) { - g.strokeRect(x - s * 0.28, y - s * 0.95, s * 0.56, s * 0.7); + 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; } - 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); - } + // 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( @@ -262,47 +403,57 @@ 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, { + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { hair: 'beanie', eyes: 'thoughtful' }); - // Acoustic Guitar held across body - g.lineStyle(2.5, INK, 1); + g.lineStyle(CONTOUR - 0.7, INK, 1); g.fillStyle(PAPER, 1); - g.fillEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.65, s * 0.95, 12); - g.strokeEllipse(x + dir * s * 0.55, y - s * 0.5, s * 0.65, s * 0.95, 12); - // Soundhole - g.fillCircle(x + dir * s * 0.55, y - s * 0.5, s * 0.12); - g.strokeCircle(x + dir * s * 0.55, y - s * 0.5, s * 0.12); - - // Guitar neck & headstock - g.lineBetween(x + dir * s * 0.55, y - s * 0.95, x + dir * s * 0.55, y - s * 1.5); - g.strokeRect(x + dir * s * 0.45, y - s * 1.68, s * 0.2, s * 0.18); - - // Guitar strap across torso - g.lineStyle(1.8, INK, 0.7); - g.lineBetween(x - dir * s * 0.3, y - s * 1.15, x + dir * s * 0.6, y - s * 0.35); - - // Wrist wrap - g.lineStyle(3, INK, 0.8); - g.lineBetween(x + dir * s * 0.35, y - s * 0.7, x + dir * s * 0.55, y - s * 0.55); + 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', eyes: 'happy' }); - 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( @@ -314,8 +465,9 @@ export function drawStickGuitar( const s = 10 * scale; g.lineStyle(2.5, INK, 1); g.fillStyle(PAPER, 1); - g.fillEllipse(x, y - s * 0.2, s * 0.75, s * 1.1, 8); - g.strokeEllipse(x, y - s * 0.2, s * 0.75, s * 1.1, 8); + 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); @@ -326,20 +478,23 @@ 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, { + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { hair: 'ponytail', walkingStick: true, eyes: 'happy' }); - // Travel backpack - g.lineStyle(2.5, INK, 1); - g.fillStyle(PAPER, 1); - g.fillRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); - g.strokeRect(x - dir * s * 0.55, y - s * 1.1, s * 0.38, s * 0.55); + 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( @@ -347,23 +502,26 @@ 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, { + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { hair: 'cap', eyes: 'focused' }); - g.lineStyle(2.5, INK, 1); - // Big clipboard with clip + 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.fillRect(x + dir * s * 0.55, y - s * 1.2, s * 0.25, s * 0.12); - g.strokeRect(x + dir * s * 0.55, y - s * 1.2, s * 0.25, s * 0.12); - 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( @@ -371,29 +529,36 @@ 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, { + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale, pose, { hair: 'ponytail', apron: true, eyes: 'happy' }); - // Lantern held high with warm light rays - 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); + + // 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, y - s * 1.5, s * 0.42, s * 0.42); - g.strokeRect(x + dir * s * 0.52, y - s * 1.5, s * 0.42, s * 0.42); - g.lineBetween(x + dir * s * 0.73, y - s * 1.5, x + dir * s * 0.73, y - s * 1.68); - // Warm hatch inside lantern - g.lineStyle(1.5, INK, 0.45); - g.lineBetween(x + dir * s * 0.6, y - s * 1.4, x + dir * s * 0.86, y - s * 1.18); - // Light rays - g.lineStyle(1.2, INK, 0.35); - g.lineBetween(x + dir * s * 0.98, y - s * 1.3, x + dir * s * 1.3, y - s * 1.4); - g.lineBetween(x + dir * s * 0.98, y - s * 1.1, x + dir * s * 1.3, y - s * 1.0); + 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( @@ -401,9 +566,11 @@ 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, @@ -416,15 +583,17 @@ 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 + drawContactShadow(g, x, y, 32 * scale); + drawBasePerson(g, x, y, facing, scale * 1.05, pose, { hair: 'hat' }); 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( @@ -434,14 +603,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 SHUTTLE" sign mark on side + 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.2, y - s * 0.55, x + s * 0.9, y - s * 0.55); + 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..8372268 100644 --- a/src/game/scenes/ridge/art/types.ts +++ b/src/game/scenes/ridge/art/types.ts @@ -1,10 +1,21 @@ import type { + RidgeActorId, RidgeActorPresence, RidgeAreaId, RidgeMode, RidgeObservation } from '@/game/core/ridge'; +/** The thing the player is currently close enough to act on. */ +export interface RidgeVisualFocus { + spotId: string; + label: string; + prompt: string; + /** Stage position of the spot, used when no actor embodies it. */ + progress: number; + actorId?: RidgeActorId; +} + /** * Replaceable art seam. * Stick math art today; iPad/Procreate sprites later without rewriting gameplay. @@ -16,7 +27,9 @@ export interface RidgeVisualViewModel { facing: RidgeObservation['facing']; beat: RidgeObservation['beat']; ambience: string; - nearbyPrompt: string | null; + focus: RidgeVisualFocus | null; + /** Who is speaking right now, so the world can animate their mouth. */ + speakingActorId: RidgeActorId | null; actors: readonly RidgeActorPresence[]; crossingOpen: boolean; } @@ -35,6 +48,8 @@ export function toRidgeVisualViewModel( observation.beat === 'concert_cleared' || observation.beat === 'dance_cleared'; + const nearest = observation.nearby[0]; + return { mode: observation.mode, areaId: observation.areaId, @@ -42,8 +57,53 @@ export function toRidgeVisualViewModel( facing: observation.facing, beat: observation.beat, ambience: observation.ambience, - nearbyPrompt: observation.nearby[0]?.prompt ?? null, + focus: nearest + ? { + spotId: nearest.spotId, + label: nearest.label, + prompt: nearest.prompt, + progress: nearest.progress, + actorId: nearest.actorId + } + : null, + speakingActorId: observation.conversation + ? actorIdForSpeaker(observation.conversation.speakerId) + : null, actors: observation.actors, crossingOpen }; } + +/** + * Dialogue speaker ids are authored per area; actor ids are the cast on stage. + * Narrator-style speakers deliberately map to nobody. + */ +export function actorIdForSpeaker(speakerId: string): RidgeActorId | null { + switch (speakerId) { + case 'cicka': + return 'cicka'; + case 'counterpartCat': + return 'counterpart-cat'; + case 'bridgeDraftsperson': + case 'draftsperson': + return 'draftsperson'; + case 'injuredGuitarist': + case 'guitarist': + return 'guitarist'; + case 'crowd': + return 'crowd'; + case 'danceDriver': + case 'driver': + return 'driver'; + case 'operationsHelper': + return 'operations-helper'; + case 'danceTeacher': + return 'dance-teacher'; + case 'traveler': + return 'traveler'; + case 'steward': + return 'steward'; + default: + return null; + } +} diff --git a/src/game/scenes/ridge/content/presenceCatalog.ts b/src/game/scenes/ridge/content/presenceCatalog.ts new file mode 100644 index 0000000..ba0b9bd --- /dev/null +++ b/src/game/scenes/ridge/content/presenceCatalog.ts @@ -0,0 +1,15 @@ +import type { RidgeActorId } from '@/game/core/ridge'; +import { getMessages } from '@/shared/i18n'; + +export interface RidgePresenceCatalog { + /** Short role tag under a resident's name. */ + roles: Partial>; + /** Lines a resident mutters as the player walks past. */ + barks: Readonly>; +} + +/** Wires i18n presence copy into the Ridge world chrome. */ +export function loadRidgePresenceCatalog(): RidgePresenceCatalog { + const { roles, barks } = getMessages().scenes.ridge.presence; + return { roles, barks }; +} diff --git a/src/game/scenes/ridge/runtime/RidgeScene.ts b/src/game/scenes/ridge/runtime/RidgeScene.ts index 70ef9cc..4107168 100644 --- a/src/game/scenes/ridge/runtime/RidgeScene.ts +++ b/src/game/scenes/ridge/runtime/RidgeScene.ts @@ -27,6 +27,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'; @@ -84,13 +85,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.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.15); this.syncPresentation(); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.cleanup()); diff --git a/src/shared/i18n/messages/en/scenes.ts b/src/shared/i18n/messages/en/scenes.ts index 163bace..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", From a8dd25af684928e5e7a4a0e74c5c50ee84c944d0 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 6 Aug 2026 00:44:55 +0200 Subject: [PATCH 4/7] feat(ridge): keep hybrid stick scenery baseline Restore dense corn/fore framing and phone-friendly zoom, keep clearer ridges/clouds/smoke from the WIP pass after A/B validation. Co-authored-by: Cursor --- .../scenes/ridge/art/stick/actorSprites.ts | 33 +-- .../scenes/ridge/art/stick/ambientLayer.ts | 21 +- src/game/scenes/ridge/art/stick/areaSets.ts | 209 ++++++++++++------ src/game/scenes/ridge/art/stick/atmosphere.ts | 180 ++++++++------- src/game/scenes/ridge/art/stick/palette.ts | 18 +- 5 files changed, 292 insertions(+), 169 deletions(-) diff --git a/src/game/scenes/ridge/art/stick/actorSprites.ts b/src/game/scenes/ridge/art/stick/actorSprites.ts index e91e866..86d4b31 100644 --- a/src/game/scenes/ridge/art/stick/actorSprites.ts +++ b/src/game/scenes/ridge/art/stick/actorSprites.ts @@ -28,26 +28,29 @@ 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: -78, - cicka: -44, - 'counterpart-cat': -40, - draftsperson: -76, - 'toy-car': -22, - guitarist: -74, - crowd: -66, - guitar: -38, - traveler: -70, - driver: -74, - 'operations-helper': -74, - 'dance-teacher': -80, - steward: -76, - shuttle: -46 + 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] ?? -70; + return (HEAD_TOP[id] ?? -44) * FIGURE_SCALE; } export interface ActorRenderRequest { diff --git a/src/game/scenes/ridge/art/stick/ambientLayer.ts b/src/game/scenes/ridge/art/stick/ambientLayer.ts index c0021bc..bf6045d 100644 --- a/src/game/scenes/ridge/art/stick/ambientLayer.ts +++ b/src/game/scenes/ridge/art/stick/ambientLayer.ts @@ -1,7 +1,7 @@ import type * as Phaser from 'phaser'; import type { RidgeAreaId } from '@/game/core/ridge'; import { drawBird, drawCloud, jitter } from './atmosphere'; -import { GROUND_Y, INK, PAPER, SKY_TOP, STAGE_WIDTH } from './palette'; +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 @@ -47,6 +47,7 @@ export function drawAmbientNear( g.clear(); if (areaId === 'bridge') { + drawCampSmoke(g, tick); drawDriftingSeeds(g, tick); return; } @@ -61,6 +62,24 @@ export function drawAmbientNear( 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; diff --git a/src/game/scenes/ridge/art/stick/areaSets.ts b/src/game/scenes/ridge/art/stick/areaSets.ts index 4b38137..219f68f 100644 --- a/src/game/scenes/ridge/art/stick/areaSets.ts +++ b/src/game/scenes/ridge/art/stick/areaSets.ts @@ -18,7 +18,15 @@ import { STAGE_WIDTH, strokeLeaf } from './atmosphere'; -import { HORIZON_Y, INK, LAYERS, PAPER, PAPER_WARM, SKY_TOP } from './palette'; +import { + BRIDGE_CAMP_X, + HORIZON_Y, + INK, + LAYERS, + PAPER, + PAPER_WARM, + SKY_TOP +} from './palette'; /** * Parallax band being baked. @@ -61,87 +69,94 @@ export function drawRidgeAreaLayer( // --- 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') { - g.fillStyle(INK, 0.1); - g.fillRect(0, 0, STAGE_WIDTH, HORIZON_Y); - drawHatch(g, 0, SKY_TOP, STAGE_WIDTH, 90, 44, 0.08, 0.7); - drawSunOrMoon(g, STAGE_WIDTH - 240, SKY_TOP + 46, 28, 'moon'); - g.fillStyle(INK, 0.4); + // 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 + 10 + jitter(i * 7.7) * 150; + const sy = SKY_TOP + 8 + jitter(i * 7.7) * 120; g.fillRect(sx, sy, 2, 2); } - drawMountainRange(g, ridgeLine(areaId), 0.14, HORIZON_Y); + drawRidges(g, areaId, 0.05, 0.09); return; } if (areaId === 'relay') { - drawSunOrMoon(g, STAGE_WIDTH * 0.66, SKY_TOP + 100, 42, 'sunset'); - drawMountainRange(g, ridgeLine(areaId), 0.12, HORIZON_Y); + drawSunOrMoon(g, STAGE_WIDTH * 0.66, SKY_TOP + 88, 40, 'sunset'); + drawRidges(g, areaId, 0.04, 0.075); return; } - drawSunOrMoon(g, areaId === 'bridge' ? 210 : 250, SKY_TOP + 56, 27, 'sun'); - drawMountainRange(g, ridgeLine(areaId), areaId === 'bridge' ? 0.09 : 0.075, HORIZON_Y); + 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 far ridge — a promise of somewhere to walk toward. - g.lineStyle(1.6, INK, 0.3); - const townY = HORIZON_Y - 58; + // 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 = 22 + (i % 3) * 12; - g.strokeRect(1180 + i * 16, townY - h, 11, h); + const h = 16 + (i % 3) * 10; + g.strokeRect(1180 + i * 15, townY - h, 10, h); } - g.lineBetween(1170, townY, 1310, townY); + g.lineBetween(1168, townY, 1306, townY); } } -/** Peaks live in the upper half of the visible sky, never above it. */ -function ridgeLine(areaId: RidgeAreaId): ReadonlyArray { - const crest = SKY_TOP + 74; - switch (areaId) { - case 'bridge': - return [ - [0, crest + 46], - [280, crest], - [560, crest + 34], - [860, crest - 22], - [1180, crest + 28], - [STAGE_WIDTH, crest + 6] - ]; - case 'concert': - return [ - [0, crest + 58], - [340, crest + 12], - [700, crest + 48], - [1080, crest + 4], - [STAGE_WIDTH, crest + 40] - ]; - case 'relay': - return [ - [0, crest + 44], - [360, crest - 18], - [760, crest + 26], - [1140, crest - 32], - [STAGE_WIDTH, crest + 10] - ]; - default: - return [ - [0, crest + 62], - [400, crest + 18], - [800, crest + 52], - [1200, crest + 2], - [STAGE_WIDTH, crest + 36] - ]; +/** 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; } /** - * Two bands of massed canopy stitching the horizon to the playable lane. - * Individual little trees at this distance read as specks; a silhouette reads - * as woodland. + * 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 @@ -167,14 +182,56 @@ function drawCanopyBand( g.fillStyle(INK, alpha); g.beginPath(); g.moveTo(-spacing, baseY); - for (let x = -spacing; x <= STAGE_WIDTH + spacing; x += spacing) { - const lift = crown * (0.45 + jitter(x * 0.11 + seed) * 0.75); - const radius = spacing * (0.52 + jitter(x * 0.07 + seed) * 0.3); - g.arc(x, baseY - lift, radius, Math.PI, 0); + 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 ------------------------------------------------------------ @@ -184,6 +241,7 @@ function drawBridgeNear( 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); } @@ -223,19 +281,27 @@ function drawBridgeNear( g.lineBetween(mid - 36, GROUND_Y - 44, mid + 20, GROUND_Y - 44); } - const campX = ctx.worldXForProgress(0.46); - g.fillStyle(PAPER_WARM, 0.9); - g.lineStyle(2.6, INK, 0.8); + const campX = BRIDGE_CAMP_X; + g.fillStyle(PAPER_WARM, 1); + g.lineStyle(2.8, INK, 0.85); g.beginPath(); - g.moveTo(campX - 36, GROUND_Y); - g.lineTo(campX, GROUND_Y - 46); - g.lineTo(campX + 36, GROUND_Y); + 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 - 30, 28, 30, 8, 0.28, 0.9); - g.lineStyle(2.2, INK, 0.7); - g.strokeRect(campX + 48, GROUND_Y - 30, 38, 20); + 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); } /** @@ -474,7 +540,6 @@ function drawForeBand(g: Phaser.GameObjects.Graphics, areaId: RidgeAreaId): void 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) { - // An arm up in the air, holding the moment. g.fillRect(x + r * 0.8, shoulder - r * 3.4, 7, r * 2.6); } } diff --git a/src/game/scenes/ridge/art/stick/atmosphere.ts b/src/game/scenes/ridge/art/stick/atmosphere.ts index 7469898..a989767 100644 --- a/src/game/scenes/ridge/art/stick/atmosphere.ts +++ b/src/game/scenes/ridge/art/stick/atmosphere.ts @@ -19,7 +19,11 @@ export function jitter(seed: number): number { return n - Math.floor(n); } -/** Soft cream wash with a warm band top and bottom. */ +/** + * 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, @@ -27,9 +31,10 @@ export function drawPaperBase( ): void { g.fillStyle(PAPER, 1); g.fillRect(0, 0, width, height); - g.fillStyle(PAPER_WARM, 0.5); - g.fillRect(0, 0, width, 40); - g.fillRect(0, height - 48, width, 48); + for (let i = 0; i < 4; i += 1) { + g.fillStyle(PAPER_WARM, 0.22); + g.fillRect(0, 0, width, 44 + i * 34); + } } /** @@ -139,41 +144,46 @@ export function drawContactShadow( g.fillRect(x - width * 0.34, y + 6, width * 0.68, 3); } -/** Hatched earth band, ink horizon, and a light grass scribble. */ +/** + * 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 { - // Trodden lane, then heavier earth below it. - g.fillStyle(INK, 0.045); - g.fillRect(0, groundY, width, 34); - g.fillStyle(INK, 0.09); - g.fillRect(0, groundY + 34, width, STAGE_HEIGHT - groundY - 34); - drawHatch(g, 0, groundY + 34, width, 60, 38, 0.06, 0.9); + 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); - g.lineStyle(1.8, INK, 0.3); - g.lineBetween(0, groundY + 34, width, groundY + 33); - // Pebbles and scuffs along the lane. - g.fillStyle(INK, 0.3); - for (let x = 24; x < width; x += 47) { - g.fillRect(x, groundY + 10 + jitter(x * 1.7) * 18, 3 + jitter(x) * 4, 2); + // 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.lineStyle(1.8, INK, 0.38); - for (let x = 16; x < width; x += 44) { - const h = 8 + jitter(x) * 11; - g.lineBetween(x, groundY, x - 3, groundY - h); - g.lineBetween(x, groundY, x + 4, groundY - h * 0.7); + 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); } } /** - * Single bumpy outline rather than stacked circles — overlapping strokes on - * pale fill read as a Venn diagram, not a cloud. + * 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, @@ -183,13 +193,30 @@ export function drawCloud( 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.arc(x - s * 0.72, y + 3, s * 0.5, Math.PI, 0); - g.arc(x, y - 4, s * 0.8, Math.PI, 0); - g.arc(x + s * 0.78, y + 2, s * 0.55, Math.PI, 0); - g.lineTo(x - s * 1.22, y + 3); + 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(); @@ -225,12 +252,13 @@ export function drawMountainRange( ): void { if (points.length < 2) return; - // Paler crest behind, offset upward. Two flat tones read as depth; loose - // hatching this far away just looks like dirt on the page. - fillRidge(g, points, baseY, INK, alpha * 0.6, -30); + // 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); - g.lineStyle(2, INK, Math.min(0.7, alpha + 0.4)); + // 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) { @@ -259,50 +287,6 @@ function fillRidge( 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); - } -} - /** Corn stalk for the Bridge field: stalk, tassel, and drooping leaves. */ export function drawCornStalk( g: Phaser.GameObjects.Graphics, @@ -347,6 +331,50 @@ export function strokeLeaf( 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, diff --git a/src/game/scenes/ridge/art/stick/palette.ts b/src/game/scenes/ridge/art/stick/palette.ts index 3ca01a0..aeb030d 100644 --- a/src/game/scenes/ridge/art/stick/palette.ts +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -16,6 +16,9 @@ export const GROUND_Y = 520; * 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; @@ -28,13 +31,17 @@ export const LAYERS = { // 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 }, - // Wider than the stage: a scroll factor above 1 outruns the right edge otherwise. + // 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 = { @@ -46,8 +53,9 @@ export const DEPTH = { } as const; /** - * Nameplate fade window. The ramp is deliberately short: a plate lingering at - * half opacity looks like a rendering fault rather than a deliberate fade. + * 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.155; -export const PRESENCE_FAR = 0.19; +export const PRESENCE_NEAR = 0.27; +export const PRESENCE_FAR = 0.33; From a5e5478313890ff2f882509c66b0adafc62ecb5f Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 6 Aug 2026 00:52:43 +0200 Subject: [PATCH 5/7] refactor(ridge): unify cast registry and hot-path cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One speaker→actor/portrait table, area adjacency in core, and tap-to-advance parity; drop duplicate focus shaping and per-frame presence allocs. Co-authored-by: Cursor --- src/game/core/ridge/session.ts | 11 +--- src/game/core/ridge/types.ts | 19 ++++++ .../ridge/art/stick/StickVisualProvider.ts | 17 ++++-- .../scenes/ridge/art/stick/barkDirector.ts | 14 +++-- src/game/scenes/ridge/art/stick/palette.ts | 4 ++ .../scenes/ridge/art/stick/presenceLayer.ts | 18 +++--- src/game/scenes/ridge/art/types.ts | 61 ++----------------- src/game/scenes/ridge/content/castRegistry.ts | 60 ++++++++++++++++++ .../scenes/ridge/content/presenceCatalog.ts | 5 +- src/game/scenes/ridge/runtime/RidgeScene.ts | 23 +------ .../ridge/sceneUi/RidgeConversationPanel.tsx | 10 +-- 11 files changed, 131 insertions(+), 111 deletions(-) create mode 100644 src/game/scenes/ridge/content/castRegistry.ts 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 54d3d55..0ae0c65 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -244,5 +244,24 @@ export const RIDGE_INITIAL_BEAT: Record = { relay: 'relay_linger' }; +/** Left-to-right Compact Ridge Area order for the First Playable Route. */ +export 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 f766e7b..9d63ed3 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -82,6 +82,11 @@ export class StickVisualProvider implements RidgeVisualProvider { 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; @@ -259,7 +264,8 @@ export class StickVisualProvider implements RidgeVisualProvider { motion: boolean, walking: boolean ): void { - const requests: ActorRenderRequest[] = []; + const requests = this.actorRequests; + requests.length = 0; const playerX = this.worldXForProgress(playerProgress); view.actors.forEach((actor, index) => { @@ -303,8 +309,10 @@ export class StickVisualProvider implements RidgeVisualProvider { motion: boolean ): void { const inConversation = view.mode === 'conversation'; - const plates: PlacedPresence[] = []; - const barkCandidates: string[] = []; + 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) { @@ -338,7 +346,8 @@ export class StickVisualProvider implements RidgeVisualProvider { } const bark = this.barkDirector.update(now, barkCandidates); - const barks: ActiveBark[] = []; + const barks = this.activeBarks; + barks.length = 0; if (bark) { const speaker = view.actors.find((actor) => actor.id === bark.actorId); if (speaker) { diff --git a/src/game/scenes/ridge/art/stick/barkDirector.ts b/src/game/scenes/ridge/art/stick/barkDirector.ts index 3e7d15b..666bf3f 100644 --- a/src/game/scenes/ridge/art/stick/barkDirector.ts +++ b/src/game/scenes/ridge/art/stick/barkDirector.ts @@ -1,9 +1,11 @@ /** Ambient chatter scheduling. No Phaser or DOM, so it stays testable. */ -export type BarkLines = Readonly>; +import type { RidgeActorId } from '@/game/core/ridge'; + +export type BarkLines = Partial>; export interface BarkPerformance { - actorId: string; + actorId: RidgeActorId; text: string; /** 0..1 fade envelope for the bubble. */ alpha: number; @@ -23,9 +25,9 @@ const MAX_GAP_MS = 6400; export class BarkDirector { private readonly lines: BarkLines; private readonly random: () => number; - private current: { actorId: string; text: string; startedAt: number } | null = null; + private current: { actorId: RidgeActorId; text: string; startedAt: number } | null = null; private nextAt = 0; - private readonly lastLineByActor = new Map(); + private readonly lastLineByActor = new Map(); constructor(lines: BarkLines, random: () => number = Math.random) { this.lines = lines; @@ -36,7 +38,7 @@ export class BarkDirector { * @param candidates actors currently worth hearing from, nearest first. * @returns the line to show right now, if any. */ - update(now: number, candidates: readonly string[]): BarkPerformance | null { + update(now: number, candidates: readonly RidgeActorId[]): BarkPerformance | null { if (this.current) { const elapsed = now - this.current.startedAt; const stillOnStage = candidates.includes(this.current.actorId); @@ -77,7 +79,7 @@ export class BarkDirector { this.nextAt = now + MIN_GAP_MS; } - private pick(candidates: readonly string[]): { actorId: string; text: string } | null { + 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; diff --git a/src/game/scenes/ridge/art/stick/palette.ts b/src/game/scenes/ridge/art/stick/palette.ts index aeb030d..ce0b5bc 100644 --- a/src/game/scenes/ridge/art/stick/palette.ts +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -4,6 +4,10 @@ export const INK = 0x1a1a1a; export const FAINT = 0x4b4337; 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; diff --git a/src/game/scenes/ridge/art/stick/presenceLayer.ts b/src/game/scenes/ridge/art/stick/presenceLayer.ts index 1a4922c..6eee065 100644 --- a/src/game/scenes/ridge/art/stick/presenceLayer.ts +++ b/src/game/scenes/ridge/art/stick/presenceLayer.ts @@ -1,9 +1,6 @@ import type * as Phaser from 'phaser'; import { createUiText } from '@/game/sharedSceneRuntime/text/createUiText'; -import { DEPTH, INK, PAPER, PAPER_WARM } from './palette'; - -const INK_CSS = '#1a1a1a'; -const PAPER_CSS = '#fbfbf9'; +import { DEPTH, INK, INK_CSS, PAPER, PAPER_CSS, PAPER_WARM } from './palette'; export interface NameplateContent { name: string; @@ -43,6 +40,7 @@ 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) { @@ -50,10 +48,10 @@ export class PresenceLayer { } syncNameplates(entries: readonly PlacedPresence[]): void { - const seen = new Set(); + this.seenIds.clear(); for (const entry of entries) { - seen.add(entry.id); + this.seenIds.add(entry.id); let plate = this.nameplates.get(entry.id); if (!plate) { plate = new Nameplate(this.scene); @@ -64,7 +62,7 @@ export class PresenceLayer { } for (const [id, plate] of this.nameplates) { - if (!seen.has(id)) plate.hide(); + if (!this.seenIds.has(id)) plate.hide(); } } @@ -79,10 +77,10 @@ export class PresenceLayer { } syncBarks(active: readonly ActiveBark[]): void { - const seen = new Set(); + this.seenIds.clear(); for (const bark of active) { - seen.add(bark.id); + this.seenIds.add(bark.id); let bubble = this.barks.get(bark.id); if (!bubble) { bubble = new SpeechBubble(this.scene); @@ -93,7 +91,7 @@ export class PresenceLayer { } for (const [id, bubble] of this.barks) { - if (!seen.has(id)) bubble.hide(); + if (!this.seenIds.has(id)) bubble.hide(); } } diff --git a/src/game/scenes/ridge/art/types.ts b/src/game/scenes/ridge/art/types.ts index 8372268..66d8385 100644 --- a/src/game/scenes/ridge/art/types.ts +++ b/src/game/scenes/ridge/art/types.ts @@ -2,19 +2,11 @@ import type { RidgeActorId, RidgeActorPresence, RidgeAreaId, + RidgeInteractable, RidgeMode, RidgeObservation } from '@/game/core/ridge'; - -/** The thing the player is currently close enough to act on. */ -export interface RidgeVisualFocus { - spotId: string; - label: string; - prompt: string; - /** Stage position of the spot, used when no actor embodies it. */ - progress: number; - actorId?: RidgeActorId; -} +import { actorIdForSpeaker } from '../content/castRegistry'; /** * Replaceable art seam. @@ -27,7 +19,8 @@ export interface RidgeVisualViewModel { facing: RidgeObservation['facing']; beat: RidgeObservation['beat']; ambience: string; - focus: RidgeVisualFocus | 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[]; @@ -48,8 +41,6 @@ export function toRidgeVisualViewModel( observation.beat === 'concert_cleared' || observation.beat === 'dance_cleared'; - const nearest = observation.nearby[0]; - return { mode: observation.mode, areaId: observation.areaId, @@ -57,15 +48,7 @@ export function toRidgeVisualViewModel( facing: observation.facing, beat: observation.beat, ambience: observation.ambience, - focus: nearest - ? { - spotId: nearest.spotId, - label: nearest.label, - prompt: nearest.prompt, - progress: nearest.progress, - actorId: nearest.actorId - } - : null, + focus: observation.nearby[0] ?? null, speakingActorId: observation.conversation ? actorIdForSpeaker(observation.conversation.speakerId) : null, @@ -73,37 +56,3 @@ export function toRidgeVisualViewModel( crossingOpen }; } - -/** - * Dialogue speaker ids are authored per area; actor ids are the cast on stage. - * Narrator-style speakers deliberately map to nobody. - */ -export function actorIdForSpeaker(speakerId: string): RidgeActorId | null { - switch (speakerId) { - case 'cicka': - return 'cicka'; - case 'counterpartCat': - return 'counterpart-cat'; - case 'bridgeDraftsperson': - case 'draftsperson': - return 'draftsperson'; - case 'injuredGuitarist': - case 'guitarist': - return 'guitarist'; - case 'crowd': - return 'crowd'; - case 'danceDriver': - case 'driver': - return 'driver'; - case 'operationsHelper': - return 'operations-helper'; - case 'danceTeacher': - return 'dance-teacher'; - case 'traveler': - return 'traveler'; - case 'steward': - return 'steward'; - default: - return null; - } -} 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 index ba0b9bd..f274974 100644 --- a/src/game/scenes/ridge/content/presenceCatalog.ts +++ b/src/game/scenes/ridge/content/presenceCatalog.ts @@ -1,15 +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: Readonly>; + 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 }; + 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 4107168..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 { @@ -163,13 +165,7 @@ export class RidgeScene extends Phaser.Scene { if (this.prevKey && Phaser.Input.Keyboard.JustDown(this.prevKey)) { const currentArea = this.session.observe().areaId; - const prevAreaMap: Record = { - bridge: 'relay', - concert: 'bridge', - danceFestival: 'concert', - relay: 'danceFestival' - }; - this.applyResult(this.session.exec(`warp ${prevAreaMap[currentArea]}`)); + this.applyResult(this.session.exec(`warp ${ridgePrevArea(currentArea)}`)); this.syncPresentation(); } } @@ -292,16 +288,3 @@ export class RidgeScene extends Phaser.Scene { this.lastConversationKey = null; } } - -function portraitForSpeaker( - speakerId: string -): RidgeConversationPanelView['portrait'] { - if (speakerId === 'cicka' || speakerId === 'counterpart-cat') return 'cicka'; - if (speakerId === 'bridgeDraftsperson' || speakerId === 'draftsperson') return 'draftsperson'; - if (speakerId === 'injuredGuitarist' || speakerId === 'guitarist') return 'guitarist'; - if (speakerId === 'danceDriver' || speakerId === 'driver' || speakerId === 'operationsHelper') return 'driver'; - if (speakerId === 'traveler' || speakerId === 'steward') return 'traveler'; - if (speakerId === 'danceTeacher') return 'teacher'; - 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 fe74a5b..0ec115c 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useState } from 'react'; import type { SceneUiSurfaceProps } from '@/game/sceneUi/registry'; import type { RidgeEmotion } from '@/game/core/ridge'; +import type { RidgePortraitId } from '../content/castRegistry'; export interface RidgeConversationChoiceView { id: string; @@ -16,7 +17,7 @@ export interface RidgeConversationPanelView { lineCount: number; awaitingChoice: boolean; choices: readonly RidgeConversationChoiceView[]; - portrait: 'player' | 'cicka' | 'draftsperson' | 'guitarist' | 'driver' | 'traveler' | 'teacher' | 'prompt'; + portrait: RidgePortraitId; emotion?: RidgeEmotion; } @@ -86,10 +87,14 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac 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 + @@ -157,9 +162,6 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac onClick={(e) => { e.stopPropagation(); handleBoxClick(); - if (!isTyping) { - dispatchAction('ridgeConversationAdvance'); - } }} > Continue[Z / Space] From 2657de530a4a252f3894c6545fca96e35d934d11 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 6 Aug 2026 01:02:33 +0200 Subject: [PATCH 6/7] fix(ridge): reset typewriter without setState-in-effect CI lint failed on react-hooks/set-state-in-effect; reset typedLength when the line key changes during render instead. Co-authored-by: Cursor --- .../ridge/sceneUi/RidgeConversationPanel.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx index 0ec115c..4827235 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -26,24 +26,34 @@ 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; - setTypedLength(0); const targetLen = view.text.length; if (targetLen === 0) return; const interval = setInterval(() => { setTypedLength((prev) => { - if (prev < targetLen) return prev + 1; - clearInterval(interval); - return prev; + if (prev >= targetLen) { + clearInterval(interval); + return prev; + } + return prev + 1; }); }, 16); return () => clearInterval(interval); - }, [view?.text, view?.lineIndex]); + }, [lineKey, view]); const isTyping = view ? typedLength < view.text.length : false; From 27ef7578c328ee513e91194e849d3ed884266576 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Thu, 6 Aug 2026 01:08:20 +0200 Subject: [PATCH 7/7] refactor(ridge): clear fallow audit on stick art Split presence sync / conversation keys and drop unused exports so changed-since main CI gate stays green. Co-authored-by: Cursor --- src/game/core/ridge/types.ts | 2 +- .../ridge/art/stick/StickVisualProvider.ts | 53 +++++++++--- .../scenes/ridge/art/stick/actorSprites.ts | 72 ++++++---------- src/game/scenes/ridge/art/stick/areaSets.ts | 6 +- src/game/scenes/ridge/art/stick/atmosphere.ts | 17 +--- src/game/scenes/ridge/art/stick/palette.ts | 2 - .../scenes/ridge/art/stick/stickFigures.ts | 46 +++++----- .../ridge/sceneUi/RidgeConversationPanel.tsx | 84 +++++++++++++------ 8 files changed, 156 insertions(+), 126 deletions(-) diff --git a/src/game/core/ridge/types.ts b/src/game/core/ridge/types.ts index 0ae0c65..2a68c29 100644 --- a/src/game/core/ridge/types.ts +++ b/src/game/core/ridge/types.ts @@ -245,7 +245,7 @@ export const RIDGE_INITIAL_BEAT: Record = { }; /** Left-to-right Compact Ridge Area order for the First Playable Route. */ -export const RIDGE_AREA_ORDER = ['bridge', 'concert', 'danceFestival', 'relay'] as const; +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 { diff --git a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts index 9d63ed3..8500234 100644 --- a/src/game/scenes/ridge/art/stick/StickVisualProvider.ts +++ b/src/game/scenes/ridge/art/stick/StickVisualProvider.ts @@ -309,6 +309,25 @@ export class StickVisualProvider implements RidgeVisualProvider { 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; + } + + this.syncAmbientBarks(view, now); + this.syncInteractPip(view, tick, motion); + } + + /** 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; @@ -316,11 +335,9 @@ export class StickVisualProvider implements RidgeVisualProvider { const focusActorId = view.focus?.actorId; for (const actor of view.actors) { - if (!actor.visible || actor.id === 'player') continue; - if (actor.id === 'toy-car' || actor.id === 'guitar') continue; + if (!actor.visible || !showsPresenceChrome(actor.id)) continue; - const distance = Math.abs(actor.progress - playerProgress); - const alpha = inConversation ? 0 : presenceAlpha(distance); + const alpha = nameplateAlpha(actor.progress, playerProgress, inConversation); if (alpha <= 0.02) continue; plates.push({ @@ -332,20 +349,14 @@ export class StickVisualProvider implements RidgeVisualProvider { alpha }); - // The focused resident gets the interact pip instead of small talk. if (actor.id !== focusActorId) barkCandidates.push(actor.id); } this.presence.syncNameplates(plates); + } - if (inConversation) { - this.barkDirector.interrupt(now); - this.presence.syncBarks([]); - this.presence.syncFocus(null, 0); - return; - } - - const bark = this.barkDirector.update(now, barkCandidates); + private syncAmbientBarks(view: RidgeVisualViewModel, now: number): void { + const bark = this.barkDirector.update(now, this.barkCandidates); const barks = this.activeBarks; barks.length = 0; if (bark) { @@ -361,7 +372,9 @@ export class StickVisualProvider implements RidgeVisualProvider { } } this.presence.syncBarks(barks); + } + private syncInteractPip(view: RidgeVisualViewModel, tick: number, motion: boolean): void { const focus = view.focus; if (!focus) { this.presence.syncFocus(null, 0); @@ -399,6 +412,20 @@ export class StickVisualProvider implements RidgeVisualProvider { } } +/** 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; diff --git a/src/game/scenes/ridge/art/stick/actorSprites.ts b/src/game/scenes/ridge/art/stick/actorSprites.ts index 86d4b31..80e6fc5 100644 --- a/src/game/scenes/ridge/art/stick/actorSprites.ts +++ b/src/game/scenes/ridge/art/stick/actorSprites.ts @@ -149,6 +149,32 @@ function depthOffsetFor(id: RidgeActorId): number { 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, @@ -156,49 +182,5 @@ function drawActor( facing: RidgeFacing, pose: StickPose ): void { - const s = FIGURE_SCALE; - switch (id) { - case 'player': - drawStickPlayer(g, 0, 0, facing, 1.15 * s, pose); - return; - case 'cicka': - drawStickCicka(g, 0, 0, 1.1 * s, pose); - return; - case 'counterpart-cat': - drawStickCicka(g, 0, 0, 0.95 * s, pose); - return; - case 'draftsperson': - drawStickDraftsperson(g, 0, 0, facing, 1.05 * s, pose); - return; - case 'toy-car': - drawStickToyCar(g, 22, 4, 1.1 * s); - return; - case 'guitarist': - drawStickGuitarist(g, 0, 0, facing, 1.05 * s, pose); - return; - case 'crowd': - drawStickCrowd(g, 0, 0, s, pose); - return; - case 'guitar': - drawStickGuitar(g, 20, 2, 1.1 * s); - return; - case 'traveler': - drawStickTraveler(g, 0, 0, facing, s, pose); - return; - case 'driver': - drawStickDriver(g, 0, 0, facing, 1.05 * s, pose); - return; - case 'operations-helper': - drawStickOperationsHelper(g, 0, 0, facing, 1.05 * s, pose); - return; - case 'dance-teacher': - drawStickDanceTeacher(g, 0, 0, facing, 1.05 * s, pose); - return; - case 'steward': - drawStickSteward(g, 0, 0, facing, s, pose); - return; - case 'shuttle': - drawStickShuttle(g, 0, 0, 1.1 * s); - return; - } + ACTOR_DRAWERS[id](g, facing, pose, FIGURE_SCALE); } diff --git a/src/game/scenes/ridge/art/stick/areaSets.ts b/src/game/scenes/ridge/art/stick/areaSets.ts index 219f68f..30b79ee 100644 --- a/src/game/scenes/ridge/art/stick/areaSets.ts +++ b/src/game/scenes/ridge/art/stick/areaSets.ts @@ -13,19 +13,19 @@ import { drawPaperBacking, drawSunOrMoon, drawTree, - GROUND_Y, jitter, - STAGE_WIDTH, strokeLeaf } from './atmosphere'; import { BRIDGE_CAMP_X, + GROUND_Y, HORIZON_Y, INK, LAYERS, PAPER, PAPER_WARM, - SKY_TOP + SKY_TOP, + STAGE_WIDTH } from './palette'; /** diff --git a/src/game/scenes/ridge/art/stick/atmosphere.ts b/src/game/scenes/ridge/art/stick/atmosphere.ts index a989767..96d284a 100644 --- a/src/game/scenes/ridge/art/stick/atmosphere.ts +++ b/src/game/scenes/ridge/art/stick/atmosphere.ts @@ -1,5 +1,5 @@ import type * as Phaser from 'phaser'; -import { GROUND_Y, INK, PAPER, PAPER_WARM, STAGE_HEIGHT, STAGE_WIDTH, WASH } from './palette'; +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 { @@ -420,19 +420,6 @@ export function drawSunOrMoon( } } -/** Tiny margin caption — storytelling scrap, not UI. */ -export function drawMarginNote( - g: Phaser.GameObjects.Graphics, - x: number, - y: number, - width: number -): void { - g.lineStyle(1.5, INK, 0.28); - g.strokeRect(x, y, width, 22); - g.lineBetween(x + 6, y + 8, x + width - 8, y + 8); - g.lineBetween(x + 6, y + 14, x + width * 0.55, y + 14); -} - export function drawPaperBacking( g: Phaser.GameObjects.Graphics, x: number, @@ -448,5 +435,3 @@ export function drawPaperBacking( 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); } - -export { STAGE_WIDTH, STAGE_HEIGHT, GROUND_Y }; diff --git a/src/game/scenes/ridge/art/stick/palette.ts b/src/game/scenes/ridge/art/stick/palette.ts index ce0b5bc..01d3043 100644 --- a/src/game/scenes/ridge/art/stick/palette.ts +++ b/src/game/scenes/ridge/art/stick/palette.ts @@ -1,7 +1,6 @@ export const PAPER = 0xfbfbf9; export const PAPER_WARM = 0xf4f1ea; export const INK = 0x1a1a1a; -export const FAINT = 0x4b4337; export const WASH = 0x2a241c; /** CSS twins of {@link PAPER} / {@link INK} for Phaser Text styles. */ @@ -28,7 +27,6 @@ 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 VIEW_BOTTOM = GROUND_Y + VIEW_BELOW_GROUND; export const LAYERS = { // Runs all the way to the ground line so the distant fill passes behind the diff --git a/src/game/scenes/ridge/art/stick/stickFigures.ts b/src/game/scenes/ridge/art/stick/stickFigures.ts index 1f4714c..58bb8af 100644 --- a/src/game/scenes/ridge/art/stick/stickFigures.ts +++ b/src/game/scenes/ridge/art/stick/stickFigures.ts @@ -185,6 +185,27 @@ interface PersonStyle { 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( g: Phaser.GameObjects.Graphics, x: number, @@ -406,10 +427,7 @@ export function drawStickGuitarist( scale = 1, pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawContactShadow(g, x, y, 32 * scale); - drawBasePerson(g, x, y, facing, scale, pose, { + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { hair: 'beanie', eyes: 'thoughtful' }); @@ -481,10 +499,7 @@ export function drawStickTraveler( scale = 1, pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawContactShadow(g, x, y, 32 * scale); - drawBasePerson(g, x, y, facing, scale, pose, { + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { hair: 'ponytail', walkingStick: true, eyes: 'happy' @@ -505,10 +520,7 @@ export function drawStickDriver( scale = 1, pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawContactShadow(g, x, y, 32 * scale); - drawBasePerson(g, x, y, facing, scale, pose, { + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { hair: 'cap', eyes: 'focused' }); @@ -532,10 +544,7 @@ export function drawStickOperationsHelper( scale = 1, pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawContactShadow(g, x, y, 32 * scale); - drawBasePerson(g, x, y, facing, scale, pose, { + const { s, dir } = placeStickPerson(g, x, y, facing, scale, pose, { hair: 'ponytail', apron: true, eyes: 'happy' @@ -586,10 +595,7 @@ export function drawStickSteward( scale = 1, pose: StickPose = STILL ): void { - const s = 16 * scale; - const dir = facing === 'left' ? -1 : 1; - drawContactShadow(g, x, y, 32 * scale); - drawBasePerson(g, x, y, facing, scale * 1.05, pose, { hair: 'hat' }); + 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.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); diff --git a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx index 4827235..288b604 100644 --- a/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx +++ b/src/game/scenes/ridge/sceneUi/RidgeConversationPanel.tsx @@ -61,32 +61,12 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac if (!view) return; const onKey = (event: KeyboardEvent) => { - if (view.awaitingChoice && view.choices.length > 0) { - if (event.key >= '1' && event.key <= '9') { - const index = parseInt(event.key, 10) - 1; - if (index < view.choices.length) { - event.preventDefault(); - dispatchAction('ridgeConversationChoose', { choiceId: view.choices[index].id }); - return; - } - } - } - - if (!view.awaitingChoice) { - if (event.key === 'Enter' || event.key === ' ' || event.key === 'z' || event.key === 'Z') { - event.preventDefault(); - if (isTyping) { - setTypedLength(view.text.length); - } else { - 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); @@ -200,6 +180,58 @@ export function RidgeConversationPanel({ params, dispatchAction }: SceneUiSurfac ); } +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',