fix(02-06,G4): add wall band primitive in gate-renderer — close floating-gate gap

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 12:16:42 -04:00
parent ab48c7ef30
commit 88adc4f623
2 changed files with 131 additions and 8 deletions
+96
View File
@@ -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<typeof drawGate>[0];
function makeRectangleMock(): {
setInteractive: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
setData: ReturnType<typeof vi.fn>;
setBlendMode: ReturnType<typeof vi.fn>;
setAlpha: ReturnType<typeof vi.fn>;
} {
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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
+35 -8
View File
@@ -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,