feat(02-02): render layer + Garden scene + scheduler integration

- src/render/garden/tile-coords.ts: GRID_LAYOUT (96px tiles + 16px gap, centered in 1024×768) + tileTopLeftCanvas / tileCenterCanvas / tileCenterToDom helpers (RESEARCH Pattern 4 + Assumption A5: handles Phaser.Scale.FIT scaling via canvas getBoundingClientRect)
- src/render/garden/tile-renderer.ts: drawTiles(scene) — 16 outlined rounded rectangles with hover state (D-06)
- src/render/garden/plant-renderer.ts: drawPlant(scene, idx, tile, stage) — primitive shapes per stage (sprout dot / mature stem / ready bloom) tinted by plant type (D-26); destroyPlant cleanup
- src/render/garden/ready-pulse.ts: applyReadyPulse alpha-cycle tween for ready stage (D-27)
- src/render/garden/index.ts + src/render/index.ts: barrels
- src/game/scenes/Garden.ts: Phaser Garden scene wires scheduler ↔ store ↔ render. update() loop drains commands → simulateOneTick → simAdapter.applyTilesAndUnlocks. appStore.subscribe drives reactive plant repaint (Pitfall 6 mitigation: subscribe, not read-once-in-create). pointerdown on empty tile emits 'tile-clicked-coords' for the React seed picker. BLOCKER 3 invariant: lastTickAt is read-through from store (NOT seeded with this.currentTick).
- src/game/main.ts: scene registry now [Boot, Garden]
- src/game/scenes/Boot.ts: create() transitions to Garden
- Lint clean; build clean; 153/153 tests pass (Task-1 sim/garden tests + all baseline)
This commit is contained in:
2026-05-09 09:36:09 -04:00
parent e82a11b988
commit 537016b48f
9 changed files with 371 additions and 11 deletions
+7 -5
View File
@@ -1,10 +1,12 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { Boot } from './scenes/Boot.ts'; import { Boot } from './scenes/Boot.ts';
import { Garden } from './scenes/Garden.ts';
// Phase 1: minimal Phaser config that boots cleanly. Real scenes (garden, weather, // Phase 2: Phaser config now adds the Garden scene (Plan 02-02). Boot
// watercolor post-process) land in Phase 2+. The architectural-firewall directories // transitions into Garden once the scene tree is up. The architectural-
// (src/sim, src/render, src/ui) are siblings to this one — see `.planning/phases/ // firewall directories (src/sim, src/render, src/ui) are siblings to
// 01-foundations-and-doctrine/01-RESEARCH.md` § "Architectural Responsibility Map". // this one — see `.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md`
// § "Architectural Responsibility Map".
const config: Phaser.Types.Core.GameConfig = { const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO, type: Phaser.AUTO,
@@ -16,7 +18,7 @@ const config: Phaser.Types.Core.GameConfig = {
mode: Phaser.Scale.FIT, mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH, autoCenter: Phaser.Scale.CENTER_BOTH,
}, },
scene: [Boot], scene: [Boot, Garden],
}; };
const StartGame = (parent: string): Phaser.Game => { const StartGame = (parent: string): Phaser.Game => {
+9 -6
View File
@@ -1,19 +1,22 @@
import * as Phaser from 'phaser'; 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, * Phase 2: Boot scene transitions to Garden once Phaser is up.
// gated behind the "Tend the garden / Begin" gesture screen that calls * No assets to load in Phase 2 (D-26 = Phaser primitives only); the
// AudioContext.resume() per CLAUDE.md banner concern #7. * 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 { export class Boot extends Phaser.Scene {
constructor() { constructor() {
super('Boot'); super('Boot');
} }
preload(): void { preload(): void {
// No assets in Phase 1. // Phase 3 wires the preloader (watercolor textures, fonts, audio).
} }
create(): void { create(): void {
// Phase 2 will start the preloader from here. this.scene.start('Garden');
} }
} }
+165
View File
@@ -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<number, PlantGameObject> = new Map();
private readyTweens: Map<number, Phaser.Tweens.Tween> = 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();
}
}
+9
View File
@@ -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';
+45
View File
@@ -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();
}
+22
View File
@@ -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,
});
}
+57
View File
@@ -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,
};
}
+49
View File
@@ -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;
}
+8
View File
@@ -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';