diff --git a/src/game/main.ts b/src/game/main.ts index 9187299..18b92cc 100644 --- a/src/game/main.ts +++ b/src/game/main.ts @@ -1,10 +1,12 @@ import * as Phaser from 'phaser'; import { Boot } from './scenes/Boot.ts'; +import { Garden } from './scenes/Garden.ts'; -// Phase 1: minimal Phaser config that boots cleanly. Real scenes (garden, weather, -// watercolor post-process) land in Phase 2+. The architectural-firewall directories -// (src/sim, src/render, src/ui) are siblings to this one — see `.planning/phases/ -// 01-foundations-and-doctrine/01-RESEARCH.md` § "Architectural Responsibility Map". +// Phase 2: Phaser config now adds the Garden scene (Plan 02-02). Boot +// transitions into Garden once the scene tree is up. The architectural- +// firewall directories (src/sim, src/render, src/ui) are siblings to +// this one — see `.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md` +// § "Architectural Responsibility Map". const config: Phaser.Types.Core.GameConfig = { type: Phaser.AUTO, @@ -16,7 +18,7 @@ const config: Phaser.Types.Core.GameConfig = { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, }, - scene: [Boot], + scene: [Boot, Garden], }; const StartGame = (parent: string): Phaser.Game => { diff --git a/src/game/scenes/Boot.ts b/src/game/scenes/Boot.ts index 0d5ad82..7849c56 100644 --- a/src/game/scenes/Boot.ts +++ b/src/game/scenes/Boot.ts @@ -1,19 +1,22 @@ import * as Phaser from 'phaser'; -// Phase 1 placeholder: empty Boot scene that just proves Phaser starts. -// Phase 2 replaces this with the real boot → preloader → garden flow, -// gated behind the "Tend the garden / Begin" gesture screen that calls -// AudioContext.resume() per CLAUDE.md banner concern #7. +/** + * Phase 2: Boot scene transitions to Garden once Phaser is up. + * No assets to load in Phase 2 (D-26 = Phaser primitives only); the + * Begin-screen gate that calls AudioContext.resume() lives in the + * React UI layer (src/ui/begin/BeginScreen.tsx) per CLAUDE.md banner + * concern #7. + */ export class Boot extends Phaser.Scene { constructor() { super('Boot'); } preload(): void { - // No assets in Phase 1. + // Phase 3 wires the preloader (watercolor textures, fonts, audio). } create(): void { - // Phase 2 will start the preloader from here. + this.scene.start('Garden'); } } diff --git a/src/game/scenes/Garden.ts b/src/game/scenes/Garden.ts new file mode 100644 index 0000000..fe3ba8e --- /dev/null +++ b/src/game/scenes/Garden.ts @@ -0,0 +1,165 @@ +import * as Phaser from 'phaser'; +import { eventBus } from '../event-bus'; +import { drainTicks, wallClock, type Clock } from '../../sim/scheduler'; +import type { SimState } from '../../sim/state'; +import { simulateOneTick, tileGrowthStage } from '../../sim/garden'; +import type { Tile } from '../../sim/garden/types'; +import { + drawTiles, + drawPlant, + destroyPlant, + applyReadyPulse, + tileCenterToDom, + type TileGameObjects, + type PlantGameObject, +} from '../../render/garden'; +import { appStore, simAdapter } from '../../store'; + +/** + * The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into + * Phaser's update() loop, draws tiles, dispatches pointer events to + * the EventBus + store, and re-renders plants on store changes. + * + * The Garden scene is the ONLY place where sim + store + render meet. + * It stays thin (RESEARCH Pattern 3): subscribe, dispatch. + */ +export class Garden extends Phaser.Scene { + private accumulatorMs = 0; + private lastFrameMs = 0; + private clock: Clock = wallClock; + private currentTick = 0; + private tileObjs: TileGameObjects[] = []; + private plantObjs: Map = new Map(); + private readyTweens: Map = new Map(); + private storeUnsubscribe: (() => void) | null = null; + + constructor() { + super('Garden'); + } + + create(): void { + // Allow Playwright (Plan 02-05) to swap in a FakeClock via a window- + // scoped slot. Production-guarded via import.meta.env.PROD by the + // application layer (PhaserGame.tsx) — this scene only reads. + const slot = (window as unknown as { __tlgClock?: Clock }).__tlgClock; + if (slot) this.clock = slot; + + // Restore tickCount from the store (set on save load by saveSync). + this.currentTick = appStore.getState().tickCount; + + this.tileObjs = drawTiles(this); + this.tileObjs.forEach((t, idx) => { + t.hit.on('pointerdown', () => this.handleTilePointerDown(idx)); + }); + + this.lastFrameMs = this.clock.now(); + + // Re-render plants when tiles change in the store (Pitfall 6 mitigation: + // subscribe rather than read once in create()). + this.storeUnsubscribe = appStore.subscribe((state) => { + this.repaintPlants(state.tiles as Tile[]); + }); + this.repaintPlants(appStore.getState().tiles as Tile[]); + + eventBus.emit('scene-ready', this); + } + + update(_time: number, _delta: number): void { + const now = this.clock.now(); + const deltaMs = now - this.lastFrameMs; + this.lastFrameMs = now; + if (deltaMs > 0) this.accumulatorMs += deltaMs; + + // Build current SimState snapshot from the store + drain commands. + const storeState = appStore.getState(); + const commands = simAdapter.drainCommands(); + + // BLOCKER 3 — DO NOT seed lastTickAt with this.currentTick. lastTickAt + // is wall-clock ms owned by saveSync. The Garden scene's snapshot + // copies the value already in the store (which was hydrated from the + // save and has not been touched by the sim). tickCount is the sim's + // own counter and is read-through from the scene's local counter. + const simStateNow: SimState = { + garden: { tiles: storeState.tiles }, + plants: [], + harvestedFragmentIds: storeState.harvestedFragmentIds, + lastTickAt: storeState.lastTickAt ?? 0, + tickCount: this.currentTick, + unlockedPlantTypes: storeState.unlockedPlantTypes, + luraBeatProgress: storeState.luraBeatProgress, + offlineEvents: null, + settings: { + musicVolume: 0.7, + ambientVolume: 0.5, + sfxVolume: 0.8, + persistenceToastShown: storeState.persistenceToastShown, + }, + }; + + const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => { + const next = simulateOneTick(s, this.currentTick + 1, commands); + this.currentTick++; + return next; + }); + this.accumulatorMs = result.remainderMs; + + if (result.ticksApplied > 0) { + simAdapter.applyTilesAndUnlocks( + result.state.garden.tiles, + result.state.unlockedPlantTypes, + ); + } + } + + private handleTilePointerDown(idx: number): void { + const tiles = appStore.getState().tiles as Tile[]; + const tile = tiles[idx]; + if (!tile || !tile.plant) { + // Empty tile — emit event for the React seed picker. + const dom = tileCenterToDom(this, idx); + eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y }); + return; + } + // Plan 02-03 wires harvest / compost on plant click. + } + + private repaintPlants(tiles: Tile[]): void { + for (let idx = 0; idx < tiles.length; idx++) { + const tile = tiles[idx]; + const stage = tile?.plant ? tileGrowthStage(tile, this.currentTick) : null; + const existing = this.plantObjs.get(idx); + + if (!stage || !tile?.plant) { + if (existing) { + destroyPlant(existing); + this.plantObjs.delete(idx); + this.readyTweens.get(idx)?.stop(); + this.readyTweens.delete(idx); + } + continue; + } + + // Repaint if missing or stage changed. + if (!existing || existing.stage !== stage) { + if (existing) destroyPlant(existing); + const next = drawPlant(this, idx, tile, stage); + if (next) { + this.plantObjs.set(idx, next); + if (stage === 'ready') { + this.readyTweens.get(idx)?.stop(); + this.readyTweens.set(idx, applyReadyPulse(this, next.shape)); + } + } + } + } + } + + destroy(): void { + this.storeUnsubscribe?.(); + this.storeUnsubscribe = null; + this.readyTweens.forEach((t) => t.stop()); + this.readyTweens.clear(); + this.plantObjs.forEach((p) => destroyPlant(p)); + this.plantObjs.clear(); + } +} diff --git a/src/render/garden/index.ts b/src/render/garden/index.ts new file mode 100644 index 0000000..a06828d --- /dev/null +++ b/src/render/garden/index.ts @@ -0,0 +1,9 @@ +/** + * Public barrel for src/render/garden/. Phaser scenes import from here. + */ +export { drawTiles } from './tile-renderer'; +export type { TileGameObjects } from './tile-renderer'; +export { drawPlant, destroyPlant } from './plant-renderer'; +export type { PlantGameObject } from './plant-renderer'; +export { applyReadyPulse } from './ready-pulse'; +export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords'; diff --git a/src/render/garden/plant-renderer.ts b/src/render/garden/plant-renderer.ts new file mode 100644 index 0000000..0749d20 --- /dev/null +++ b/src/render/garden/plant-renderer.ts @@ -0,0 +1,45 @@ +import * as Phaser from 'phaser'; +import type { Tile, GrowthStage } from '../../sim/garden/types'; +import { PLANT_TYPES } from '../../sim/garden/plants'; +import { tileCenterCanvas, GRID_LAYOUT } from './tile-coords'; + +/** + * Plant primitives per CONTEXT D-26. + * sprout = small dot (radius 6) near the tile bottom + * mature = stem rectangle (width 4, height 24) at tile center + * ready = bloom shape (filled circle, radius 18) at tile center + * + * Tinted by plant type (PLANT_TYPES[plantTypeId].tints[stage]). + * Phase 3 swaps in painted sprites without touching this signature. + */ +export interface PlantGameObject { + shape: Phaser.GameObjects.Shape; + stage: GrowthStage; +} + +export function drawPlant( + scene: Phaser.Scene, + tileIdx: number, + tile: Tile, + stage: GrowthStage, +): PlantGameObject | null { + if (!tile.plant) return null; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) return null; + const center = tileCenterCanvas(tileIdx); + const tint = type.tints[stage]; + + let shape: Phaser.GameObjects.Shape; + if (stage === 'sprout') { + shape = scene.add.circle(center.x, center.y + GRID_LAYOUT.tileSize / 4, 6, tint); + } else if (stage === 'mature') { + shape = scene.add.rectangle(center.x, center.y, 4, 24, tint); + } else { + shape = scene.add.circle(center.x, center.y, 18, tint); + } + return { shape, stage }; +} + +export function destroyPlant(obj: PlantGameObject | null): void { + obj?.shape.destroy(); +} diff --git a/src/render/garden/ready-pulse.ts b/src/render/garden/ready-pulse.ts new file mode 100644 index 0000000..867c1f9 --- /dev/null +++ b/src/render/garden/ready-pulse.ts @@ -0,0 +1,22 @@ +import * as Phaser from 'phaser'; + +/** + * Subtle alpha pulse on ready-stage plants. Per CONTEXT D-27. Phase 3 + * paints over with a warmer light treatment. + * + * Returns the tween so the scene can stop it when the plant is harvested + * or the tile changes stage. + */ +export function applyReadyPulse( + scene: Phaser.Scene, + target: Phaser.GameObjects.GameObject, +): Phaser.Tweens.Tween { + return scene.tweens.add({ + targets: target, + alpha: { from: 0.7, to: 1.0 }, + duration: 1200, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); +} diff --git a/src/render/garden/tile-coords.ts b/src/render/garden/tile-coords.ts new file mode 100644 index 0000000..3e8ba4d --- /dev/null +++ b/src/render/garden/tile-coords.ts @@ -0,0 +1,57 @@ +import * as Phaser from 'phaser'; +import { GRID_COLS, GRID_SIZE } from '../../sim/garden/types'; + +/** + * 4×4 garden layout in canvas pixel coordinates. Centered in the + * 1024×768 game area declared in src/game/main.ts. + * + * Tile size + spacing chosen so the grid sits comfortably with margins + * for Phase-3 watercolor frames. Phase 2 ships placeholder primitives + * inside these bounds. + * + * Math (canvas 1024×768; tileSize 96; tileGap 16): + * gridWidth = 4*96 + 3*16 = 432 + * gridHeight = 4*96 + 3*16 = 432 + * gridOriginX = (1024 - 432)/2 = 296 + * gridOriginY = (768 - 432)/2 = 168 + */ +export const GRID_LAYOUT = Object.freeze({ + tileSize: 96, + tileGap: 16, + gridOriginX: 296, + gridOriginY: 168, +}); + +export function tileTopLeftCanvas(idx: number): { x: number; y: number } { + if (idx < 0 || idx >= GRID_SIZE) throw new Error(`Bad tile idx: ${idx}`); + const row = Math.floor(idx / GRID_COLS); + const col = idx % GRID_COLS; + const x = GRID_LAYOUT.gridOriginX + col * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap); + const y = GRID_LAYOUT.gridOriginY + row * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap); + return { x, y }; +} + +export function tileCenterCanvas(idx: number): { x: number; y: number } { + const tl = tileTopLeftCanvas(idx); + return { x: tl.x + GRID_LAYOUT.tileSize / 2, y: tl.y + GRID_LAYOUT.tileSize / 2 }; +} + +/** + * Convert a tile center from canvas pixel space to viewport DOM coordinates. + * The seed picker (DOM popover) uses this to mount itself in absolute-position + * over the canvas (RESEARCH Pattern 4 + Assumption A5). + * + * Phaser.Scale.FIT scales + letterboxes; we need the actual canvas DOMRect + * to translate canvas-space → CSS pixel space. + */ +export function tileCenterToDom(scene: Phaser.Scene, idx: number): { x: number; y: number } { + const center = tileCenterCanvas(idx); + const canvas = scene.game.canvas; + const rect = canvas.getBoundingClientRect(); + const scaleX = rect.width / scene.game.scale.width; + const scaleY = rect.height / scene.game.scale.height; + return { + x: rect.left + center.x * scaleX, + y: rect.top + center.y * scaleY, + }; +} diff --git a/src/render/garden/tile-renderer.ts b/src/render/garden/tile-renderer.ts new file mode 100644 index 0000000..ef9eb72 --- /dev/null +++ b/src/render/garden/tile-renderer.ts @@ -0,0 +1,49 @@ +import * as Phaser from 'phaser'; +import { GRID_SIZE } from '../../sim/garden/types'; +import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords'; + +/** + * Empty-tile look: faint outlined rounded rectangle with subtle hover. + * Per CONTEXT D-06; Phase 3 paints the watercolor treatment over this + * primitive without changing the function signature. + */ +const OUTLINE_COLOR = 0x4d4d52; +const OUTLINE_HOVER = 0x6e6e75; +const OUTLINE_ALPHA = 0.6; + +export interface TileGameObjects { + /** Hit-area rectangle (interactive). */ + hit: Phaser.GameObjects.Rectangle; + /** Outline graphic. */ + outline: Phaser.GameObjects.Graphics; +} + +function drawOutline(g: Phaser.GameObjects.Graphics, tlX: number, tlY: number, color: number): void { + g.clear(); + g.lineStyle(2, color, OUTLINE_ALPHA); + g.strokeRoundedRect(tlX, tlY, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6); +} + +export function drawTiles(scene: Phaser.Scene): TileGameObjects[] { + const tiles: TileGameObjects[] = []; + for (let i = 0; i < GRID_SIZE; i++) { + const tl = tileTopLeftCanvas(i); + const cx = tl.x + GRID_LAYOUT.tileSize / 2; + const cy = tl.y + GRID_LAYOUT.tileSize / 2; + + const g = scene.add.graphics(); + drawOutline(g, tl.x, tl.y, OUTLINE_COLOR); + + // Hit rectangle (transparent, interactive). + const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0); + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerover', () => drawOutline(g, tl.x, tl.y, OUTLINE_HOVER)); + hit.on('pointerout', () => drawOutline(g, tl.x, tl.y, OUTLINE_COLOR)); + + // Tag the hit object with its index for handler dispatch. + hit.setData('tileIdx', i); + + tiles.push({ hit, outline: g }); + } + return tiles; +} diff --git a/src/render/index.ts b/src/render/index.ts new file mode 100644 index 0000000..19389b5 --- /dev/null +++ b/src/render/index.ts @@ -0,0 +1,8 @@ +/** + * Top-level barrel for src/render/. App code imports from here. + * + * Per CORE-10: src/sim/** must NOT import from this module. The sim + * stays rendering-agnostic; the Phaser scene tree (src/game/**) is the + * only place sim + render meet. + */ +export * from './garden';