From 88adc4f6239807d37f03a614d49cf749eccfa9cc Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 12:16:42 -0400 Subject: [PATCH] =?UTF-8?q?fix(02-06,G4):=20add=20wall=20band=20primitive?= =?UTF-8?q?=20in=20gate-renderer=20=E2=80=94=20close=20floating-gate=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 4th Phaser primitive to drawGate: a faint vertical wall band at the gate's column (x=880) spanning the full 768px canvas height with alpha=0.18 (mid of the 0.15-0.20 fix_shape range). Closes G4 first-impression UX gap from 2026-05-09 live UAT — the gate now reads as part of a wall rather than a floating gray rectangle, honoring the bible's "walled garden" framing. Z-order: wall (behind) → body → glow → hit. The wall band shares the GATE_COLOR hue (0x6e6e75); the low alpha is what visually distinguishes the wall (structural context) from the body (the load-bearing element). WALL_BAND_WIDTH = GATE_HIT_W * 0.55 = 44px — narrower than the gate so the gate body still reads as the focal point. The wall does NOT pulse; updateGateIndicator continues to manage only the glow's alpha tween. Phase 3 watercolor deferral preserved: this is a single Phaser primitive, no painted texture, no animation. GateGameObjects interface gains a `wall` field — additive, so the existing Garden.ts consumer continues to work unchanged (it stores the whole returned object in this.gate). Vitest: 4 new cases green via Phaser-Scene-mock pattern (constants in range, first rectangle call has wall geometry, 4 total rectangles, gate exposes the wall handle). npm run ci exits 0; 333/333 total green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/render/garden/gate-renderer.test.ts | 96 +++++++++++++++++++++++++ src/render/garden/gate-renderer.ts | 43 ++++++++--- 2 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 src/render/garden/gate-renderer.test.ts diff --git a/src/render/garden/gate-renderer.test.ts b/src/render/garden/gate-renderer.test.ts new file mode 100644 index 0000000..5e46268 --- /dev/null +++ b/src/render/garden/gate-renderer.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** + * G4 (gap closure 02-06) — assert gate-renderer adds a faint vertical + * wall band primitive at the gate's column. + * + * Phaser-Scene-mock pattern from Plan 02-06 Task 3 (avoids the Phaser 4 / + * happy-dom canvas.getContext incompatibility per Plan 02-02 SUMMARY). + * vi.mock('phaser') short-circuits the Phaser bundle import so this test + * can exercise drawGate's call surface in isolation; BlendModes.ADD is + * mocked as a sentinel value. + */ +vi.mock('phaser', () => ({ + default: { BlendModes: { ADD: 1 } }, + BlendModes: { ADD: 1 }, +})); + +const { + drawGate, + WALL_BAND_X, + WALL_BAND_WIDTH, + WALL_BAND_HEIGHT, + WALL_BAND_ALPHA, + WALL_BAND_COLOR, +} = await import('./gate-renderer'); +type Scene = Parameters[0]; + +function makeRectangleMock(): { + setInteractive: ReturnType; + on: ReturnType; + setData: ReturnType; + setBlendMode: ReturnType; + setAlpha: ReturnType; +} { + const r = { + setInteractive: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + setData: vi.fn().mockReturnThis(), + setBlendMode: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + }; + return r; +} + +function makeScene(): Scene { + const rectangle = makeRectangleMock(); + return { + add: { + rectangle: vi.fn(() => rectangle), + }, + tweens: { add: vi.fn() }, + } as unknown as Scene; +} + +describe('gate-renderer (Plan 02-06 G4 closure)', () => { + it('exports the wall band geometry constants with expected values', () => { + expect(WALL_BAND_X).toBe(880); // matches GATE_X + expect(WALL_BAND_HEIGHT).toBe(768); // matches Phaser canvas height + expect(WALL_BAND_ALPHA).toBeGreaterThanOrEqual(0.15); // fix_shape range + expect(WALL_BAND_ALPHA).toBeLessThanOrEqual(0.2); // fix_shape range + expect(WALL_BAND_COLOR).toBe(0x6e6e75); // same hue as GATE_COLOR + expect(WALL_BAND_WIDTH).toBeGreaterThan(0); + }); + + it('drawGate adds the wall primitive at the gate column with low alpha', () => { + const scene = makeScene(); + drawGate(scene); + + // First scene.add.rectangle call is the wall band (per drawGate + // implementation order — wall is drawn behind everything else). + const firstCall = (scene.add.rectangle as ReturnType).mock.calls[0]; + // Signature: (x, y, width, height, fillColor, fillAlpha) + expect(firstCall[0]).toBe(WALL_BAND_X); // x + expect(firstCall[1]).toBe(WALL_BAND_HEIGHT / 2); // y-centered + expect(firstCall[2]).toBe(WALL_BAND_WIDTH); // width + expect(firstCall[3]).toBe(WALL_BAND_HEIGHT); // height = canvas height (full vertical span) + expect(firstCall[4]).toBe(WALL_BAND_COLOR); // color + expect(firstCall[5]).toBe(WALL_BAND_ALPHA); // alpha — low (0.18) + }); + + it('drawGate creates 4 rectangles total (wall + body + glow + hit)', () => { + const scene = makeScene(); + drawGate(scene); + expect(scene.add.rectangle as ReturnType).toHaveBeenCalledTimes(4); + }); + + it('returned GateGameObjects exposes the wall handle', () => { + const scene = makeScene(); + const gate = drawGate(scene); + expect(gate.wall).toBeDefined(); + expect(gate.body).toBeDefined(); + expect(gate.glow).toBeDefined(); + expect(gate.hit).toBeDefined(); + expect(gate.glowTween).toBeNull(); + }); +}); diff --git a/src/render/garden/gate-renderer.ts b/src/render/garden/gate-renderer.ts index 8d2905a..4ff47b5 100644 --- a/src/render/garden/gate-renderer.ts +++ b/src/render/garden/gate-renderer.ts @@ -1,7 +1,7 @@ import * as Phaser from 'phaser'; /** - * Phaser primitive gate visual + indicator (D-15). + * Phaser primitive gate visual + indicator (D-15) + wall context (G4). * * The gate sits at the right edge of the 4×4 garden (canvas pixel * coordinates). When a Lura beat is pending — luraBeatProgress.pending @@ -9,8 +9,12 @@ import * as Phaser from 'phaser'; * When the player clicks the gate's hit rectangle, the Garden scene * dispatches setDialogueOverlayOpen(true), which mounts LuraDialogue. * - * Phase 3 paints over with the watercolor gate. The hit + glow shapes - * stay in place; only the body's primitive draw is replaced. + * Plan 02-06 G4 — additionally renders a faint vertical wall band at + * the gate's column connecting top-to-bottom of the canvas. Phaser + * primitive only (no painted texture, no animation). Phase 3 paints + * the watercolor wall over this band without changing the structural + * intent. The bible's "walled garden" framing requires the gate to + * read as part of a wall, not a free-floating element. */ // Canvas-space coordinates. The garden's 4×4 grid is centered at @@ -24,7 +28,18 @@ const GATE_GLOW_COLOR = 0xe8d8b6; const GATE_HIT_W = 80; const GATE_HIT_H = 120; +// Plan 02-06 G4 — wall band geometry. Spans the canvas height vertically +// and is centered on the gate's column. Faint alpha so the gate body +// reads as the load-bearing element; the wall is structural context only. +export const WALL_BAND_X = GATE_X; +export const WALL_BAND_WIDTH = GATE_HIT_W * 0.55; // narrower than the gate hit so the gate body still reads +export const WALL_BAND_HEIGHT = 768; // matches Phaser canvas height in src/game/main.ts +export const WALL_BAND_ALPHA = 0.18; // mid of the 0.15-0.20 fix_shape range +export const WALL_BAND_COLOR = GATE_COLOR; // same hue as gate body, low alpha distinguishes them + export interface GateGameObjects { + /** Plan 02-06 G4 — faint vertical wall band primitive (no animation). */ + wall: Phaser.GameObjects.Rectangle; hit: Phaser.GameObjects.Rectangle; body: Phaser.GameObjects.Rectangle; glow: Phaser.GameObjects.Rectangle; @@ -32,11 +47,22 @@ export interface GateGameObjects { } /** - * drawGate — adds the three rectangles (body / glow / hit) to the - * scene and returns handles. The glow is initially fully transparent - * (alpha=0); updateGateIndicator manages its visibility. + * drawGate — adds the four rectangles (wall / body / glow / hit) to the + * scene and returns handles. Z-order: wall (behind) → body → glow → hit. + * The glow is initially fully transparent (alpha=0); updateGateIndicator + * manages its visibility. */ export function drawGate(scene: Phaser.Scene): GateGameObjects { + // Plan 02-06 G4 — wall band first (drawn behind everything else). + const wall = scene.add.rectangle( + WALL_BAND_X, + WALL_BAND_HEIGHT / 2, // y-centered on the canvas + WALL_BAND_WIDTH, + WALL_BAND_HEIGHT, + WALL_BAND_COLOR, + WALL_BAND_ALPHA, + ); + const body = scene.add.rectangle( GATE_X, GATE_Y, @@ -65,13 +91,14 @@ export function drawGate(scene: Phaser.Scene): GateGameObjects { ); hit.setInteractive({ useHandCursor: true }); hit.setData('isGate', true); - return { hit, body, glow, glowTween: null }; + return { wall, hit, body, glow, glowTween: null }; } /** * updateGateIndicator — start/stop the soft alpha pulse based on * whether a beat is pending. Idempotent: calling it twice with the - * same isPending value is a no-op. + * same isPending value is a no-op. Plan 02-06 G4 leaves this function + * untouched — the wall band does NOT pulse. */ export function updateGateIndicator( scene: Phaser.Scene,