kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

commit cb8a19cf1823462c6e17520c52c4d1aafd7f5cc8
parent 1ca8544426df7055ce8be4e4fb9a20e29d3d2aca
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Wed, 22 Apr 2026 12:39:04 -0700

New implementation

Diffstat:
Afire_truck_plan.md | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/fire-truck/game.js | 360+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/fire-truck/index.html | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/fire-truck/lib.js | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mjs/main.js | 2+-
Mtests/browser/runner.js | 68+++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtests/unit/projection.test.js | 77++++++++++++++++++-----------------------------------------------------------
Mtests/unit/route.test.js | 85++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
8 files changed, 1059 insertions(+), 123 deletions(-)

diff --git a/fire_truck_plan.md b/fire_truck_plan.md @@ -0,0 +1,331 @@ +# Fire Truck Implementation Plan + +## Goal + +Implement a new `fire-truck` game from a clean slate as a simple top-down Phaser game for young kids. + +The first shipped version should support: + +1. Loading from the portal as a live game. +2. Rendering a top-down city with roads and placeholder buildings. +3. Automatically driving a fire truck along streets. +4. Prompting the player at intersections to press the correct arrow key: + - left arrow = turn left + - up arrow = go straight + - right arrow = turn right +5. Stopping the truck on wrong or late input. +6. Playing a fail sound and showing a clear visual warning on failure. +7. Resuming once the correct arrow is pressed. +8. Continuing indefinitely through newly generated turns and intersections. + +Out of scope for this phase: + +- Fires, destinations, scoring, timers, lives +- Traffic, pedestrians, collisions +- Detailed art assets +- Complex audio/music systems +- Multiple levels or difficulty settings + +## Repo Constraints + +Follow existing project conventions: + +1. Static HTML, CSS, and JavaScript only. +2. No build tooling. +3. Phaser loaded from CDN. +4. Optional audio via Web Audio API or Tone.js loaded from CDN. +5. No external image assets. Use Phaser graphics / generated textures only. +6. Browser tests expect `preserveDrawingBuffer: true`. +7. No `console.log` in production code. +8. Expose `window.__FT_SCENE__ = this` in `create()` for browser test inspection. + +## Current State + +The repository currently has stale fire-truck tests referencing an older pseudo-3D implementation: + +- `tests/unit/route.test.js` +- `tests/unit/projection.test.js` +- fire-truck-specific cases in `tests/browser/runner.js` + +The `games/fire-truck/` directory does not currently exist. + +The portal already has a placeholder tile for `fire-truck` in `js/main.js`, but it is marked `coming-soon`. + +This implementation should treat the game as a fresh top-down design and replace all outdated fire-truck assumptions. + +## Deliverables + +Create or update these files: + +1. `games/fire-truck/index.html` +2. `games/fire-truck/lib.js` +3. `games/fire-truck/game.js` +4. `js/main.js` +5. `tests/unit/route.test.js` +6. `tests/unit/projection.test.js` +7. `tests/browser/runner.js` + +## High-Level Architecture + +Split the implementation into two layers: + +1. Pure logic in `lib.js` +2. Phaser scene and rendering in `game.js` + +### `lib.js` responsibilities + +`lib.js` should contain all pure, deterministic logic that can be unit tested in Node without Phaser. + +Keep it small and focused on: + +1. Road network generation +2. Intersection classification +3. Move legality +4. Heading transitions +5. Prompt selection +6. Path extension for infinite driving + +### `game.js` responsibilities + +`game.js` should contain: + +1. Phaser scene setup +2. World generation bootstrapping +3. Rendering of roads, buildings, and truck +4. Camera follow behavior +5. Automatic movement +6. Intersection detection +7. Prompt UI +8. Input handling +9. Failure/recovery behavior +10. Minimal SFX +11. Debug state exposure for browser tests + +## Core Design Decisions + +### 1. World representation + +Use a grid-based city. + +Each cell should be one of: + +1. road cell +2. building cell + +Road cells should store exits in cardinal directions: + +- `N` +- `E` +- `S` +- `W` + +A road cell can therefore represent: + +1. straight +2. corner +3. T intersection +4. four-way intersection + +This keeps generation and movement simple and testable. + +### 2. Truck movement model + +The truck should move automatically from cell center to cell center along the road network. + +Use a heading enum: + +- `north` +- `east` +- `south` +- `west` + +The truck should always be aligned to one heading and centered in its lane/path. + +Movement loop: + +1. truck moves along current road segment +2. when nearing next intersection, prompt appears +3. player must press correct arrow before reaching the center +4. if correct, truck commits the move and continues +5. if wrong or late, truck stops +6. while stopped, only the correct key resumes movement + +### 3. Prompt timing + +The correct key must be pressed before the truck reaches the center of the intersection. + +### 4. Infinite play model + +Do not generate an infinite full map up front. + +Instead: + +1. generate an initial connected city chunk around the start +2. track the route ahead +3. extend the network / route ahead as needed + +The simplest reliable version is route-first generation with local filler roads/buildings around it. + +## Recommended Implementation Strategy + +Implement in this order: + +1. Create the HTML shell. +2. Implement pure logic functions in `lib.js`. +3. Replace the old unit tests with tests for the new logic. +4. Implement the Phaser scene in `game.js`. +5. Update `js/main.js` so the portal tile is live. +6. Replace old browser tests with new top-down gameplay tests. +7. Run `npm test` and fix issues. + +## Acceptance Criteria + +The feature is complete when all of the following are true: + +1. The `Fire Truck` tile on the portal is live and opens the game. +2. The game loads without console or runtime errors. +3. A top-down road/building city renders clearly. +4. The truck starts driving automatically after load. +5. A prompt appears before intersections. +6. Left/right/up arrows correspond to left/right/straight. +7. Wrong input stops the truck and shows fail feedback. +8. Missing the prompt before the intersection center also stops the truck. +9. Pressing the correct arrow while stopped resumes movement. +10. The route continues indefinitely without obvious dead ends. +11. The fire-truck unit tests reflect the new design, not the old pseudo-3D one. +12. Browser smoke tests reflect the new top-down gameplay. +13. `npm test` passes. + +## Next Steps + +The first version is now implemented and passing tests. The next development pass should focus on feel and city quality rather than adding unrelated features. + +### 1. Raise runtime frame rate and remove the low-FPS workaround path + +Current issue: + +- The scene currently includes extra wall-clock stepping to keep Playwright stable under headless throttling. +- That keeps tests reliable, but it is a workaround rather than the ideal runtime architecture. +- The visual update rate and motion smoothness should be improved in normal play. + +Goals: + +1. Keep gameplay smooth at interactive frame rates in the browser. +2. Reduce dependence on duplicate movement stepping paths. +3. Preserve browser test reliability without degrading real gameplay. + +Recommended work: + +1. Profile the current render/update path and simplify anything done every frame that can be precomputed. +2. Keep static city drawing on cached graphics or render textures rather than redrawing dynamic content unnecessarily. +3. Ensure only the truck, prompt UI, and transient effects are changing each frame. +4. Revisit the Playwright compatibility path so test stability does not force a slower-feeling runtime. +5. Prefer one canonical movement/update pipeline if possible, with test timing adapted around it rather than maintaining two divergent timing behaviors long-term. + +Acceptance criteria for this step: + +1. Motion looks smoother during normal play. +2. Prompt timing remains correct. +3. Browser tests still pass. +4. No new console warnings or errors are introduced. + +### 2. Make street grid generation much more connected and realistic + +Current issue: + +- The current generation is route-first with local side branches. +- It is good enough for v1 gameplay, but it does not yet feel like a believable dense city. +- Connectivity is limited and the road network is too obviously centered around the active route. + +Goals: + +1. Produce a city grid that feels more like real urban blocks. +2. Increase cross-connectivity between nearby streets. +3. Include many more plausible T intersections and 4-ways. +4. Reduce the feeling of isolated decorative branches. + +Recommended generation direction: + +1. Start from a coarse block plan instead of only the truck route. +2. Build a connected backbone of horizontal and vertical streets across a rectangular neighborhood. +3. Add secondary streets that connect existing roads rather than ending as stubs. +4. Use sparse omissions to create T intersections intentionally, instead of relying on ad hoc branch placement. +5. Maintain short-to-medium block lengths so intersections occur often enough for the teaching loop. + +Recommended concrete algorithm changes: + +1. Generate several north-south avenues spanning the neighborhood. +2. Generate several east-west streets spanning the neighborhood. +3. Use probabilistic gaps at selected crossings to convert some full crossings into T intersections. +4. Add short connector streets only when they join two existing streets or complete a block edge. +5. Run a connectivity pass to ensure the playable component is highly connected. +6. Keep the truck route embedded inside this broader street graph rather than serving as the primary source of roads. + +Recommended test additions for this step: + +1. Verify the generated road graph has a large connected component. +2. Verify the graph contains both T intersections and 4-ways in realistic proportions. +3. Verify the average road-cell degree is above a minimum threshold. +4. Verify there are multiple alternate neighboring connections around the active route. + +Acceptance criteria for this step: + +1. The visible city looks denser and more grid-like. +2. Roads feel mutually connected rather than decorative. +3. The active route still supports infinite driving. +4. Prompt opportunities remain frequent and readable. + +### 3. Smooth turn animation instead of heading snap + +Current issue: + +- Heading changes are mechanically correct but visually abrupt. + +Goals: + +1. Make turns feel more natural. +2. Preserve the simple teaching gameplay. + +Recommended work: + +1. Interpolate truck rotation during the intersection-to-exit transition. +2. Optionally move along a short corner arc rather than a strict two-segment snap. +3. Keep the implementation small; do not add full path spline complexity unless required. + +Acceptance criteria for this step: + +1. Left and right turns read clearly. +2. Straight movement remains centered and stable. +3. Prompt timing and fail timing do not regress. + +### 4. Add positive feedback for correct input + +Current issue: + +- Failure is communicated clearly. +- Success currently lacks an equally clear but gentle reward cue. + +Goals: + +1. Reinforce correct arrow input for young kids. +2. Keep audio short and non-intrusive. + +Recommended work: + +1. Add a brief success chirp or two-note chime on correct input. +2. Optionally add a small visual confirmation such as a soft flash or badge. +3. Keep success feedback lighter than failure feedback so the screen stays calm. + +Acceptance criteria for this step: + +1. Correct input is immediately rewarding. +2. Feedback does not obscure the next prompt. +3. Audio still respects browser autoplay constraints. + +### Recommended implementation order for the next pass + +1. Improve the street graph generator first. +2. Rework runtime update/render flow for smoother frame rate. +3. Add turn smoothing. +4. Add success feedback. +5. Update tests where necessary and rerun `npm test`. diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -0,0 +1,360 @@ +const FT = window.FireTruckLib; + +const CELL_SIZE = 96; +const ROAD_WIDTH = 44; +const BASE_SPEED = 170; +const MAX_SPEED = 220; +const ACCEL = 260; +const BRAKE = 320; +const PROMPT_TRIGGER_DIST = CELL_SIZE * 0.72; +const STOP_LINE_DIST = 6; + +class FireTruckScene extends Phaser.Scene { + constructor() { + super({ key: 'FireTruckScene' }); + this.state = 'driving'; + this.speed = BASE_SPEED; + this.targetSpeed = BASE_SPEED; + this.promptDir = null; + this.failVisible = false; + this.route = []; + this.city = null; + this.routeIndex = 0; + this.phase = 'approach'; + this.currentStep = null; + this.heading = 'east'; + this.segmentEnd = { x: 0, y: 0 }; + this.audioCtx = null; + this.promptResolved = false; + this.warningUntil = 0; + this.truckX = 0; + this.truckY = 0; + } + + create() { + this.cameras.main.setBackgroundColor('#d8ead2'); + + const rng = { + next: () => Math.random(), + pick: arr => arr[Math.floor(Math.random() * arr.length)], + }; + + this.route = FT.buildRoute(64, rng); + this.city = FT.buildCity(this.route, rng); + + this.roadGraphics = this.add.graphics(); + this.buildingGraphics = this.add.graphics(); + this.overlay = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0xe63946, 0) + .setOrigin(0, 0) + .setScrollFactor(0) + .setDepth(40); + + this.promptText = this.add.text(this.scale.width / 2, this.scale.height - 74, '', { + fontFamily: 'Fredoka, sans-serif', + fontSize: '34px', + color: '#ffffff', + stroke: '#2f2f2f', + strokeThickness: 8, + align: 'center', + }).setOrigin(0.5).setScrollFactor(0).setDepth(50); + + this.statusText = this.add.text(this.scale.width / 2, this.scale.height - 34, 'Watch for the next arrow.', { + fontFamily: 'Fredoka, sans-serif', + fontSize: '20px', + color: '#213321', + backgroundColor: '#ffffff', + padding: { x: 10, y: 4 }, + }).setOrigin(0.5).setScrollFactor(0).setDepth(50); + this.statusText.setAlpha(0.88); + + this.renderCity(); + this.createTruck(); + + this.currentStep = this.route[0]; + const start = this.worldPoint(-1, 0); + this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); + this.heading = this.currentStep.headingIn; + this.truck.setPosition(start.x, start.y); + this.truck.rotation = this.rotationForHeading(this.heading); + + this.input.keyboard.on('keydown', this.onKeyDown, this); + this.scale.on('resize', this.onResize, this); + this.onResize(this.scale.gameSize); + this.time.addEvent({ delay: 16, loop: true, callback: () => this.stepByWallClock() }); + + this.cameras.main.startFollow(this.truck, true, 0.08, 0.08); + window.__FT_SCENE__ = this; + this.refreshDebug(); + } + + worldPoint(x, y) { + return { x: x * CELL_SIZE, y: y * CELL_SIZE }; + } + + renderCity() { + const road = this.roadGraphics; + const buildings = this.buildingGraphics; + road.clear(); + buildings.clear(); + + const roadCells = new Set(this.city.cells.map(cell => cell.x + ',' + cell.y)); + const xs = this.city.cells.map(cell => cell.x); + const ys = this.city.cells.map(cell => cell.y); + const minX = Math.min(...xs) - 4; + const maxX = Math.max(...xs) + 4; + const minY = Math.min(...ys) - 4; + const maxY = Math.max(...ys) + 4; + const palette = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb]; + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + if (roadCells.has(x + ',' + y)) continue; + const px = x * CELL_SIZE - CELL_SIZE * 0.38; + const py = y * CELL_SIZE - CELL_SIZE * 0.38; + const color = palette[Math.abs((x * 13 + y * 17) % palette.length)]; + buildings.fillStyle(color, 1); + buildings.fillRoundedRect(px, py, CELL_SIZE * 0.76, CELL_SIZE * 0.76, 10); + buildings.fillStyle(0xffffff, 0.16); + buildings.fillRect(px + 8, py + 8, CELL_SIZE * 0.3, 10); + } + } + + this.city.cells.forEach(cell => { + const center = this.worldPoint(cell.x, cell.y); + road.fillStyle(0x4a4e57, 1); + road.fillRect(center.x - ROAD_WIDTH / 2, center.y - ROAD_WIDTH / 2, ROAD_WIDTH, ROAD_WIDTH); + if (cell.exits.N) road.fillRect(center.x - ROAD_WIDTH / 2, center.y - CELL_SIZE / 2, ROAD_WIDTH, CELL_SIZE / 2); + if (cell.exits.S) road.fillRect(center.x - ROAD_WIDTH / 2, center.y, ROAD_WIDTH, CELL_SIZE / 2); + if (cell.exits.E) road.fillRect(center.x, center.y - ROAD_WIDTH / 2, CELL_SIZE / 2, ROAD_WIDTH); + if (cell.exits.W) road.fillRect(center.x - CELL_SIZE / 2, center.y - ROAD_WIDTH / 2, CELL_SIZE / 2, ROAD_WIDTH); + + road.fillStyle(0xfff4b1, 0.92); + if (cell.exits.N && cell.exits.S) road.fillRect(center.x - 3, center.y - CELL_SIZE / 2 + 8, 6, CELL_SIZE - 16); + if (cell.exits.E && cell.exits.W) road.fillRect(center.x - CELL_SIZE / 2 + 8, center.y - 3, CELL_SIZE - 16, 6); + }); + } + + createTruck() { + const body = this.add.rectangle(0, 0, 40, 22, 0xe63946, 1).setStrokeStyle(3, 0x9b1c25, 1); + const cab = this.add.rectangle(11, 0, 14, 16, 0xf6f7fb, 1).setStrokeStyle(2, 0xbcc6d4, 1); + const ladder = this.add.rectangle(-4, 0, 14, 4, 0xffd23f, 1); + const light = this.add.rectangle(-14, 0, 8, 8, 0x1d7cf2, 1); + this.truck = this.add.container(0, 0, [body, cab, ladder, light]); + this.truck.setDepth(35); + } + + onResize(gameSize) { + this.overlay.setSize(gameSize.width, gameSize.height); + this.promptText.setPosition(gameSize.width / 2, gameSize.height - 74); + this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34); + } + + onKeyDown(event) { + const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null; + if (!move || !this.promptDir) return; + this.initAudio(); + if (move === this.promptDir) { + this.promptResolved = true; + this.failVisible = false; + this.overlay.setFillStyle(0xe63946, 0); + this.state = 'driving'; + this.targetSpeed = MAX_SPEED; + this.statusText.setText('Great! Keep driving.'); + this.refreshDebug(); + return; + } + if (this.state !== 'stopped') this.enterStopped(); + } + + initAudio() { + if (this.audioCtx) return; + try { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } catch (_) {} + } + + playFailSound() { + if (!this.audioCtx) return; + try { + const ctx = this.audioCtx; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'square'; + osc.frequency.setValueAtTime(220, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + 0.24); + gain.gain.setValueAtTime(0.18, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.26); + } catch (_) {} + } + + enterStopped() { + this.state = 'stopped'; + this.targetSpeed = 0; + this.failVisible = true; + this.warningUntil = this.time.now + 250; + this.overlay.setFillStyle(0xe63946, 0.18); + this.statusText.setText('Try the ' + this.promptDir + ' arrow.'); + this.playFailSound(); + this.refreshDebug(); + } + + update(_, deltaMs) { + const dt = Math.min(deltaMs / 1000, 1 / 15); + if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * dt); + if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * dt); + + const dx = this.segmentEnd.x - this.truck.x; + const dy = this.segmentEnd.y - this.truck.y; + const dist = Math.hypot(dx, dy); + + if (this.phase === 'approach' && !this.promptDir && dist <= PROMPT_TRIGGER_DIST) { + this.promptDir = this.currentStep.move; + this.promptResolved = false; + this.state = 'prompting'; + this.statusText.setText('Press the ' + this.promptDir + ' arrow before the crossing.'); + } + + if (this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { + this.enterStopped(); + } + + if (dist > 0.001 && this.speed > 0) { + const travel = Math.min(dist, this.speed * dt); + this.truck.x += (dx / dist) * travel; + this.truck.y += (dy / dist) * travel; + } + + const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y); + if (remaining <= 0.8) { + this.truck.setPosition(this.segmentEnd.x, this.segmentEnd.y); + this.advancePhase(); + } + + if (this.failVisible && this.state !== 'stopped') { + this.failVisible = false; + this.overlay.setFillStyle(0xe63946, 0); + } + if (this.state === 'stopped' && this.time.now > this.warningUntil) { + this.overlay.setFillStyle(0xe63946, 0.12); + } + + this.truck.rotation = this.rotationForHeading(this.heading); + this.refreshDebug(); + } + + // Playwright headless runs Phaser at a very low frame rate; advance using real elapsed time too. + stepByWallClock() { + const now = performance.now(); + if (!this.lastWallClock) { + this.lastWallClock = now; + return; + } + const dt = Math.min((now - this.lastWallClock) / 1000, 0.05); + this.lastWallClock = now; + if (dt <= 0) return; + + if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * dt); + if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * dt); + + const dx = this.segmentEnd.x - this.truck.x; + const dy = this.segmentEnd.y - this.truck.y; + const dist = Math.hypot(dx, dy); + if (this.phase === 'approach' && !this.promptDir && dist <= PROMPT_TRIGGER_DIST) { + this.promptDir = this.currentStep.move; + this.promptResolved = false; + this.state = 'prompting'; + this.statusText.setText('Press the ' + this.promptDir + ' arrow before the crossing.'); + } + if (this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { + this.enterStopped(); + } + if (dist > 0.001 && this.speed > 0) { + const travel = Math.min(dist, this.speed * dt); + this.truck.x += (dx / dist) * travel; + this.truck.y += (dy / dist) * travel; + } + const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y); + if (remaining <= 0.8) { + this.truck.setPosition(this.segmentEnd.x, this.segmentEnd.y); + this.advancePhase(); + } + if (this.failVisible && this.state !== 'stopped') { + this.failVisible = false; + this.overlay.setFillStyle(0xe63946, 0); + } + if (this.state === 'stopped' && this.time.now > this.warningUntil) { + this.overlay.setFillStyle(0xe63946, 0.12); + } + this.truck.rotation = this.rotationForHeading(this.heading); + this.refreshDebug(); + } + + advancePhase() { + if (this.phase === 'approach') { + if (this.promptDir && !this.promptResolved) return; + this.heading = this.currentStep.headingOut; + this.segmentEnd = this.worldPoint(this.currentStep.exitX, this.currentStep.exitY); + this.phase = 'exit'; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED; + return; + } + + this.routeIndex += 1; + this.extendRouteIfNeeded(); + this.currentStep = this.route[this.routeIndex]; + if (!this.currentStep) return; + this.heading = this.currentStep.headingIn; + this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); + this.phase = 'approach'; + this.promptDir = null; + this.promptResolved = false; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED; + this.statusText.setText('Watch for the next arrow.'); + } + + extendRouteIfNeeded() { + if (this.routeIndex < this.route.length - 12) return; + const last = this.route[this.route.length - 1]; + const extension = FT.buildRoute(36).map((step, index) => ({ + ...step, + index: this.route.length + index, + x: step.x + last.exitX, + y: step.y + last.exitY, + exitX: step.exitX + last.exitX, + exitY: step.exitY + last.exitY, + })); + this.route.push(...extension); + } + + rotationForHeading(heading) { + if (heading === 'east') return 0; + if (heading === 'south') return Math.PI / 2; + if (heading === 'west') return Math.PI; + return -Math.PI / 2; + } + + refreshDebug() { + this.truckX = this.truck ? this.truck.x : 0; + this.truckY = this.truck ? this.truck.y : 0; + this.promptText.setText(this.promptDir ? this.promptDir.toUpperCase() : ''); + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game-container', + backgroundColor: '#d8ead2', + scale: { + mode: Phaser.Scale.RESIZE, + width: window.innerWidth, + height: window.innerHeight, + }, + render: { preserveDrawingBuffer: true }, + scene: [FireTruckScene], +}); diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Fire Truck - KGames</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet"> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { height: 100%; overflow: hidden; } + body { + background: #d8ead2; + font-family: 'Fredoka', sans-serif; + display: flex; + flex-direction: column; + } + nav { + width: 100%; + padding: 0.6rem 1rem; + position: fixed; + top: 0; + left: 0; + z-index: 20; + pointer-events: none; + } + nav a { + pointer-events: auto; + color: #3c4c39; + text-decoration: none; + font-size: 1rem; + background: rgba(255, 255, 255, 0.72); + border-radius: 999px; + padding: 0.35rem 0.8rem; + display: inline-block; + } + #hud { + position: fixed; + top: 0.7rem; + left: 50%; + transform: translateX(-50%); + z-index: 20; + background: rgba(255, 255, 255, 0.78); + color: #2e372d; + border-radius: 18px; + padding: 0.55rem 0.9rem; + text-align: center; + min-width: min(92vw, 360px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + } + #hud .title { font-size: 1.2rem; font-weight: 600; } + #hud .hint { font-size: 0.9rem; opacity: 0.78; } + #game-container { flex: 1; width: 100%; height: 100%; } + </style> +</head> +<body> + <nav><a href="../../index.html">&larr; Back to KGames</a></nav> + <div id="hud"> + <div class="title">Arrow Key Fire Truck</div> + <div class="hint">Left arrow = left, up arrow = straight, right arrow = right</div> + </div> + <div id="game-container"></div> + <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> + <script src="lib.js"></script> + <script src="game.js"></script> +</body> +</html> diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -0,0 +1,191 @@ +(function (root) { + const HEADINGS = ['north', 'east', 'south', 'west']; + const CARDINALS = ['N', 'E', 'S', 'W']; + const DX = { north: 0, east: 1, south: 0, west: -1 }; + const DY = { north: -1, east: 0, south: 1, west: 0 }; + const TO_CARD = { north: 'N', east: 'E', south: 'S', west: 'W' }; + const OPPOSITE = { north: 'south', east: 'west', south: 'north', west: 'east' }; + const LEFT = { north: 'west', west: 'south', south: 'east', east: 'north' }; + const RIGHT = { north: 'east', east: 'south', south: 'west', west: 'north' }; + const RNG = { + next() { return Math.random(); }, + pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }, + }; + + function keyFor(x, y) { + return x + ',' + y; + } + + function getRng(rng) { + return rng || RNG; + } + + function cloneExits(exits) { + const out = { N: false, E: false, S: false, W: false }; + CARDINALS.forEach(card => { out[card] = !!exits[card]; }); + return out; + } + + function turnHeading(heading, move) { + if (move === 'straight') return heading; + if (move === 'left') return LEFT[heading]; + if (move === 'right') return RIGHT[heading]; + throw new Error('Invalid move: ' + move); + } + + function getRelativeMove(fromHeading, toHeading) { + if (toHeading === fromHeading) return 'straight'; + if (toHeading === LEFT[fromHeading]) return 'left'; + if (toHeading === RIGHT[fromHeading]) return 'right'; + throw new Error('Unsupported heading transition'); + } + + function stepPosition(pos, heading) { + return { x: pos.x + DX[heading], y: pos.y + DY[heading] }; + } + + function classifyIntersection(exits) { + const norm = cloneExits(exits); + const dirs = CARDINALS.filter(card => norm[card]); + const degree = dirs.length; + let kind = 'dead-end'; + if (degree === 4) kind = 'four'; + else if (degree === 3) kind = 't'; + else if (degree === 2) { + kind = (norm.N && norm.S) || (norm.E && norm.W) ? 'straight' : 'corner'; + } else if (degree === 1) { + kind = 'dead-end'; + } + return { degree, kind, exits: norm }; + } + + function getLegalMoves(cell, heading) { + const exits = cloneExits(cell.exits || {}); + const legal = []; + if (exits[TO_CARD[heading]]) legal.push('straight'); + if (exits[TO_CARD[LEFT[heading]]]) legal.push('left'); + if (exits[TO_CARD[RIGHT[heading]]]) legal.push('right'); + return legal; + } + + function pickPromptMove(legalMoves, rng) { + if (!legalMoves.length) throw new Error('No legal moves'); + return getRng(rng).pick(legalMoves); + } + + function ensureCell(grid, x, y) { + const key = keyFor(x, y); + if (!grid[key]) grid[key] = { x, y, exits: { N: false, E: false, S: false, W: false } }; + return grid[key]; + } + + function connectCells(grid, from, to, heading) { + const a = ensureCell(grid, from.x, from.y); + const b = ensureCell(grid, to.x, to.y); + const outCard = TO_CARD[heading]; + const inCard = TO_CARD[OPPOSITE[heading]]; + a.exits[outCard] = true; + b.exits[inCard] = true; + } + + function addBranch(grid, pos, heading, length) { + let current = { x: pos.x, y: pos.y }; + for (let i = 0; i < length; i++) { + const next = stepPosition(current, heading); + connectCells(grid, current, next, heading); + current = next; + } + } + + function buildRoute(count, rng) { + const rand = getRng(rng); + const route = []; + let at = { x: 0, y: 0 }; + let heading = 'east'; + let lastMove = null; + for (let i = 0; i < count; i++) { + let options = ['left', 'straight', 'right']; + if (lastMove === 'left') options = ['straight', 'right']; + if (lastMove === 'right') options = ['left', 'straight']; + const move = rand.pick(options); + const nextHeading = turnHeading(heading, move); + const intersection = stepPosition(at, heading); + const exitCell = stepPosition(intersection, nextHeading); + route.push({ + index: i, + x: intersection.x, + y: intersection.y, + headingIn: heading, + headingOut: nextHeading, + move, + exitX: exitCell.x, + exitY: exitCell.y, + }); + at = exitCell; + heading = nextHeading; + lastMove = move; + } + return route; + } + + function buildCity(route, rng) { + const rand = getRng(rng); + const grid = {}; + const seen = new Set(); + let prevCell = { x: -1, y: 0 }; + ensureCell(grid, prevCell.x, prevCell.y); + connectCells(grid, prevCell, { x: 0, y: 0 }, 'east'); + seen.add(keyFor(prevCell.x, prevCell.y)); + seen.add(keyFor(0, 0)); + + route.forEach((step, index) => { + const center = { x: step.x, y: step.y }; + const exit = { x: step.exitX, y: step.exitY }; + connectCells(grid, prevCell, center, step.headingIn); + connectCells(grid, center, exit, step.headingOut); + seen.add(keyFor(center.x, center.y)); + seen.add(keyFor(exit.x, exit.y)); + + const leftHeading = LEFT[step.headingIn]; + const rightHeading = RIGHT[step.headingIn]; + if (index % 5 !== 0 || step.move !== 'left') addBranch(grid, center, leftHeading, 1); + if (index % 4 !== 1 || step.move !== 'right') addBranch(grid, center, rightHeading, 1); + if (index % 3 === 0 && rand.next() < 0.85) addBranch(grid, exit, LEFT[step.headingOut], 1); + if (index % 4 === 0 && rand.next() < 0.75) addBranch(grid, exit, RIGHT[step.headingOut], 1); + + prevCell = exit; + }); + + const cells = Object.values(grid).map(cell => ({ + x: cell.x, + y: cell.y, + exits: cloneExits(cell.exits), + meta: classifyIntersection(cell.exits), + })); + return { grid, cells }; + } + + function summarizeKinds(cells) { + return cells.reduce((acc, cell) => { + const kind = classifyIntersection(cell.exits).kind; + acc[kind] = (acc[kind] || 0) + 1; + return acc; + }, {}); + } + + const api = { + HEADINGS, + turnHeading, + getRelativeMove, + stepPosition, + classifyIntersection, + getLegalMoves, + pickPromptMove, + buildRoute, + buildCity, + summarizeKinds, + }; + + if (typeof module !== 'undefined' && module.exports) module.exports = api; + root.FireTruckLib = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/main.js b/js/main.js @@ -1,6 +1,6 @@ const GAMES = [ { slug: 'letter-find', title: 'Letter Find', thumb: 'assets/thumbnails/letter-find.svg', status: 'live' }, - { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'coming-soon' }, + { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'live' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -78,15 +78,15 @@ async function main() { await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(2200); + await page.waitForTimeout(1800); if (errors.length) throw new Error(errors[0]); const canvas = await page.$('canvas'); if (!canvas) throw new Error('No canvas element'); }); - await test('fire-truck: canvas has road (many distinct colors, not just sky+ground)', async (page, url) => { + await test('fire-truck: canvas shows city colors', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(2200); + await page.waitForTimeout(1800); const colorCount = await page.evaluate(() => { const cv = document.querySelector('canvas'); if (!cv) return 0; @@ -95,42 +95,60 @@ async function main() { if (gl) { const pixels = new Uint8Array(cv.width * cv.height * 4); gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); + for (let i = 0; i < pixels.length; i += 48) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); } else { - const ctx = cv.getContext('2d'); + const ctx = cv.getContext('2d'); const data = ctx.getImageData(0, 0, cv.width, cv.height).data; - for (let i = 0; i < data.length; i += 16) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); + for (let i = 0; i < data.length; i += 48) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); } return seen.size; }); - // sky + ground + road grey tones + kerb stripes + lane dash = many more than 3 - if (colorCount < 8) throw new Error(`Only ${colorCount} colors found — road may not be rendering`); + if (colorCount < 6) throw new Error(`Only ${colorCount} colors found — city may not be rendering`); }); - await test('fire-truck: initial state is "driving"', async (page, url) => { + await test('fire-truck: truck starts moving automatically', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(2200); - const state = await page.evaluate(() => window.__FT_SCENE__ && window.__FT_SCENE__.state); - if (state !== 'driving') throw new Error(`Expected state "driving", got "${state}"`); + await page.waitForTimeout(1200); + const start = await page.evaluate(() => ({ x: window.__FT_SCENE__.truckX, y: window.__FT_SCENE__.truckY })); + await page.waitForTimeout(3000); + const end = await page.evaluate(() => ({ x: window.__FT_SCENE__.truckX, y: window.__FT_SCENE__.truckY })); + const moved = Math.hypot(end.x - start.x, end.y - start.y); + if (moved < 20) throw new Error(`Truck moved only ${moved.toFixed(1)} pixels`); }); - await test('fire-truck: lights button toggles .active class', async (page, url) => { + await test('fire-truck: prompt appears before an intersection', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1200); - await page.locator('#btn-lights').click({ force: true }); - const on = await page.$eval('#btn-lights', el => el.classList.contains('active')); - if (!on) throw new Error('Button did not get .active after first click'); - await page.locator('#btn-lights').click({ force: true }); - const off = await page.$eval('#btn-lights', el => el.classList.contains('active')); - if (off) throw new Error('Button should not have .active after second click'); + await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 12000 }); + const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); + if (!['left', 'right', 'straight'].includes(promptDir)) throw new Error(`Unexpected prompt ${promptDir}`); }); - await test('fire-truck: lightsOn scene state tracks button', async (page, url) => { + await test('fire-truck: wrong arrow stops the truck', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1200); - await page.locator('#btn-lights').click({ force: true }); - const on = await page.evaluate(() => window.__FT_SCENE__ && window.__FT_SCENE__.lightsOn); - if (!on) throw new Error('lightsOn should be true after click'); + await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 12000 }); + const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); + const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft'; + await page.keyboard.press(wrongKey); + await page.waitForTimeout(250); + const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, speed: window.__FT_SCENE__.speed, targetSpeed: window.__FT_SCENE__.targetSpeed, failVisible: window.__FT_SCENE__.failVisible })); + if (state.state !== 'stopped') throw new Error(`Expected stopped, got ${state.state}`); + if (!state.failVisible) throw new Error('Expected failVisible to be true'); + if (state.targetSpeed !== 0) throw new Error(`Expected targetSpeed 0, got ${state.targetSpeed}`); + }); + + await test('fire-truck: correct arrow after failure resumes movement', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 12000 }); + const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); + const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft'; + const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp'; + await page.keyboard.press(wrongKey); + await page.waitForTimeout(250); + await page.keyboard.press(correctKey); + await page.waitForTimeout(500); + const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, speed: window.__FT_SCENE__.speed })); + if (state.state === 'stopped') throw new Error('Truck did not resume'); + if (state.speed <= 0) throw new Error('Speed did not recover'); }); // ── Summary ─────────────────────────────────────────────────────────────── diff --git a/tests/unit/projection.test.js b/tests/unit/projection.test.js @@ -1,69 +1,28 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { project } = require('../../games/fire-truck/lib.js'); +const { buildRoute, buildCity, summarizeKinds, pickPromptMove } = require('../../games/fire-truck/lib.js'); -const W = 800; -const H = 600; -const camZ = 0; -const camY = 1500; // world units -const camDepth = 0.84; -const horizonY = H * 0.45; // 270px +const fixedRng = { pick: arr => arr[0], next: () => 0.2 }; -describe('project()', () => { - it('returns visible=false when point is behind camera (dz ≤ 0)', () => { - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ - 1, 0); - assert.equal(r.visible, false); - assert.equal(r.scale, 0); +describe('buildCity()', () => { + it('creates road cells from a generated route', () => { + const route = buildRoute(12, fixedRng); + const city = buildCity(route, fixedRng); + assert(city.cells.length > route.length); }); - it('returns visible=false when point is exactly at camera (dz = 0)', () => { - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ, 0); - assert.equal(r.visible, false); - }); - - it('ground-level point near camera projects below horizon', () => { - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 2000, 0); - assert.equal(r.visible, true); - assert(r.y > horizonY, `y (${r.y.toFixed(1)}) should be below horizon (${horizonY})`); - assert(r.y < H, `y (${r.y.toFixed(1)}) should be on-screen (H=${H})`); - }); - - it('ground-level point far away projects just below horizon (not at horizon)', () => { - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 80000, 0); - assert.equal(r.visible, true); - assert(r.y > horizonY, `y (${r.y.toFixed(2)}) should be below horizon`); - assert(r.y < horizonY + 15, `y (${r.y.toFixed(2)}) should be very close to horizon`); - }); - - it('center-X world point projects to W/2 screenX', () => { - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 0); - assert.equal(r.x, W / 2); - }); - - it('±worldX projects symmetrically around W/2', () => { - const L = project(W, H, camZ, camY, camDepth, horizonY, -1000, 0, camZ + 5000, 0); - const R = project(W, H, camZ, camY, camDepth, horizonY, 1000, 0, camZ + 5000, 0); - const dL = W / 2 - L.x; - const dR = R.x - W / 2; - assert(Math.abs(dL - dR) < 0.001, `L offset (${dL.toFixed(3)}) ≠ R offset (${dR.toFixed(3)})`); - }); - - it('turnShift offsets screenX linearly', () => { - const r0 = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 0); - const r1 = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 5000, 50); - assert(Math.abs(r1.x - r0.x - 50) < 0.001, `x shift should be 50, got ${(r1.x - r0.x).toFixed(3)}`); - }); - - it('closer point has larger scale than farther point', () => { - const near = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 2000, 0); - const far = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + 10000, 0); - assert(near.scale > far.scale, `near scale (${near.scale}) should exceed far scale (${far.scale})`); + it('contains both t intersections and four-ways in a sample city', () => { + const route = buildRoute(20, fixedRng); + const city = buildCity(route, fixedRng); + const kinds = summarizeKinds(city.cells); + assert((kinds.t || 0) > 0, 'expected at least one t intersection'); + assert((kinds.four || 0) > 0, 'expected at least one four-way'); }); +}); - it('scale = CAM_DEPTH / dz', () => { - const dz = 3000; - const expected = camDepth / dz; - const r = project(W, H, camZ, camY, camDepth, horizonY, 0, 0, camZ + dz, 0); - assert(Math.abs(r.scale - expected) < 1e-9, `scale ${r.scale} ≠ expected ${expected}`); +describe('pickPromptMove()', () => { + it('always returns a legal move', () => { + assert.equal(pickPromptMove(['left', 'right'], fixedRng), 'left'); + assert.equal(pickPromptMove(['straight'], fixedRng), 'straight'); }); }); diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js @@ -1,54 +1,63 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { buildRoute } = require('../../games/fire-truck/lib.js'); +const { + turnHeading, + getRelativeMove, + classifyIntersection, + getLegalMoves, + buildRoute, +} = require('../../games/fire-truck/lib.js'); -const VALID_DIRS = new Set(['left', 'right', 'straight']); -const fixedRng = { pick: arr => arr[0] }; // always picks first element +const fixedRng = { pick: arr => arr[0], next: () => 0.25 }; -describe('buildRoute()', () => { - it('returns the requested number of intersections', () => { - assert.equal(buildRoute(5, 4000, fixedRng).length, 5); - assert.equal(buildRoute(1, 4000, fixedRng).length, 1); - assert.equal(buildRoute(0, 4000, fixedRng).length, 0); +describe('turnHeading()', () => { + it('handles left, right, and straight', () => { + assert.equal(turnHeading('north', 'left'), 'west'); + assert.equal(turnHeading('north', 'right'), 'east'); + assert.equal(turnHeading('east', 'straight'), 'east'); + assert.equal(turnHeading('south', 'left'), 'east'); }); +}); - it('Z values are strictly increasing', () => { - const r = buildRoute(6, 4000, fixedRng); - for (let i = 1; i < r.length; i++) { - assert(r[i].z > r[i - 1].z, - `r[${i}].z (${r[i].z}) should be > r[${i-1}].z (${r[i-1].z})`); - } +describe('getRelativeMove()', () => { + it('maps heading changes to child-friendly move labels', () => { + assert.equal(getRelativeMove('north', 'west'), 'left'); + assert.equal(getRelativeMove('north', 'east'), 'right'); + assert.equal(getRelativeMove('east', 'east'), 'straight'); }); +}); - it('Z values equal (index + 1) * gap', () => { - const gap = 5000; - const r = buildRoute(4, gap, fixedRng); - r.forEach((seg, i) => { - assert.equal(seg.z, (i + 1) * gap); - }); +describe('classifyIntersection()', () => { + it('classifies 4-way and t intersections', () => { + assert.equal(classifyIntersection({ N: true, E: true, S: true, W: true }).kind, 'four'); + assert.equal(classifyIntersection({ N: true, E: true, S: true, W: false }).kind, 't'); + assert.equal(classifyIntersection({ N: true, E: false, S: true, W: false }).kind, 'straight'); + assert.equal(classifyIntersection({ N: true, E: true, S: false, W: false }).kind, 'corner'); }); +}); - it('all dir values are valid', () => { - // run with real random to get variety - const r = buildRoute(20, 1000); - r.forEach((seg, i) => { - assert(VALID_DIRS.has(seg.dir), - `r[${i}].dir "${seg.dir}" is not a valid direction`); - }); - }); +describe('getLegalMoves()', () => { + it('returns relative options from approach heading', () => { + const fourWay = { exits: { N: true, E: true, S: true, W: true } }; + assert.deepEqual(getLegalMoves(fourWay, 'north').sort(), ['left', 'right', 'straight']); - it('deterministic rng produces repeatable routes', () => { - const a = buildRoute(5, 4000, fixedRng); - const b = buildRoute(5, 4000, fixedRng); - assert.deepEqual(a, b); + const t = { exits: { N: false, E: true, S: true, W: true } }; + assert.deepEqual(getLegalMoves(t, 'north').sort(), ['left', 'right']); }); +}); - it('each entry has z, dir, and kind properties', () => { - const r = buildRoute(3, 4000, fixedRng); - r.forEach((seg, i) => { - const keys = Object.keys(seg).sort(); - assert.deepEqual(keys, ['dir', 'kind', 'z'], `r[${i}] keys: ${keys}`); - assert.ok(['four', 't'].includes(seg.kind), `r[${i}] kind invalid: ${seg.kind}`); +describe('buildRoute()', () => { + it('returns connected route entries with valid moves', () => { + const route = buildRoute(6, fixedRng); + assert.equal(route.length, 6); + route.forEach((step, index) => { + assert.ok(['left', 'right', 'straight'].includes(step.move)); + assert.equal(getRelativeMove(step.headingIn, step.headingOut), step.move); + if (index > 0) { + const prev = route[index - 1]; + const gap = Math.abs(step.x - prev.exitX) + Math.abs(step.y - prev.exitY); + assert.equal(gap, 1); + } }); }); });