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:
+7
-5
@@ -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 => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user