feat(lowball-blitz): scaffold 3D endless runner with envelope-throwing mechanics

Implement Lowball Blitz -- a Three.js endless runner where an AI robot agent
sprints through a suburban neighborhood throwing lowball offer envelopes at houses.
Hit houses spawn panicking homeowners who drop collectible panic points. Dodge
enemy real estate agents carrying FOR SALE signs.

Core systems:
- Auto-run forward with left/right lane shifting (WASD/arrows + mobile touch)
- Envelope throwing (space/tap) with cooldown and Punch animation
- Procedural street generation (road, sidewalks, grass, lane markings, houses)
- House entities with colored boxes + triangular roofs, shake/flash on hit
- Homeowner NPCs with arm-waving panic animation, drop panic points
- Agent enemies walking toward player with FOR SALE signs
- Combo system (consecutive hits build multiplier up to 10x, 3s timeout)
- 3-life system with invincibility frames and flashing effect
- Speed increases over time (8 to 25 units/sec)
- Full render_game_to_text() for AI agent state reading
- Spectacle event hooks for future visual polish
- Mobile touch zones (left half dodge, right half throw)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-02-26 15:21:59 -05:00
parent 60f804770e
commit 4b2ee58ef0
28 changed files with 3456 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
output/
+95
View File
@@ -0,0 +1,95 @@
# Lowball Blitz -- Design Brief
## Concept
You play as an AI robot agent sprinting through a suburban neighborhood, hurling lowball offer envelopes at houses. Each hit house spawns a panicking homeowner who drops "panic points" you collect. Dodge angry real estate agents trying to tackle you. The more offers you send, the more chaos spreads! Endless runner style -- the neighborhood keeps generating ahead.
## Core Mechanics
### Auto-Run
The player automatically runs forward (negative Z direction) at a speed that increases over time. No forward/backward control -- the player can only shift left and right across the street lanes using WASD/Arrow keys or mobile touch.
### Throw Envelopes
Press SPACE (or tap right side of screen on mobile) to throw a lowball offer envelope forward. Envelopes are flat white rectangles that fly forward and spin. They have a short cooldown between throws. When an envelope hits a house, the house flashes yellow and shakes.
### Collect Panic Points
When a house is hit, a panicking homeowner (simple humanoid) pops out and runs in a random direction with flailing arms. The homeowner drops green floating "panic points" along their path. Run through these collectibles to earn score.
### Dodge Agents
Enemy real estate agents spawn ahead on the street, walking toward the player. They wear dark suits and carry red "FOR SALE" signs. Colliding with an agent costs one life. Dodge left or right to avoid them.
## Win/Lose Conditions
- **Score**: Collect panic points (+1 each, multiplied by combo). Hitting houses also scores points.
- **Combo System**: Consecutive house hits without missing build a multiplier (up to 10x). Combo resets after 3 seconds without a hit or when colliding with an agent.
- **Lives**: Start with 3. Lose 1 per agent collision. Brief invincibility after each hit (flashing effect).
- **Game Over**: 0 lives remaining. Shows final score, best score, houses hit, and best combo.
- **Endless**: No win condition -- play for the highest score. Speed increases over time.
## Entity Descriptions
### Player (RobotExpressive)
- Animated GLB robot character from Three.js examples
- Auto-runs forward, shifts left/right across street lanes
- Plays "Running" animation normally, "Punch" animation when throwing
- Flashes during invincibility frames after being hit
### Envelopes
- Small flat white rectangles (0.3 x 0.02 x 0.2)
- Launched from player chest height, fly forward with spin
- Destroyed on house contact or after max distance (40 units)
### Houses
- Colorful boxes with triangular pyramid roofs (BufferGeometry)
- 8 body colors (pastel blue, yellow, pink, green, white, beige, peach, steel blue)
- 5 roof colors (browns, gray, olive, maroon)
- Doors and windows on the street-facing side
- Flash yellow and shake when hit, then dim to gray ("sold")
- Spawn on both sides of the street with random gaps
### Homeowners
- Simple humanoid: box body, sphere head, two box arms
- Random bright colors (tomato, royal blue, lime green, hot pink, orange)
- Pop out of hit houses, run erratically with arm-waving animation
- Drop panic points every 0.4 seconds for 3 seconds before despawning
### Panic Points
- Small green spheres with yellow ring detail
- Float at 0.8 units height with bobbing sine wave animation
- Spin continuously
- Collected by proximity (1.2 unit radius)
- Flash and expand on collection, auto-despawn after 8 seconds
### Real Estate Agents
- Dark navy box body, peach sphere head
- Carry a red "FOR SALE" sign rectangle on a gray post
- Walk toward player (positive Z) with bobbing walk animation
- Spawn periodically ahead of player, increasing frequency with speed
- Collision radius of 0.7 units
### Street
- Gray asphalt road (10 units wide) with white dashed center line
- Concrete sidewalks on both sides
- Green grass strips beyond sidewalks
- Procedurally generated ahead and cleaned up behind player
- Houses set back 7 units from center on each side
## Visual Identity
- Bright suburban neighborhood aesthetic
- Sky blue background/fog
- Colorful houses contrast against gray street
- Green collectibles stand out against the environment
- Dark-suited agents are clearly antagonistic
- White envelopes are instantly recognizable as mail
## Technical Notes
- Three.js WebGLRenderer with shadow mapping
- All constants in Constants.js, all events in EventBus.js
- GameState singleton for score, lives, combo, speed
- Procedural street generation with cleanup for endless running
- Camera follows player as a fixed chase cam (behind and above)
- Mobile support via touch zones (left half = dodge, right half = throw)
- No title screen -- boots directly into gameplay
- No in-game score HUD -- Play.fun widget handles score display
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Lowball Blitz</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
canvas { display: block; }
/* Score HUD omitted -- Play.fun widget displays score in a deadzone at the top */
/* Lives display (top-right, below Play.fun safe zone) */
#lives-display {
position: fixed;
top: max(80px, calc(8vh + 10px)); /* Below Play.fun widget bar */
right: 16px;
font-size: clamp(20px, 4vmin, 32px);
color: #ff4444;
z-index: 15;
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
pointer-events: none;
}
/* Combo display */
#combo-display {
position: fixed;
top: max(80px, calc(8vh + 10px));
left: 16px;
font-size: clamp(16px, 3vmin, 24px);
color: #ffcc00;
z-index: 15;
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
#combo-display.visible { opacity: 1; }
.overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.75);
z-index: 20;
color: #fff;
padding: 20px;
padding-top: max(20px, 8vh); /* Safe zone: Play.fun widget bar is ~75px at top */
}
.overlay.hidden { display: none; }
.overlay h1 {
font-size: clamp(28px, 7vmin, 56px);
margin-bottom: clamp(10px, 2vmin, 24px);
text-align: center;
}
.overlay p {
font-size: clamp(14px, 2.5vmin, 22px);
margin-bottom: 8px;
color: #aaa;
text-align: center;
}
.overlay .score-display {
font-size: clamp(18px, 4vmin, 32px);
margin: 8px 0;
}
.overlay .best-display {
font-size: clamp(14px, 2.5vmin, 22px);
color: #aaa;
}
.overlay .stat-display {
font-size: clamp(14px, 2.5vmin, 20px);
color: #ccc;
margin: 4px 0;
}
.overlay button {
margin-top: clamp(16px, 3vmin, 32px);
padding: clamp(10px, 2vmin, 16px) clamp(28px, 6vmin, 48px);
font-size: clamp(16px, 2.5vmin, 22px);
font-family: inherit;
font-weight: bold;
color: #fff;
background: #6c63ff;
border: none;
border-radius: 8px;
cursor: pointer;
min-height: 44px;
min-width: 120px;
transition: background 0.2s, transform 0.1s;
}
.overlay button:hover { background: #857dff; transform: scale(1.05); }
.overlay button:active { background: #5a52d5; transform: scale(0.95); }
/* Mobile throw button */
#throw-btn {
position: fixed;
bottom: max(20px, 3vh);
right: max(20px, 3vw);
width: clamp(60px, 12vmin, 80px);
height: clamp(60px, 12vmin, 80px);
border-radius: 50%;
background: rgba(255, 100, 50, 0.6);
border: 3px solid rgba(255, 150, 100, 0.7);
color: #fff;
font-size: clamp(20px, 4vmin, 28px);
font-weight: bold;
display: none;
justify-content: center;
align-items: center;
z-index: 15;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#throw-btn:active { background: rgba(255, 100, 50, 0.9); }
/* Virtual joystick (mobile only, created by InputSystem) */
#joystick-zone {
position: fixed;
bottom: max(20px, 3vh);
left: max(20px, 3vw);
width: clamp(100px, 20vmin, 140px);
height: clamp(100px, 20vmin, 140px);
z-index: 15;
display: none;
touch-action: none;
}
#joystick-base {
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.25);
position: relative;
}
#joystick-thumb {
width: 40%;
height: 40%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
/* Mobile hint text */
#mobile-hints {
position: fixed;
bottom: max(90px, calc(3vh + 70px));
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.4);
font-size: 12px;
z-index: 14;
text-align: center;
pointer-events: none;
display: none;
}
</style>
</head>
<body>
<!-- Lives HUD -->
<div id="lives-display"></div>
<!-- Combo HUD -->
<div id="combo-display"></div>
<div id="gameover-overlay" class="overlay hidden">
<h1>GAME OVER</h1>
<div class="score-display" id="final-score">Score: 0</div>
<div class="best-display" id="best-score">Best: 0</div>
<div class="stat-display" id="houses-hit">Houses Hit: 0</div>
<div class="stat-display" id="best-combo">Best Combo: 0x</div>
<button id="restart-btn">RESTART</button>
</div>
<div id="joystick-zone">
<div id="joystick-base">
<div id="joystick-thumb"></div>
</div>
</div>
<!-- Mobile throw button -->
<div id="throw-btn">THROW</div>
<!-- Mobile hints -->
<div id="mobile-hints">Tap left/right to dodge | Tap right side to throw</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "lowball-blitz",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "npx playwright test",
"test:headed": "npx playwright test --headed",
"test:ui": "npx playwright test --ui",
"test:update-snapshots": "npx playwright test --update-snapshots",
"verify": "node scripts/verify-runtime.mjs",
"validate": "node scripts/validate-architecture.mjs",
"iterate": "node scripts/iterate-client.js --url http://localhost:3000"
},
"dependencies": {
"three": "^0.183.0",
"@strudel/web": "^1.3.0"
},
"devDependencies": {
"vite": "^7.3.1",
"@playwright/test": "^1.58.0",
"@axe-core/playwright": "^4.11.0"
}
}
+50
View File
@@ -0,0 +1,50 @@
# Lowball Blitz -- Progress
## Original Prompt
You play as an AI robot agent sprinting through a suburban neighborhood, hurling lowball offer envelopes at houses. Each hit house spawns a panicking homeowner who drops "panic points" you collect. Dodge angry real estate agents trying to tackle you. The more offers you send, the more chaos spreads! Endless runner style - the neighborhood keeps generating ahead.
## Step 1: Scaffold (DONE)
### Completed
- [x] Constants.js -- All game config: STREET, ENVELOPE, HOUSE, HOMEOWNER, PANIC_POINT, AGENT, GAMEPLAY, COMBO
- [x] EventBus.js -- 20+ events: throw, hit, collect, collision, combo, spectacle hooks
- [x] GameState.js -- lives, combo, bestCombo, currentSpeed, housesHit, totalThrown, isMuted
- [x] Envelope entity -- flat white box, flies forward with spin, house collision detection
- [x] House entity -- colored box body + triangular roof, door/windows, shake/flash on hit
- [x] Homeowner entity -- humanoid (body + head + arms), panic run with arm-waving, drops panic points
- [x] PanicPoint entity -- green sphere with ring, bobbing/spinning, collect-on-proximity
- [x] Agent entity -- dark suit body, sphere head, red FOR SALE sign, walks toward player
- [x] StreetGenerator -- procedural road + sidewalks + grass + lane markings, house spawning, agent spawning, cleanup
- [x] Player.js -- auto-run forward, left/right lane shifting, envelope throwing (Punch anim), invincibility frames
- [x] Game.js -- full orchestrator: envelope-house collisions, agent-player collisions, panic collection, combo system, camera follow, homeowner spawning
- [x] InputSystem.js -- keyboard (WASD/arrows + space) + mobile touch zones (left half = dodge, right half = throw)
- [x] Menu.js -- game over overlay with score, best, houses hit, best combo
- [x] index.html -- lives HUD, combo HUD, mobile throw button, mobile hints
- [x] main.js -- render_game_to_text() with full state, combo HUD logic, mobile throw button
- [x] design-brief.md -- full concept, mechanics, entities, visual identity
- [x] example-actions.json -- test actions for auto-runner
### Architecture
- EventBus-only communication between modules
- GameState is single source of truth
- All magic numbers in Constants.js
- No title screen -- boots directly into gameplay
- Camera: fixed chase cam (behind and above player)
- Procedural endless street with cleanup
### Decisions
- RobotExpressive GLB as player character (faces +Z, facingOffset: 0)
- "Punch" animation clip used for throw action
- Houses are boxes with BufferGeometry triangular roofs
- Street is 10 units wide, houses 7 units from center on each side
- Speed increases 0.15 units/sec, caps at 25
- 3 lives, 1.5s invincibility after hit
- Combo timeout 3 seconds, multiplier cap 10x
### Known Issues / Loose Ends
- No audio yet (Step 5)
- No visual polish/particles yet (Step 3)
- No 3D asset replacements yet (Step 2)
- Houses are simple geometry -- could be improved with real 3D models
- Homeowners are simple shapes -- could be replaced with animated characters
- No "SOLD!" text popup on house hit yet (visual polish)
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:047f5e5fb3bb6d378bd1df16ca6137f2a596c99b3a1b5690b4020c05aaf6f319
size 463988
@@ -0,0 +1,65 @@
{
"_comment": "Example action payloads for iterate-client.js — auto-runner with left/right dodging and space to throw envelopes",
"steps": [
{
"_comment": "Throw envelope at houses",
"buttons": ["Space"],
"frames": 10
},
{
"_comment": "Dodge left",
"buttons": ["ArrowLeft"],
"frames": 15
},
{
"_comment": "Throw while moving left",
"buttons": ["Space"],
"frames": 5
},
{
"_comment": "Coast forward",
"buttons": [],
"frames": 20
},
{
"_comment": "Dodge right",
"buttons": ["ArrowRight"],
"frames": 15
},
{
"_comment": "Throw from right lane",
"buttons": ["Space"],
"frames": 5
},
{
"_comment": "Dodge left again",
"buttons": ["ArrowLeft"],
"frames": 10
},
{
"_comment": "Throw again",
"buttons": ["Space"],
"frames": 10
},
{
"_comment": "Coast",
"buttons": [],
"frames": 15
},
{
"_comment": "Dodge right",
"buttons": ["ArrowRight"],
"frames": 10
},
{
"_comment": "Throw",
"buttons": ["Space"],
"frames": 5
},
{
"_comment": "Watch the chaos unfold",
"buttons": [],
"frames": 80
}
]
}
@@ -0,0 +1,672 @@
#!/usr/bin/env node
// =============================================================================
// iterate-client.js — Tight implement→test loop for browser game development
//
// A standalone Playwright script that launches a browser, performs choreographed
// actions on a game, captures screenshots + text state, and tracks console errors.
// Designed for AI agents to verify changes after each small code edit.
//
// Usage:
// node scripts/iterate-client.js --url http://localhost:3000 \
// --actions-json '[{"buttons":["space"],"frames":4}]' \
// --iterations 3 --pause-ms 250
//
// Or with an actions file:
// node scripts/iterate-client.js --url http://localhost:3000 \
// --actions-file scripts/example-actions.json --iterations 5
//
// Or a simple click:
// node scripts/iterate-client.js --url http://localhost:3000 \
// --click 480,270 --iterations 2
//
// Multi-restart testing:
// node scripts/iterate-client.js --url http://localhost:3000 \
// --restart-cycles 3 --restart-key Space
//
// Outputs:
// <screenshot-dir>/shot-<i>.png — canvas screenshot per iteration
// <screenshot-dir>/state-<i>.json — render_game_to_text() output per iteration
// <screenshot-dir>/errors-<i>.json — console errors (breaks on first new error)
// =============================================================================
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from '@playwright/test';
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = {
url: null,
iterations: 3,
pauseMs: 250,
headless: true,
screenshotDir: 'output/iterate',
actionsFile: null,
actionsJson: null,
click: null,
clickSelector: null,
waitForGame: true, // Wait for window.__GAME__ to be ready
timeoutMs: 10000, // Max wait for game boot
restartCycles: 0, // Number of restart cycles (0 = disabled)
restartKey: 'Space', // Key to press to restart the game
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
if (arg === '--url' && next) { args.url = next; i++; }
else if (arg === '--iterations' && next) { args.iterations = parseInt(next, 10); i++; }
else if (arg === '--pause-ms' && next) { args.pauseMs = parseInt(next, 10); i++; }
else if (arg === '--headless' && next) { args.headless = next !== '0' && next !== 'false'; i++; }
else if (arg === '--screenshot-dir' && next) { args.screenshotDir = next; i++; }
else if (arg === '--actions-file' && next) { args.actionsFile = next; i++; }
else if (arg === '--actions-json' && next) { args.actionsJson = next; i++; }
else if (arg === '--click' && next) {
const parts = next.split(',').map(v => parseFloat(v.trim()));
if (parts.length === 2 && parts.every(v => Number.isFinite(v))) {
args.click = { x: parts[0], y: parts[1] };
}
i++;
}
else if (arg === '--click-selector' && next) { args.clickSelector = next; i++; }
else if (arg === '--no-wait') { args.waitForGame = false; }
else if (arg === '--timeout' && next) { args.timeoutMs = parseInt(next, 10); i++; }
else if (arg === '--restart-cycles' && next) { args.restartCycles = parseInt(next, 10); i++; }
else if (arg === '--restart-key' && next) { args.restartKey = next; i++; }
}
if (!args.url) {
console.error(`Usage: node iterate-client.js --url <game-url> [--actions-json <json> | --actions-file <path> | --click x,y]
Required:
--url <url> Game URL (e.g., http://localhost:3000)
Actions (at least one required):
--actions-json <json> Inline JSON array of action steps
--actions-file <path> Path to JSON file with action steps
--click <x,y> Single click at canvas-relative coordinates
Options:
--iterations <n> Number of action→capture cycles (default: 3)
--pause-ms <ms> Pause between iterations (default: 250)
--headless <bool> Run headless (default: true, use 'false' for debugging)
--screenshot-dir <dir> Output directory (default: output/iterate)
--click-selector <sel> CSS selector to click before starting actions
--no-wait Don't wait for window.__GAME__ to be ready
--timeout <ms> Max wait for game boot (default: 10000)
Restart testing:
--restart-cycles <n> Number of restart cycles to test (default: 0 = disabled)
--restart-key <key> Key to press to restart (default: Space)`);
process.exit(1);
}
return args;
}
// ---------------------------------------------------------------------------
// Key mapping
// ---------------------------------------------------------------------------
const KEY_MAP = {
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
enter: 'Enter',
space: 'Space',
escape: 'Escape',
tab: 'Tab',
w: 'KeyW',
a: 'KeyA',
s: 'KeyS',
d: 'KeyD',
f: 'KeyF',
m: 'KeyM',
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function ensureDir(p) {
fs.mkdirSync(p, { recursive: true });
}
// ---------------------------------------------------------------------------
// Virtual time shim — injected before page load via addInitScript.
// Overrides RAF/setTimeout/setInterval to provide advanceTime() even for
// games that don't expose it natively. If the game already has advanceTime,
// this shim's version takes precedence (injected earlier).
// ---------------------------------------------------------------------------
function makeVirtualTimeShim() {
return `(() => {
const origRAF = window.requestAnimationFrame.bind(window);
const origSetTimeout = window.setTimeout.bind(window);
const origSetInterval = window.setInterval.bind(window);
const pending = new Set();
window.__vt_pending = pending;
window.setTimeout = (fn, t, ...rest) => {
const task = {};
pending.add(task);
return origSetTimeout(() => { pending.delete(task); fn(...rest); }, t);
};
window.setInterval = (fn, t, ...rest) => {
const task = {};
pending.add(task);
return origSetInterval(() => { fn(...rest); }, t);
};
window.requestAnimationFrame = (fn) => {
const task = {};
pending.add(task);
return origRAF((ts) => { pending.delete(task); fn(ts); });
};
// Wait for real time to elapse (game loop runs normally via patched RAF)
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step(now) {
if (now - start >= ms) return resolve();
origRAF(step);
}
origRAF(step);
});
};
window.__drainVirtualTimePending = () => pending.size;
})();`;
}
// ---------------------------------------------------------------------------
// Canvas detection and screenshot capture
// ---------------------------------------------------------------------------
async function getCanvasHandle(page) {
const handle = await page.evaluateHandle(() => {
let best = null;
let bestArea = 0;
for (const canvas of document.querySelectorAll('canvas')) {
const area = (canvas.width || canvas.clientWidth || 0) * (canvas.height || canvas.clientHeight || 0);
if (area > bestArea) {
bestArea = area;
best = canvas;
}
}
return best;
});
return handle.asElement();
}
async function captureCanvasPngBase64(canvas) {
return canvas.evaluate(c => {
if (!c || typeof c.toDataURL !== 'function') return '';
try {
const data = c.toDataURL('image/png');
const idx = data.indexOf(',');
return idx === -1 ? '' : data.slice(idx + 1);
} catch {
return ''; // Security: tainted canvas
}
});
}
async function isCanvasTransparent(canvas) {
if (!canvas) return true;
return canvas.evaluate(c => {
try {
const w = c.width || c.clientWidth || 0;
const h = c.height || c.clientHeight || 0;
if (!w || !h) return true;
const size = Math.max(1, Math.min(16, w, h));
const probe = document.createElement('canvas');
probe.width = size;
probe.height = size;
const ctx = probe.getContext('2d');
if (!ctx) return true;
ctx.drawImage(c, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) return false;
}
return true;
} catch {
return false; // Can't probe → assume not transparent
}
});
}
async function captureScreenshot(page, canvas, outPath) {
let buffer = null;
// Strategy 1: canvas.toDataURL (highest fidelity for WebGL)
if (canvas) {
const base64 = await captureCanvasPngBase64(canvas);
if (base64) {
buffer = Buffer.from(base64, 'base64');
const transparent = await isCanvasTransparent(canvas);
if (transparent) buffer = null; // Probably failed, try next strategy
}
}
// Strategy 2: Playwright element screenshot
if (!buffer && canvas) {
try {
buffer = await canvas.screenshot({ type: 'png' });
} catch {
buffer = null;
}
}
// Strategy 3: Page screenshot with canvas clip
if (!buffer) {
const bbox = canvas ? await canvas.boundingBox() : null;
if (bbox) {
buffer = await page.screenshot({ type: 'png', omitBackground: false, clip: bbox });
} else {
buffer = await page.screenshot({ type: 'png', omitBackground: false });
}
}
fs.writeFileSync(outPath, buffer);
}
// ---------------------------------------------------------------------------
// Console error tracking (deduplicated)
// ---------------------------------------------------------------------------
class ConsoleErrorTracker {
constructor() {
this._seen = new Set();
this._errors = [];
}
ingest(err) {
const key = JSON.stringify(err);
if (this._seen.has(key)) return;
this._seen.add(key);
this._errors.push(err);
}
drain() {
const out = [...this._errors];
this._errors = [];
return out;
}
get count() {
return this._errors.length;
}
}
// ---------------------------------------------------------------------------
// Action choreography
// ---------------------------------------------------------------------------
async function runActions(page, canvas, steps) {
for (const step of steps) {
const buttons = new Set(step.buttons || []);
// Press down all buttons
for (const button of buttons) {
if (button === 'left_mouse_button' || button === 'right_mouse_button') {
const bbox = canvas ? await canvas.boundingBox() : null;
if (!bbox) continue;
const x = typeof step.mouse_x === 'number' ? step.mouse_x : bbox.width / 2;
const y = typeof step.mouse_y === 'number' ? step.mouse_y : bbox.height / 2;
await page.mouse.move(bbox.x + x, bbox.y + y);
await page.mouse.down({ button: button === 'left_mouse_button' ? 'left' : 'right' });
} else {
const key = KEY_MAP[button] || button;
await page.keyboard.down(key);
}
}
// Advance frame-by-frame (each evaluate round-trip lets Playwright process
// events, so input state is properly registered per-frame)
const frames = step.frames || 1;
for (let f = 0; f < frames; f++) {
await page.evaluate(async () => {
if (typeof window.advanceTime === 'function') {
await window.advanceTime(1000 / 60);
}
});
}
// Release all buttons
for (const button of buttons) {
if (button === 'left_mouse_button' || button === 'right_mouse_button') {
await page.mouse.up({ button: button === 'left_mouse_button' ? 'left' : 'right' });
} else {
const key = KEY_MAP[button] || button;
await page.keyboard.up(key);
}
}
// Optional wait between steps
if (step.wait_ms) {
await sleep(step.wait_ms);
}
}
}
// ---------------------------------------------------------------------------
// Parse action steps from CLI args
// ---------------------------------------------------------------------------
function loadActions(args) {
if (args.actionsFile) {
const raw = fs.readFileSync(args.actionsFile, 'utf-8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : parsed.steps || [];
}
if (args.actionsJson) {
const parsed = JSON.parse(args.actionsJson);
return Array.isArray(parsed) ? parsed : parsed.steps || [];
}
if (args.click) {
return [{
buttons: ['left_mouse_button'],
frames: 2,
mouse_x: args.click.x,
mouse_y: args.click.y,
}];
}
// Default: single space press (works for most menu→game transitions)
return [{ buttons: ['space'], frames: 4 }];
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const args = parseArgs(process.argv);
const steps = loadActions(args);
ensureDir(args.screenshotDir);
console.log(`iterate-client: ${args.url}`);
console.log(` actions: ${steps.length} steps, ${args.iterations} iterations`);
if (args.restartCycles > 0) {
console.log(` restart: ${args.restartCycles} cycles, key: ${args.restartKey}`);
}
console.log(` output: ${args.screenshotDir}/`);
const browser = await chromium.launch({
headless: args.headless,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
const errors = new ConsoleErrorTracker();
// Track console errors
page.on('console', msg => {
if (msg.type() !== 'error') return;
errors.ingest({ type: 'console.error', text: msg.text() });
});
page.on('pageerror', err => {
errors.ingest({ type: 'pageerror', text: String(err) });
});
// Inject virtual time shim before page load — provides advanceTime() even
// for games that don't expose it, and tracks pending async operations
await page.addInitScript({ content: makeVirtualTimeShim() });
// Navigate
await page.goto(args.url, { waitUntil: 'domcontentloaded' });
// Wait for game to be ready (our templates expose window.__GAME__)
if (args.waitForGame) {
try {
await page.waitForFunction(() => {
// Phaser: __GAME__.isBooted && __GAME__.canvas
const g = window.__GAME__;
if (g && g.isBooted && g.canvas) return true;
// Three.js: __GAME__.renderer
if (g && g.renderer) return true;
return false;
}, { timeout: args.timeoutMs });
} catch {
console.warn(' warn: timed out waiting for game boot, proceeding anyway');
}
}
// Let initial render settle
await page.waitForTimeout(500);
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
let canvas = await getCanvasHandle(page);
// Optional pre-action click (e.g., click a start button)
if (args.clickSelector) {
try {
await page.click(args.clickSelector, { timeout: 5000 });
await page.waitForTimeout(250);
} catch (err) {
console.warn(` warn: failed to click selector "${args.clickSelector}":`, err.message);
}
}
// Check for boot errors before starting iterations
const bootErrors = errors.drain();
if (bootErrors.length) {
const errPath = path.join(args.screenshotDir, 'errors-boot.json');
fs.writeFileSync(errPath, JSON.stringify(bootErrors, null, 2));
console.error(` BOOT ERRORS (${bootErrors.length}): see ${errPath}`);
}
// --- Iteration loop ---
let hadErrors = false;
for (let i = 0; i < args.iterations; i++) {
if (!canvas) canvas = await getCanvasHandle(page);
// Run choreographed actions
await runActions(page, canvas, steps);
await sleep(args.pauseMs);
// Capture screenshot
const shotPath = path.join(args.screenshotDir, `shot-${i}.png`);
await captureScreenshot(page, canvas, shotPath);
// Capture text state
const textState = await page.evaluate(() => {
if (typeof window.render_game_to_text === 'function') {
return window.render_game_to_text();
}
// Fallback: read __GAME_STATE__ directly
if (window.__GAME_STATE__) {
return JSON.stringify(window.__GAME_STATE__);
}
return null;
});
if (textState) {
fs.writeFileSync(path.join(args.screenshotDir, `state-${i}.json`), textState);
}
const tag = textState ? ` state=${textState.slice(0, 80)}` : '';
console.log(` [${i}] screenshot: ${shotPath}${tag}`);
// Check for new errors
const freshErrors = errors.drain();
if (freshErrors.length) {
const errPath = path.join(args.screenshotDir, `errors-${i}.json`);
fs.writeFileSync(errPath, JSON.stringify(freshErrors, null, 2));
console.error(` [${i}] ERRORS (${freshErrors.length}): see ${errPath}`);
hadErrors = true;
break; // Stop on first new error — fix before continuing
}
}
// --- Restart cycle testing ---
if (!hadErrors && args.restartCycles > 0) {
console.log(`\n--- Restart cycle testing (${args.restartCycles} cycles, key: ${args.restartKey}) ---`);
const restartIssues = [];
for (let cycle = 0; cycle < args.restartCycles; cycle++) {
console.log(`\n [cycle ${cycle + 1}/${args.restartCycles}]`);
// Step 1: Run actions to play the game
if (!canvas) canvas = await getCanvasHandle(page);
await runActions(page, canvas, steps);
await sleep(args.pauseMs);
// Step 2: Wait for game_over state (poll render_game_to_text every 500ms, timeout 15s)
let gameOverReached = false;
const pollStart = Date.now();
const pollTimeout = 15000;
while (Date.now() - pollStart < pollTimeout) {
const stateStr = await page.evaluate(() => {
if (typeof window.render_game_to_text === 'function') {
return window.render_game_to_text();
}
if (window.__GAME_STATE__) {
return JSON.stringify(window.__GAME_STATE__);
}
return null;
});
if (stateStr) {
try {
const state = JSON.parse(stateStr);
if (state.mode === 'game_over' || state.gameOver === true) {
gameOverReached = true;
console.log(` game_over reached (${Date.now() - pollStart}ms)`);
break;
}
} catch { /* ignore parse errors */ }
}
// Advance time and wait before next poll
await page.evaluate(async () => {
if (typeof window.advanceTime === 'function') {
await window.advanceTime(500);
}
});
await sleep(500);
}
if (!gameOverReached) {
console.warn(' warn: game_over not reached within 15s, pressing restart anyway');
}
// Capture pre-restart screenshot
const preRestartPath = path.join(args.screenshotDir, `restart-${cycle}-pre.png`);
await captureScreenshot(page, canvas, preRestartPath);
// Step 3: Press the restart key
const restartKeyMapped = KEY_MAP[args.restartKey.toLowerCase()] || args.restartKey;
await page.keyboard.press(restartKeyMapped);
console.log(` pressed ${args.restartKey}`);
// Small delay for game to process restart
await sleep(500);
// Also advance a few frames for the game loop to process
await page.evaluate(async () => {
if (typeof window.advanceTime === 'function') {
await window.advanceTime(500);
}
});
await sleep(250);
// Step 4: Verify state after restart
const postStateStr = await page.evaluate(() => {
if (typeof window.render_game_to_text === 'function') {
return window.render_game_to_text();
}
if (window.__GAME_STATE__) {
return JSON.stringify(window.__GAME_STATE__);
}
return null;
});
// Capture post-restart screenshot
const postRestartPath = path.join(args.screenshotDir, `restart-${cycle}-post.png`);
await captureScreenshot(page, canvas, postRestartPath);
if (postStateStr) {
try {
const postState = JSON.parse(postStateStr);
const issues = [];
// Check score reset to 0
if (typeof postState.score === 'number' && postState.score !== 0) {
issues.push(`score not reset: expected 0, got ${postState.score}`);
}
// Check mode is not game_over (should be playing, menu, or similar)
if (postState.mode === 'game_over') {
issues.push(`mode still game_over after restart`);
}
if (issues.length > 0) {
console.error(` ISSUES: ${issues.join('; ')}`);
restartIssues.push({
cycle: cycle + 1,
issues,
preRestartScreenshot: preRestartPath,
postRestartScreenshot: postRestartPath,
postState: postState,
});
} else {
const mode = postState.mode || 'unknown';
const score = postState.score ?? 'n/a';
console.log(` OK — mode: ${mode}, score: ${score}`);
}
} catch (err) {
console.warn(` warn: could not parse post-restart state: ${err.message}`);
}
} else {
console.warn(' warn: render_game_to_text() returned null after restart');
}
// Check for console errors during this cycle
const cycleErrors = errors.drain();
if (cycleErrors.length) {
const errPath = path.join(args.screenshotDir, `errors-restart-${cycle}.json`);
fs.writeFileSync(errPath, JSON.stringify(cycleErrors, null, 2));
console.error(` ERRORS (${cycleErrors.length}): see ${errPath}`);
hadErrors = true;
break;
}
}
// Write issues file if there were any
if (restartIssues.length > 0) {
const issuesPath = path.join(args.screenshotDir, `restart-${args.restartCycles}-issues.json`);
fs.writeFileSync(issuesPath, JSON.stringify(restartIssues, null, 2));
console.error(`\n Restart issues written to ${issuesPath}`);
} else if (!hadErrors) {
console.log(`\n All ${args.restartCycles} restart cycles passed`);
}
}
await browser.close();
if (hadErrors) {
console.error('\niterate-client: FAILED — console errors detected');
process.exit(1);
}
console.log('\niterate-client: PASSED — no errors');
process.exit(0);
}
main().catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,225 @@
#!/usr/bin/env node
// =============================================================================
// validate-architecture.mjs — Static architecture validation for Three.js games
//
// Checks that a game project follows the required architecture patterns:
// - render_game_to_text() in main.js
// - GameState.reset() in core/GameState.js
// - SAFE_ZONE constant in core/Constants.js
// - systems/ directory exists with at least one system file
// - EventBus events defined in core/EventBus.js
// - [WARN] Hardcoded magic numbers outside Constants.js
//
// Usage:
// node scripts/validate-architecture.mjs
//
// Exits 0 if all required checks pass, 1 if any FAIL.
// =============================================================================
import fs from 'node:fs';
import path from 'node:path';
const CWD = process.cwd();
const SRC = path.join(CWD, 'src');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readFile(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full)) return null;
return fs.readFileSync(full, 'utf-8');
}
function readDir(relPath) {
const full = path.join(SRC, relPath);
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) return [];
return fs.readdirSync(full).filter(f => f.endsWith('.js'));
}
function getAllJsFiles(dir, base) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
const rel = path.join(base, entry.name);
if (entry.isDirectory()) {
results.push(...getAllJsFiles(full, rel));
} else if (entry.name.endsWith('.js')) {
results.push({ path: full, rel });
}
}
return results;
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
let passed = 0;
let failed = 0;
let warnings = 0;
function pass(msg) {
console.log(`[PASS] ${msg}`);
passed++;
}
function fail(msg) {
console.log(`[FAIL] ${msg}`);
failed++;
}
function warn(msg) {
console.log(`[WARN] ${msg}`);
warnings++;
}
console.log('=== Architecture Validation (Three.js) ===\n');
// Check 1: render_game_to_text() in main.js
const mainJs = readFile('main.js');
if (mainJs && /render_game_to_text/.test(mainJs)) {
pass('render_game_to_text() found in main.js');
} else if (!mainJs) {
fail('render_game_to_text() — src/main.js not found');
} else {
fail('render_game_to_text() not found in main.js');
}
// Check 2: GameState has reset() method
const gameStateJs = readFile('core/GameState.js');
if (gameStateJs && /reset\s*\(/.test(gameStateJs)) {
pass('GameState.reset() found');
} else if (!gameStateJs) {
fail('GameState.reset() — src/core/GameState.js not found');
} else {
fail('GameState.reset() not found in GameState.js');
}
// Check 3: SAFE_ZONE constant in Constants.js
const constantsJs = readFile('core/Constants.js');
if (constantsJs && /SAFE_ZONE/.test(constantsJs)) {
pass('SAFE_ZONE found in Constants.js');
} else if (!constantsJs) {
fail('SAFE_ZONE — src/core/Constants.js not found');
} else {
fail('SAFE_ZONE not found in Constants.js');
}
// Check 4: systems/ directory with system files (Three.js equivalent of Phaser scenes)
const systemFiles = readDir('systems');
if (systemFiles.length > 0) {
pass(`systems/ directory found (${systemFiles.length} system file${systemFiles.length !== 1 ? 's' : ''}: ${systemFiles.join(', ')})`);
} else {
const systemsDir = path.join(SRC, 'systems');
if (fs.existsSync(systemsDir)) {
fail('systems/ directory exists but contains no .js files');
} else {
fail('systems/ directory not found — Three.js games need src/systems/ for game systems');
}
}
// Check 5: EventBus has events defined
const eventBusJs = readFile('core/EventBus.js');
let eventCount = 0;
if (eventBusJs) {
// Count exported event constants — look for KEY: 'value' patterns in an Events object
const eventMatches = eventBusJs.match(/[A-Z_]+\s*:\s*['"][a-z]+:[a-z_]+['"]/g);
eventCount = eventMatches ? eventMatches.length : 0;
if (eventCount > 0) {
pass(`EventBus events defined (${eventCount} events)`);
} else {
fail('EventBus — no event constants found in EventBus.js');
}
} else {
fail('EventBus — src/core/EventBus.js not found');
}
// Check 6: [WARN] Hardcoded magic numbers
const allJsFiles = getAllJsFiles(SRC, '');
const excludeFiles = new Set(['core/Constants.js', 'core/PixelRenderer.js']);
// Also exclude by basename for flexibility
const excludeBaseNames = new Set(['Constants.js', 'PixelRenderer.js']);
// Common non-magic numbers to allow
const allowedNumbers = new Set([
0, 1, 2, 3, 4, 5, 10, 16, 32, 60, 64, 100, 255, 256, 1000,
0.5, 0.25, 0.75, 1.0, 2.0,
-1, -2,
]);
const magicNumberPattern = /(?<!\w)(-?\d+\.?\d*)\b/g;
const suspiciousLines = [];
for (const { path: filePath, rel } of allJsFiles) {
// Normalize path separators for comparison
const normRel = rel.replace(/\\/g, '/');
const baseName = path.basename(filePath);
if (excludeFiles.has(normRel) || excludeBaseNames.has(baseName)) continue;
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
const trimmed = line.trim();
// Skip comments
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
// Skip import/export lines
if (trimmed.startsWith('import ') || trimmed.startsWith('export ')) continue;
// Skip lines that are just closing braces, returns of simple values, etc.
if (trimmed.length < 5) continue;
let match;
magicNumberPattern.lastIndex = 0;
while ((match = magicNumberPattern.exec(line)) !== null) {
const num = parseFloat(match[1]);
if (allowedNumbers.has(num)) continue;
if (isNaN(num)) continue;
// Skip proportional values (alpha, scale, ratio)
if (Math.abs(num) > 0 && Math.abs(num) < 1) continue;
// Skip hex color literals (0x...)
if (/0x[0-9a-fA-F]+/.test(match[0])) continue;
const before = line.substring(0, match.index);
if (/0x[0-9a-fA-F]*$/.test(before)) continue;
// Skip version-like patterns and string contents
if (/['"`]/.test(before) && /['"`]/.test(line.substring(match.index + match[0].length))) continue;
suspiciousLines.push({
file: `src/${normRel}`,
line: lineNum + 1,
text: trimmed,
number: match[1],
});
}
}
}
if (suspiciousLines.length > 0) {
warn('Possible magic numbers:');
// Show up to 15 suspicious lines
const shown = suspiciousLines.slice(0, 15);
for (const s of shown) {
console.log(` ${s.file}:${s.line} ${s.text}`);
}
if (suspiciousLines.length > 15) {
console.log(` ... and ${suspiciousLines.length - 15} more`);
}
} else {
console.log('[INFO] No suspicious magic numbers detected');
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const total = passed + failed;
console.log(`\nResults: ${passed}/${total} passed, ${failed} failed, ${warnings} warning${warnings !== 1 ? 's' : ''}`);
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// =============================================================================
// verify-runtime.mjs — Headless runtime verification for browser games
//
// Launches headless Chromium, loads the game, checks for runtime errors
// (WebGL failures, uncaught exceptions, console errors).
// Exit 0 = pass, Exit 1 = fail (prints errors to stderr).
//
// Usage:
// node scripts/verify-runtime.mjs
// PORT=5173 node scripts/verify-runtime.mjs
// =============================================================================
import { chromium } from '@playwright/test';
const PORT = process.env.PORT || 3000;
const URL = `http://localhost:${PORT}`;
const WAIT_MS = 3000;
async function verify() {
const errors = [];
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
page.on('pageerror', (err) => errors.push(`PAGE ERROR: ${err.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(`CONSOLE ERROR: ${msg.text()}`);
}
});
try {
const response = await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 10000 });
if (!response || response.status() >= 400) {
errors.push(`HTTP ${response?.status() || 'NO_RESPONSE'} loading ${URL}`);
}
} catch (e) {
errors.push(`NAVIGATION ERROR: ${e.message}`);
}
// Wait for game to initialize and render
await page.waitForTimeout(WAIT_MS);
await browser.close();
if (errors.length > 0) {
console.error(`Runtime verification FAILED with ${errors.length} error(s):\n`);
errors.forEach((e, i) => console.error(` ${i + 1}. ${e}`));
process.exit(1);
}
console.log('Runtime verification PASSED — no errors detected.');
process.exit(0);
}
verify();
@@ -0,0 +1,192 @@
export const GAME = {
FOV: 60,
NEAR: 0.1,
FAR: 200,
MAX_DELTA: 0.05,
MAX_DPR: 2,
};
export const IS_MOBILE = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 1);
// Play.fun SDK widget renders a 75px fixed bar at top:0, z-index:9999.
// All HTML overlays must account for this with padding-top or safe offset.
export const SAFE_ZONE = {
TOP_PX: 75,
TOP_PERCENT: 8,
};
export const PLAYER = {
SIZE: 1,
LANE_SPEED: 8, // lateral movement speed (left/right)
TURN_SPEED: 10,
START_X: 0,
START_Y: 0,
START_Z: 0,
COLOR: 0x44aaff,
COLLISION_RADIUS: 0.5,
INVINCIBILITY_DURATION: 1.5, // seconds after getting hit
FLASH_RATE: 10, // flashes per second during invincibility
};
export const STREET = {
WIDTH: 10, // total street width
LANE_LEFT: -2.5, // left lane X position
LANE_CENTER: 0, // center lane X position
LANE_RIGHT: 2.5, // right lane X position
LANE_MIN: -4, // leftmost boundary
LANE_MAX: 4, // rightmost boundary
HOUSE_OFFSET_X: 7, // distance from center to house row
SIDEWALK_WIDTH: 2, // sidewalk between street and houses
SEGMENT_LENGTH: 40, // length of each street segment
LANE_MARKING_WIDTH: 0.15,
LANE_MARKING_LENGTH: 2,
LANE_MARKING_GAP: 2,
};
export const ENVELOPE = {
WIDTH: 0.3,
HEIGHT: 0.02,
DEPTH: 0.2,
SPEED: 30,
COOLDOWN: 0.35, // seconds between throws
MAX_DISTANCE: 40, // auto-destroy after this distance
COLOR: 0xffffff,
SPIN_SPEED: 8, // radians per second rotation
};
export const HOUSE = {
WIDTH: 3,
HEIGHT: 2.5,
DEPTH: 3,
ROOF_HEIGHT: 1.2,
SPACING_Z: 6, // distance between houses along Z
SPAWN_DISTANCE: 80, // how far ahead to generate houses
CLEANUP_DISTANCE: 20, // how far behind to remove houses
COLORS: [
0x7eb8d8, // pastel blue
0xf0e68c, // pastel yellow
0xf4a6b8, // pastel pink
0x90c695, // pastel green
0xf5f5f5, // white
0xd2b48c, // beige
0xe8c4a0, // peach
0xb0c4de, // light steel blue
],
ROOF_COLORS: [
0x8b4513, // saddle brown
0x696969, // dim gray
0xa0522d, // sienna
0x556b2f, // dark olive
0x800000, // maroon
],
SHAKE_DURATION: 0.3,
SHAKE_INTENSITY: 0.15,
FLASH_DURATION: 0.2,
};
export const HOMEOWNER = {
BODY_WIDTH: 0.3,
BODY_HEIGHT: 0.5,
BODY_DEPTH: 0.2,
HEAD_RADIUS: 0.15,
ARM_WIDTH: 0.08,
ARM_HEIGHT: 0.35,
SPEED: 3,
PANIC_DURATION: 3, // seconds the homeowner runs around
DROP_INTERVAL: 0.4, // seconds between panic point drops
COLORS: [
0xff6347, // tomato
0x4169e1, // royal blue
0x32cd32, // lime green
0xff69b4, // hot pink
0xffa500, // orange
],
};
export const PANIC_POINT = {
RADIUS: 0.15,
FLOAT_HEIGHT: 0.8,
BOB_AMPLITUDE: 0.15,
BOB_SPEED: 3,
SPIN_SPEED: 4,
COLLECT_RADIUS: 1.2,
COLOR: 0x00ff00,
GLOW_COLOR: 0x44ff44,
LIFETIME: 8, // seconds before auto-despawn
FLASH_DURATION: 0.15,
};
export const AGENT = {
BODY_WIDTH: 0.4,
BODY_HEIGHT: 0.7,
BODY_DEPTH: 0.3,
HEAD_RADIUS: 0.18,
SIGN_WIDTH: 0.5,
SIGN_HEIGHT: 0.7,
SIGN_DEPTH: 0.05,
SPEED: 4,
SPAWN_INTERVAL: 3, // seconds between agent spawns (initial)
SPAWN_DISTANCE: 50, // how far ahead they spawn
COLLISION_RADIUS: 0.7,
COLOR_BODY: 0x1a1a2e, // dark navy suit
COLOR_HEAD: 0xffdab9, // peach skin
COLOR_SIGN: 0xff0000, // red FOR SALE sign
MIN_SPAWN_INTERVAL: 1, // fastest possible spawn rate
};
export const GAMEPLAY = {
AUTO_SPEED: 8, // initial forward speed (units/sec)
SPEED_INCREASE_RATE: 0.15, // speed increase per second
MAX_SPEED: 25, // cap
LIVES: 3,
THROW_ANIMATION_DURATION: 0.4, // how long the punch animation plays
};
export const COMBO = {
TIMEOUT_MS: 3000, // ms before combo resets if no hits
MULTIPLIER_CAP: 10, // max combo multiplier
};
export const LEVEL = {
GROUND_COLOR: 0x4a7c2e, // grass color
STREET_COLOR: 0x555555, // asphalt
SIDEWALK_COLOR: 0xccccbb, // concrete
FOG_COLOR: 0x87ceeb, // sky blue
FOG_NEAR: 30,
FOG_FAR: 100,
};
export const CAMERA = {
HEIGHT: 5,
DISTANCE: 8,
LOOK_AHEAD: 6, // how far ahead of player camera looks
MIN_DISTANCE: 3,
MAX_DISTANCE: 15,
};
export const COLORS = {
SKY: 0x87ceeb,
AMBIENT_LIGHT: 0xffffff,
AMBIENT_INTENSITY: 0.7,
DIR_LIGHT: 0xffffff,
DIR_INTENSITY: 0.9,
PLAYER: 0x44aaff,
};
// RobotExpressive character
export const CHARACTER = {
path: 'assets/models/RobotExpressive.glb',
scale: 1,
offsetY: 0,
facingOffset: 0, // RobotExpressive faces +Z
clipMap: {
idle: 'Idle',
walk: 'Walking',
run: 'Running',
throw: 'Punch',
},
};
export const ASSET_PATHS = {};
export const MODEL_CONFIG = {};
@@ -0,0 +1,93 @@
export const Events = {
// Game lifecycle
GAME_START: 'game:start',
GAME_OVER: 'game:over',
GAME_RESTART: 'game:restart',
// Player
PLAYER_MOVE: 'player:move',
PLAYER_JUMP: 'player:jump',
PLAYER_DIED: 'player:died',
PLAYER_HIT: 'player:hit',
// Score
SCORE_CHANGED: 'score:changed',
// Envelope throwing
ENVELOPE_THROWN: 'envelope:thrown',
// House hits
HOUSE_HIT: 'house:hit',
// Homeowner spawning
HOMEOWNER_SPAWNED: 'homeowner:spawned',
// Panic point collection
PANIC_COLLECTED: 'panic:collected',
// Agent (enemy) collision
AGENT_COLLISION: 'agent:collision',
// Lives
LIVES_CHANGED: 'lives:changed',
// Combo system
COMBO_CHANGED: 'combo:changed',
// Speed changes
SPEED_CHANGED: 'speed:changed',
// Spectacle events (future visual polish hooks)
SPECTACLE_ENTRANCE: 'spectacle:entrance',
SPECTACLE_ACTION: 'spectacle:action',
SPECTACLE_HIT: 'spectacle:hit',
SPECTACLE_COMBO: 'spectacle:combo',
SPECTACLE_STREAK: 'spectacle:streak',
SPECTACLE_NEAR_MISS: 'spectacle:near_miss',
// Audio (used by /add-audio)
AUDIO_INIT: 'audio:init',
MUSIC_MENU: 'music:menu',
MUSIC_GAMEPLAY: 'music:gameplay',
MUSIC_GAMEOVER: 'music:gameover',
MUSIC_STOP: 'music:stop',
};
class EventBus {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
return this;
}
off(event, callback) {
if (!this.listeners[event]) return this;
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
return this;
}
emit(event, data) {
if (!this.listeners[event]) return this;
this.listeners[event].forEach(callback => {
try {
callback(data);
} catch (err) {
console.error(`EventBus error in ${event}:`, err);
}
});
return this;
}
removeAll() {
this.listeners = {};
return this;
}
}
export const eventBus = new EventBus();
+284
View File
@@ -0,0 +1,284 @@
import * as THREE from 'three';
import { GAME, CAMERA, COLORS, GAMEPLAY, STREET } from './Constants.js';
import { eventBus, Events } from './EventBus.js';
import { gameState } from './GameState.js';
import { InputSystem } from '../systems/InputSystem.js';
import { StreetGenerator } from '../systems/StreetGenerator.js';
import { Player } from '../gameplay/Player.js';
import { LevelBuilder } from '../level/LevelBuilder.js';
import { Homeowner } from '../entities/Homeowner.js';
import { PanicPoint } from '../entities/PanicPoint.js';
import { Menu } from '../ui/Menu.js';
export class Game {
constructor() {
this.clock = new THREE.Clock();
// Renderer (DPR capped for mobile GPU performance)
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, GAME.MAX_DPR));
this.renderer.setClearColor(COLORS.SKY);
this.renderer.shadowMap.enabled = true;
document.body.prepend(this.renderer.domElement);
// Scene
this.scene = new THREE.Scene();
// Camera -- fixed chase camera behind and above the player
this.camera = new THREE.PerspectiveCamera(
GAME.FOV, window.innerWidth / window.innerHeight, GAME.NEAR, GAME.FAR
);
this.camera.position.set(0, CAMERA.HEIGHT, CAMERA.DISTANCE);
this.camera.lookAt(0, 1, -CAMERA.LOOK_AHEAD);
// Systems
this.input = new InputSystem();
this.level = new LevelBuilder(this.scene);
this.streetGen = null;
this.menu = new Menu();
this.player = null;
// Entity arrays managed by Game (homeowners, panic points)
this.homeowners = [];
this.panicPoints = [];
// Events
eventBus.on(Events.GAME_RESTART, () => this.restart());
// Resize
window.addEventListener('resize', () => this.onResize());
// Auto-start game (no title screen -- Play.fun handles the chrome)
this.startGame();
// Start render loop (official Three.js pattern -- pauses when tab hidden)
this.renderer.setAnimationLoop(() => this.animate());
}
startGame() {
gameState.reset();
gameState.started = true;
// Clean up old entities
this._clearEntities();
// Create street generator
if (this.streetGen) this.streetGen.reset();
else this.streetGen = new StreetGenerator(this.scene);
// Create player
this.player = new Player(this.scene);
this.input.setGameActive(true);
}
restart() {
if (this.player) {
this.player.destroy();
this.player = null;
}
this._clearEntities();
this.startGame();
}
_clearEntities() {
for (const hw of this.homeowners) hw.dispose(this.scene);
for (const pp of this.panicPoints) pp.dispose(this.scene);
this.homeowners = [];
this.panicPoints = [];
}
animate() {
const delta = Math.min(this.clock.getDelta(), GAME.MAX_DELTA);
this.input.update();
if (gameState.started && !gameState.gameOver && this.player) {
const playerZ = this.player.mesh.position.z;
// Update player (auto-run + input)
this.player.update(delta, this.input);
// Update street generator (houses, agents, street surface)
this.streetGen.update(delta, playerZ);
// Check envelope-house collisions
this._checkEnvelopeHits();
// Check player-agent collisions
this._checkAgentCollisions();
// Check player-panicPoint collection
this._checkPanicCollection();
// Update homeowners and spawn panic points
this._updateHomeowners(delta);
// Update panic points
this._updatePanicPoints(delta);
// Update camera to follow player
this._updateCamera();
// Update directional light to follow player
this.level.updateLightTarget(this.player.mesh.position);
}
this.renderer.render(this.scene, this.camera);
}
_checkEnvelopeHits() {
const playerZ = this.player.mesh.position.z;
const nearbyHouses = this.streetGen.getHousesInRange(playerZ, 50);
for (const envelope of this.player.envelopes) {
if (!envelope.alive) continue;
for (const house of nearbyHouses) {
if (house.isHit) continue;
if (envelope.checkHouse(house)) {
// Hit!
house.hit();
envelope.alive = false;
gameState.housesHit++;
// Combo
gameState.incrementCombo();
eventBus.emit(Events.COMBO_CHANGED, { combo: gameState.combo });
// Score for hitting the house
const earned = gameState.addScore(1);
eventBus.emit(Events.SCORE_CHANGED, { score: gameState.score, earned });
eventBus.emit(Events.HOUSE_HIT, {
x: house.mesh.position.x,
z: house.mesh.position.z,
combo: gameState.combo,
});
// Spectacle events
eventBus.emit(Events.SPECTACLE_HIT, { combo: gameState.combo });
if (gameState.combo >= 3) {
eventBus.emit(Events.SPECTACLE_COMBO, { combo: gameState.combo });
}
if (gameState.combo >= 5) {
eventBus.emit(Events.SPECTACLE_STREAK, { combo: gameState.combo });
}
// Spawn homeowner from the hit house
this._spawnHomeowner(house);
break; // one envelope hits one house
}
}
}
}
_spawnHomeowner(house) {
const homeowner = new Homeowner(
house.mesh.position,
house.side
);
this.scene.add(homeowner.mesh);
this.homeowners.push(homeowner);
eventBus.emit(Events.HOMEOWNER_SPAWNED, {
x: house.mesh.position.x,
z: house.mesh.position.z,
});
}
_updateHomeowners(delta) {
for (let i = this.homeowners.length - 1; i >= 0; i--) {
const hw = this.homeowners[i];
hw.update(delta);
// Collect any panic point drops
const drops = hw.consumeDrops();
for (const drop of drops) {
const pp = new PanicPoint(drop.x, drop.z);
this.scene.add(pp.mesh);
this.panicPoints.push(pp);
}
// Remove expired homeowners
if (!hw.alive) {
hw.dispose(this.scene);
this.homeowners.splice(i, 1);
}
}
}
_updatePanicPoints(delta) {
for (let i = this.panicPoints.length - 1; i >= 0; i--) {
const pp = this.panicPoints[i];
pp.update(delta);
if (!pp.alive) {
pp.dispose(this.scene);
this.panicPoints.splice(i, 1);
}
}
}
_checkPanicCollection() {
const playerPos = this.player.mesh.position;
for (const pp of this.panicPoints) {
if (pp.checkPlayer(playerPos)) {
if (pp.collect()) {
const earned = gameState.addScore(1);
eventBus.emit(Events.PANIC_COLLECTED, { score: gameState.score, earned });
eventBus.emit(Events.SCORE_CHANGED, { score: gameState.score, earned });
}
}
}
}
_checkAgentCollisions() {
if (this.player.isInvincible) return;
const playerPos = this.player.mesh.position;
const nearbyAgents = this.streetGen.getAgentsInRange(playerPos.z, 5);
for (const agent of nearbyAgents) {
if (agent.checkPlayer(playerPos)) {
agent.hasCollided = true;
this.player.takeDamage();
eventBus.emit(Events.AGENT_COLLISION, {
x: agent.mesh.position.x,
z: agent.mesh.position.z,
});
// Reset combo on hit
gameState.resetCombo();
eventBus.emit(Events.COMBO_CHANGED, { combo: 0 });
break;
}
}
// Near-miss detection for spectacle
for (const agent of nearbyAgents) {
if (!agent.hasCollided && agent.alive) {
const dx = agent.mesh.position.x - playerPos.x;
const dz = agent.mesh.position.z - playerPos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < 1.5 && dist > 0.7) {
eventBus.emit(Events.SPECTACLE_NEAR_MISS, { distance: dist });
}
}
}
}
_updateCamera() {
const pPos = this.player.mesh.position;
// Chase camera behind and above player
this.camera.position.x = pPos.x * 0.3; // slight follow on X for feel
this.camera.position.y = CAMERA.HEIGHT;
this.camera.position.z = pPos.z + CAMERA.DISTANCE;
this.camera.lookAt(pPos.x * 0.5, 1, pPos.z - CAMERA.LOOK_AHEAD);
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
}
@@ -0,0 +1,75 @@
import { GAMEPLAY, COMBO } from './Constants.js';
class GameState {
constructor() {
this.reset();
}
reset() {
this.score = 0;
this.bestScore = this.bestScore || 0;
this.started = false;
this.gameOver = false;
// Lives
this.lives = GAMEPLAY.LIVES;
// Combo system
this.combo = 0;
this.bestCombo = this.bestCombo || 0;
// Speed
this.currentSpeed = GAMEPLAY.AUTO_SPEED;
// Stats
this.housesHit = 0;
this.totalThrown = 0;
// Audio
this.isMuted = false;
// Combo timer
this._comboTimer = 0;
}
addScore(points = 1) {
const multiplier = Math.min(this.combo, COMBO.MULTIPLIER_CAP);
const finalPoints = points * Math.max(1, multiplier);
this.score += finalPoints;
if (this.score > this.bestScore) {
this.bestScore = this.score;
}
return finalPoints;
}
incrementCombo() {
this.combo++;
if (this.combo > this.bestCombo) {
this.bestCombo = this.combo;
}
this._comboTimer = COMBO.TIMEOUT_MS;
}
resetCombo() {
this.combo = 0;
this._comboTimer = 0;
}
updateComboTimer(deltaMs) {
if (this.combo > 0 && this._comboTimer > 0) {
this._comboTimer -= deltaMs;
if (this._comboTimer <= 0) {
this.resetCombo();
return true; // combo expired
}
}
return false;
}
loseLife() {
this.lives--;
return this.lives <= 0;
}
}
export const gameState = new GameState();
@@ -0,0 +1,89 @@
import * as THREE from 'three';
import { AGENT } from '../core/Constants.js';
export class Agent {
constructor(x, z) {
this.alive = true;
this.hasCollided = false;
// Container
this.mesh = new THREE.Group();
this.mesh.position.set(x, 0, z);
// Body (dark suit)
const bodyGeo = new THREE.BoxGeometry(AGENT.BODY_WIDTH, AGENT.BODY_HEIGHT, AGENT.BODY_DEPTH);
const bodyMat = new THREE.MeshLambertMaterial({ color: AGENT.COLOR_BODY });
this.bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
this.bodyMesh.position.y = AGENT.BODY_HEIGHT / 2 + 0.1;
this.bodyMesh.castShadow = true;
this.mesh.add(this.bodyMesh);
// Head
const headGeo = new THREE.SphereGeometry(AGENT.HEAD_RADIUS, 8, 6);
const headMat = new THREE.MeshLambertMaterial({ color: AGENT.COLOR_HEAD });
this.headMesh = new THREE.Mesh(headGeo, headMat);
this.headMesh.position.y = AGENT.BODY_HEIGHT + AGENT.HEAD_RADIUS + 0.15;
this.mesh.add(this.headMesh);
// FOR SALE sign (red rectangle held to the side)
const signGeo = new THREE.BoxGeometry(AGENT.SIGN_WIDTH, AGENT.SIGN_HEIGHT, AGENT.SIGN_DEPTH);
const signMat = new THREE.MeshLambertMaterial({ color: AGENT.COLOR_SIGN });
this.sign = new THREE.Mesh(signGeo, signMat);
this.sign.position.set(
AGENT.BODY_WIDTH / 2 + AGENT.SIGN_WIDTH / 2 + 0.1,
AGENT.BODY_HEIGHT * 0.6,
0
);
this.mesh.add(this.sign);
// Sign post (thin pole under sign)
const postGeo = new THREE.BoxGeometry(0.04, 0.8, 0.04);
const postMat = new THREE.MeshLambertMaterial({ color: 0x888888 });
const post = new THREE.Mesh(postGeo, postMat);
post.position.set(
AGENT.BODY_WIDTH / 2 + AGENT.SIGN_WIDTH / 2 + 0.1,
AGENT.BODY_HEIGHT * 0.6 - AGENT.SIGN_HEIGHT / 2 - 0.4,
0
);
this.mesh.add(post);
// Walking animation state
this._walkTime = Math.random() * Math.PI * 2; // random start phase
}
update(delta, playerZ) {
if (!this.alive) return;
// Walk toward the player (positive Z direction, since player runs -Z)
this.mesh.position.z += AGENT.SPEED * delta;
// Walking animation — bob up/down and sway
this._walkTime += delta * 8;
this.mesh.position.y = Math.abs(Math.sin(this._walkTime)) * 0.08;
this.bodyMesh.rotation.z = Math.sin(this._walkTime) * 0.05;
this.sign.rotation.z = Math.sin(this._walkTime * 0.7) * 0.1;
// Clean up if passed well behind the player
if (this.mesh.position.z > playerZ + 10) {
this.alive = false;
}
}
checkPlayer(playerPos) {
if (!this.alive || this.hasCollided) return false;
const dx = this.mesh.position.x - playerPos.x;
const dz = this.mesh.position.z - playerPos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
return dist < AGENT.COLLISION_RADIUS;
}
dispose(scene) {
this.mesh.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
child.material.dispose();
}
});
scene.remove(this.mesh);
}
}
@@ -0,0 +1,53 @@
import * as THREE from 'three';
import { ENVELOPE } from '../core/Constants.js';
const _envelopeGeo = new THREE.BoxGeometry(ENVELOPE.WIDTH, ENVELOPE.HEIGHT, ENVELOPE.DEPTH);
const _envelopeMat = new THREE.MeshLambertMaterial({ color: ENVELOPE.COLOR });
export class Envelope {
constructor(startPos, direction) {
this.mesh = new THREE.Mesh(_envelopeGeo, _envelopeMat.clone());
this.mesh.position.copy(startPos);
this.mesh.position.y += 1; // launch from player chest height
this.direction = direction.clone().normalize();
this.startZ = startPos.z;
this.alive = true;
this.distanceTraveled = 0;
}
update(delta) {
if (!this.alive) return;
// Move forward
const moveAmount = ENVELOPE.SPEED * delta;
this.mesh.position.addScaledVector(this.direction, moveAmount);
this.distanceTraveled += moveAmount;
// Spin for visual flair
this.mesh.rotation.y += ENVELOPE.SPIN_SPEED * delta;
this.mesh.rotation.x += ENVELOPE.SPIN_SPEED * 0.5 * delta;
// Auto-destroy after max distance
if (this.distanceTraveled >= ENVELOPE.MAX_DISTANCE) {
this.alive = false;
}
}
checkHouse(house) {
if (!this.alive || house.isHit) return false;
const dx = this.mesh.position.x - house.mesh.position.x;
const dz = this.mesh.position.z - house.mesh.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
// Use half-house width + envelope width as collision threshold
const threshold = (house.width * 0.5) + ENVELOPE.WIDTH;
return dist < threshold;
}
dispose(scene) {
scene.remove(this.mesh);
this.mesh.material.dispose();
}
}
@@ -0,0 +1,121 @@
import * as THREE from 'three';
import { HOMEOWNER } from '../core/Constants.js';
export class Homeowner {
constructor(housePos, side) {
this.alive = true;
this.timeAlive = 0;
this.dropTimer = 0;
this.pendingDrops = [];
// Pick random color
const color = HOMEOWNER.COLORS[Math.floor(Math.random() * HOMEOWNER.COLORS.length)];
// Container
this.mesh = new THREE.Group();
// Body (torso)
const bodyGeo = new THREE.BoxGeometry(HOMEOWNER.BODY_WIDTH, HOMEOWNER.BODY_HEIGHT, HOMEOWNER.BODY_DEPTH);
const bodyMat = new THREE.MeshLambertMaterial({ color });
this.bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
this.bodyMesh.position.y = HOMEOWNER.BODY_HEIGHT / 2 + 0.1;
this.mesh.add(this.bodyMesh);
// Head
const headGeo = new THREE.SphereGeometry(HOMEOWNER.HEAD_RADIUS, 8, 6);
const headMat = new THREE.MeshLambertMaterial({ color: 0xffdab9 }); // skin tone
this.headMesh = new THREE.Mesh(headGeo, headMat);
this.headMesh.position.y = HOMEOWNER.BODY_HEIGHT + HOMEOWNER.HEAD_RADIUS + 0.1;
this.mesh.add(this.headMesh);
// Left arm
const armGeo = new THREE.BoxGeometry(HOMEOWNER.ARM_WIDTH, HOMEOWNER.ARM_HEIGHT, HOMEOWNER.ARM_WIDTH);
const armMat = new THREE.MeshLambertMaterial({ color });
this.leftArm = new THREE.Mesh(armGeo, armMat);
this.leftArm.position.set(
-(HOMEOWNER.BODY_WIDTH / 2 + HOMEOWNER.ARM_WIDTH / 2 + 0.02),
HOMEOWNER.BODY_HEIGHT * 0.7,
0
);
this.mesh.add(this.leftArm);
// Right arm
this.rightArm = new THREE.Mesh(armGeo, armMat.clone());
this.rightArm.position.set(
HOMEOWNER.BODY_WIDTH / 2 + HOMEOWNER.ARM_WIDTH / 2 + 0.02,
HOMEOWNER.BODY_HEIGHT * 0.7,
0
);
this.mesh.add(this.rightArm);
// Position at house exit
const exitX = side === 'left'
? housePos.x + 2
: housePos.x - 2;
this.mesh.position.set(exitX, 0, housePos.z);
// Random run direction (away from house, somewhat toward street)
const awayX = side === 'left' ? 1 : -1;
this.runDir = new THREE.Vector3(
awayX * (0.5 + Math.random() * 0.5),
0,
(Math.random() - 0.5) * 2
).normalize();
}
update(delta) {
if (!this.alive) return;
this.timeAlive += delta;
// Move in panic direction
this.mesh.position.addScaledVector(this.runDir, HOMEOWNER.SPEED * delta);
// Arm-waving animation (oscillate rotation)
const wave = Math.sin(this.timeAlive * 12) * 1.2;
this.leftArm.rotation.z = wave;
this.rightArm.rotation.z = -wave;
// Slight head bobble
this.headMesh.rotation.z = Math.sin(this.timeAlive * 8) * 0.3;
// Occasional direction change for erratic movement
if (Math.random() < delta * 2) {
this.runDir.x += (Math.random() - 0.5) * 0.5;
this.runDir.z += (Math.random() - 0.5) * 0.5;
this.runDir.normalize();
}
// Drop panic points at intervals
this.dropTimer += delta;
if (this.dropTimer >= HOMEOWNER.DROP_INTERVAL) {
this.dropTimer -= HOMEOWNER.DROP_INTERVAL;
this.pendingDrops.push({
x: this.mesh.position.x,
z: this.mesh.position.z,
});
}
// Expire after duration
if (this.timeAlive >= HOMEOWNER.PANIC_DURATION) {
this.alive = false;
}
}
/** Returns and clears pending panic point drop positions */
consumeDrops() {
const drops = this.pendingDrops;
this.pendingDrops = [];
return drops;
}
dispose(scene) {
this.mesh.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
child.material.dispose();
}
});
scene.remove(this.mesh);
}
}
@@ -0,0 +1,153 @@
import * as THREE from 'three';
import { HOUSE } from '../core/Constants.js';
export class House {
constructor(x, z, side) {
this.isHit = false;
this.side = side; // 'left' or 'right'
this.width = HOUSE.WIDTH;
// Pick random colors
const bodyColor = HOUSE.COLORS[Math.floor(Math.random() * HOUSE.COLORS.length)];
const roofColor = HOUSE.ROOF_COLORS[Math.floor(Math.random() * HOUSE.ROOF_COLORS.length)];
// Container group
this.mesh = new THREE.Group();
this.mesh.position.set(x, 0, z);
// House body
const bodyGeo = new THREE.BoxGeometry(HOUSE.WIDTH, HOUSE.HEIGHT, HOUSE.DEPTH);
const bodyMat = new THREE.MeshLambertMaterial({ color: bodyColor });
this.body = new THREE.Mesh(bodyGeo, bodyMat);
this.body.position.y = HOUSE.HEIGHT / 2;
this.body.castShadow = true;
this.body.receiveShadow = true;
this.mesh.add(this.body);
// Door (darker rectangle on front face)
const doorGeo = new THREE.BoxGeometry(0.4, 0.8, 0.05);
const doorMat = new THREE.MeshLambertMaterial({ color: 0x5c3317 });
const door = new THREE.Mesh(doorGeo, doorMat);
door.position.set(0, 0.4, HOUSE.DEPTH / 2 + 0.02);
// Rotate door to face the street
if (side === 'left') {
door.position.set(-HOUSE.WIDTH / 2 - 0.02, 0.4, 0);
door.rotation.y = -Math.PI / 2;
} else {
door.position.set(HOUSE.WIDTH / 2 + 0.02, 0.4, 0);
door.rotation.y = Math.PI / 2;
}
this.mesh.add(door);
// Window (lighter square)
const windowGeo = new THREE.BoxGeometry(0.4, 0.4, 0.05);
const windowMat = new THREE.MeshLambertMaterial({ color: 0xadd8e6 });
const win1 = new THREE.Mesh(windowGeo, windowMat);
if (side === 'left') {
win1.position.set(-HOUSE.WIDTH / 2 - 0.02, 1.5, -0.5);
win1.rotation.y = -Math.PI / 2;
} else {
win1.position.set(HOUSE.WIDTH / 2 + 0.02, 1.5, -0.5);
win1.rotation.y = Math.PI / 2;
}
this.mesh.add(win1);
const win2 = win1.clone();
if (side === 'left') {
win2.position.set(-HOUSE.WIDTH / 2 - 0.02, 1.5, 0.5);
} else {
win2.position.set(HOUSE.WIDTH / 2 + 0.02, 1.5, 0.5);
}
this.mesh.add(win2);
// Triangular roof using BufferGeometry
const roofShape = this._createRoofGeometry();
const roofMat = new THREE.MeshLambertMaterial({ color: roofColor, side: THREE.DoubleSide });
this.roof = new THREE.Mesh(roofShape, roofMat);
this.roof.position.y = HOUSE.HEIGHT;
this.roof.castShadow = true;
this.mesh.add(this.roof);
// Shake/flash state
this._shakeTimer = 0;
this._flashTimer = 0;
this._originalBodyColor = bodyColor;
this._baseX = x;
}
_createRoofGeometry() {
const hw = HOUSE.WIDTH / 2 + 0.2; // slight overhang
const hd = HOUSE.DEPTH / 2 + 0.2;
const rh = HOUSE.ROOF_HEIGHT;
const vertices = new Float32Array([
// Front face
-hw, 0, hd,
hw, 0, hd,
0, rh, 0,
// Back face
-hw, 0, -hd,
hw, 0, -hd,
0, rh, 0,
// Left face
-hw, 0, hd,
-hw, 0, -hd,
0, rh, 0,
// Right face
hw, 0, hd,
hw, 0, -hd,
0, rh, 0,
// Bottom face (two triangles)
-hw, 0, hd,
hw, 0, hd,
-hw, 0, -hd,
hw, 0, hd,
hw, 0, -hd,
-hw, 0, -hd,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.computeVertexNormals();
return geometry;
}
hit() {
if (this.isHit) return;
this.isHit = true;
this._shakeTimer = HOUSE.SHAKE_DURATION;
this._flashTimer = HOUSE.FLASH_DURATION;
this.body.material.color.setHex(0xffff00); // flash yellow
}
update(delta) {
// Shake effect
if (this._shakeTimer > 0) {
this._shakeTimer -= delta;
const intensity = HOUSE.SHAKE_INTENSITY * (this._shakeTimer / HOUSE.SHAKE_DURATION);
this.mesh.position.x = this._baseX + (Math.random() - 0.5) * intensity * 2;
if (this._shakeTimer <= 0) {
this.mesh.position.x = this._baseX;
}
}
// Flash effect — restore color after flash duration
if (this._flashTimer > 0) {
this._flashTimer -= delta;
if (this._flashTimer <= 0) {
// Dim the house color to show it's been "sold"
this.body.material.color.setHex(0x999999);
}
}
}
dispose(scene) {
this.mesh.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
child.material.dispose();
}
});
scene.remove(this.mesh);
}
}
@@ -0,0 +1,84 @@
import * as THREE from 'three';
import { PANIC_POINT } from '../core/Constants.js';
const _ppGeo = new THREE.SphereGeometry(PANIC_POINT.RADIUS, 8, 6);
export class PanicPoint {
constructor(x, z) {
this.alive = true;
this.collected = false;
this.timeAlive = 0;
this._flashTimer = 0;
// Green glowing sphere
const mat = new THREE.MeshLambertMaterial({
color: PANIC_POINT.COLOR,
emissive: PANIC_POINT.GLOW_COLOR,
emissiveIntensity: 0.3,
});
this.mesh = new THREE.Mesh(_ppGeo, mat);
this.mesh.position.set(x, PANIC_POINT.FLOAT_HEIGHT, z);
this.baseY = PANIC_POINT.FLOAT_HEIGHT;
// Add a small inner ring for a $ sign effect
const ringGeo = new THREE.TorusGeometry(PANIC_POINT.RADIUS * 0.6, 0.02, 4, 8);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
this.ring = new THREE.Mesh(ringGeo, ringMat);
this.mesh.add(this.ring);
}
update(delta) {
if (!this.alive) return;
this.timeAlive += delta;
// Bobbing animation
this.mesh.position.y = this.baseY +
Math.sin(this.timeAlive * PANIC_POINT.BOB_SPEED) * PANIC_POINT.BOB_AMPLITUDE;
// Spinning
this.mesh.rotation.y += PANIC_POINT.SPIN_SPEED * delta;
// Flash on collect
if (this._flashTimer > 0) {
this._flashTimer -= delta;
const scale = 1 + (1 - this._flashTimer / PANIC_POINT.FLASH_DURATION) * 2;
this.mesh.scale.setScalar(scale);
this.mesh.material.opacity = this._flashTimer / PANIC_POINT.FLASH_DURATION;
if (this._flashTimer <= 0) {
this.alive = false;
}
return;
}
// Auto-despawn after lifetime
if (this.timeAlive >= PANIC_POINT.LIFETIME) {
this.alive = false;
}
}
collect() {
if (this.collected) return false;
this.collected = true;
this._flashTimer = PANIC_POINT.FLASH_DURATION;
this.mesh.material.transparent = true;
return true;
}
checkPlayer(playerPos) {
if (this.collected || !this.alive) return false;
const dx = this.mesh.position.x - playerPos.x;
const dz = this.mesh.position.z - playerPos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
return dist < PANIC_POINT.COLLECT_RADIUS;
}
dispose(scene) {
scene.remove(this.mesh);
this.mesh.material.dispose();
if (this.ring) {
this.ring.geometry.dispose();
this.ring.material.dispose();
}
}
}
@@ -0,0 +1,252 @@
import * as THREE from 'three';
import { PLAYER, CHARACTER, STREET, ENVELOPE, GAMEPLAY } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
import { loadAnimatedModel } from '../level/AssetLoader.js';
import { Envelope } from '../entities/Envelope.js';
const _v = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
export class Player {
constructor(scene) {
this.scene = scene;
this.mixer = null;
this.actions = {};
this.activeAction = null;
this.model = null;
this.ready = false;
// Throwing
this.envelopes = [];
this._throwCooldown = 0;
this._throwAnimTimer = 0;
this._isThrowAnim = false;
// Invincibility
this._invincibleTimer = 0;
this._flashAccum = 0;
// Group is the position anchor -- camera follows this
this.mesh = new THREE.Group();
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
this.scene.add(this.mesh);
this._loadModel();
}
async _loadModel() {
try {
const { model, clips } = await loadAnimatedModel(CHARACTER.path);
model.scale.setScalar(CHARACTER.scale);
model.position.y = CHARACTER.offsetY;
this.model = model;
this.mesh.add(model);
// Set up mixer
this.mixer = new THREE.AnimationMixer(model);
for (const clip of clips) {
this.actions[clip.name] = this.mixer.clipAction(clip);
}
// Start running (auto-runner)
const runClip = CHARACTER.clipMap.run;
if (this.actions[runClip]) {
this.actions[runClip].play();
this.activeAction = this.actions[runClip];
}
this.ready = true;
console.log('Player animations:', Object.keys(this.actions).join(', '));
} catch (err) {
console.warn('Player model failed, using fallback:', err.message);
// Fallback: colored box
const geo = new THREE.BoxGeometry(0.6, 1.8, 0.6);
const mat = new THREE.MeshLambertMaterial({ color: PLAYER.COLOR });
const box = new THREE.Mesh(geo, mat);
box.castShadow = true;
box.position.y = 0.9;
this.mesh.add(box);
this.ready = true;
}
}
fadeToAction(key, duration = 0.3) {
const clipName = CHARACTER.clipMap[key];
const next = this.actions[clipName];
if (!next || next === this.activeAction) return;
if (this.activeAction) this.activeAction.fadeOut(duration);
next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(duration).play();
this.activeAction = next;
}
throwEnvelope() {
if (this._throwCooldown > 0) return;
if (gameState.gameOver) return;
this._throwCooldown = ENVELOPE.COOLDOWN;
gameState.totalThrown++;
// Create envelope flying forward (negative Z)
const dir = new THREE.Vector3(0, 0, -1);
const envelope = new Envelope(this.mesh.position, dir);
this.scene.add(envelope.mesh);
this.envelopes.push(envelope);
// Play throw (Punch) animation briefly
this._isThrowAnim = true;
this._throwAnimTimer = GAMEPLAY.THROW_ANIMATION_DURATION;
this.fadeToAction('throw', 0.1);
eventBus.emit(Events.ENVELOPE_THROWN, {
x: this.mesh.position.x,
z: this.mesh.position.z,
});
eventBus.emit(Events.SPECTACLE_ACTION, { type: 'throw' });
}
takeDamage() {
if (this._invincibleTimer > 0) return;
const isDead = gameState.loseLife();
this._invincibleTimer = PLAYER.INVINCIBILITY_DURATION;
this._flashAccum = 0;
eventBus.emit(Events.PLAYER_HIT, { lives: gameState.lives });
eventBus.emit(Events.LIVES_CHANGED, { lives: gameState.lives });
if (isDead) {
eventBus.emit(Events.PLAYER_DIED);
gameState.gameOver = true;
eventBus.emit(Events.GAME_OVER, {
score: gameState.score,
housesHit: gameState.housesHit,
bestCombo: gameState.bestCombo,
});
}
}
update(delta, input) {
if (this.mixer) this.mixer.update(delta);
if (!this.ready) return;
// Throw cooldown
if (this._throwCooldown > 0) {
this._throwCooldown -= delta;
}
// Throw animation timer -- return to run after throw completes
if (this._isThrowAnim) {
this._throwAnimTimer -= delta;
if (this._throwAnimTimer <= 0) {
this._isThrowAnim = false;
this.fadeToAction('run', 0.2);
}
}
// Auto-run forward (negative Z direction)
this.mesh.position.z -= gameState.currentSpeed * delta;
// Left/right lane movement
let ix = 0;
if (input.left) ix -= 1;
if (input.right) ix += 1;
if (ix !== 0) {
this.mesh.position.x += ix * PLAYER.LANE_SPEED * delta;
// Clamp to street boundaries
this.mesh.position.x = Math.max(STREET.LANE_MIN, Math.min(STREET.LANE_MAX, this.mesh.position.x));
}
// Face the model forward (-Z) with slight lean for lateral movement
if (this.model) {
const targetAngle = (CHARACTER.facingOffset || 0) + Math.PI + ix * 0.2;
_q.setFromAxisAngle(_up, targetAngle);
this.model.quaternion.rotateTowards(_q, PLAYER.TURN_SPEED * delta);
}
// Invincibility flash
if (this._invincibleTimer > 0) {
this._invincibleTimer -= delta;
this._flashAccum += delta;
// Toggle visibility for flashing effect
const visible = Math.floor(this._flashAccum * PLAYER.FLASH_RATE) % 2 === 0;
this.mesh.visible = visible;
if (this._invincibleTimer <= 0) {
this.mesh.visible = true;
}
}
// Update envelopes
for (const env of this.envelopes) {
env.update(delta);
}
// Remove dead envelopes
for (let i = this.envelopes.length - 1; i >= 0; i--) {
if (!this.envelopes[i].alive) {
this.envelopes[i].dispose(this.scene);
this.envelopes.splice(i, 1);
}
}
// Throw on input
if (input.throwPressed) {
this.throwEnvelope();
}
// Increase speed over time
if (gameState.currentSpeed < GAMEPLAY.MAX_SPEED) {
gameState.currentSpeed += GAMEPLAY.SPEED_INCREASE_RATE * delta;
if (gameState.currentSpeed > GAMEPLAY.MAX_SPEED) {
gameState.currentSpeed = GAMEPLAY.MAX_SPEED;
}
}
// Update combo timer
const comboExpired = gameState.updateComboTimer(delta * 1000);
if (comboExpired) {
eventBus.emit(Events.COMBO_CHANGED, { combo: 0 });
}
}
get isInvincible() {
return this._invincibleTimer > 0;
}
reset() {
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
this.mesh.visible = true;
this._invincibleTimer = 0;
this._throwCooldown = 0;
this._isThrowAnim = false;
// Clean up envelopes
for (const env of this.envelopes) {
env.dispose(this.scene);
}
this.envelopes = [];
// Restart run animation
if (this.ready) {
this.fadeToAction('run', 0.1);
}
}
destroy() {
if (this.mixer) this.mixer.stopAllAction();
for (const env of this.envelopes) {
env.dispose(this.scene);
}
this.envelopes = [];
this.mesh.traverse((c) => {
if (c.isMesh) {
c.geometry.dispose();
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
});
this.scene.remove(this.mesh);
}
}
@@ -0,0 +1,92 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
const loader = new GLTFLoader();
const cache = new Map();
/**
* Load a GLTF/GLB model (static, no skeleton).
*/
export async function loadModel(path) {
const gltf = await _load(path);
const clone = gltf.scene.clone(true);
clone.traverse((child) => {
if (child.isMesh) {
child.material = child.material.clone();
child.castShadow = true;
child.receiveShadow = true;
}
});
return clone;
}
/**
* Load a GLTF/GLB model with skeleton + animations.
* Uses SkeletonUtils.clone() so bone bindings survive cloning.
*/
export async function loadAnimatedModel(path) {
const gltf = await _load(path);
// SkeletonUtils.clone properly re-binds SkinnedMesh to cloned Skeleton
const model = SkeletonUtils.clone(gltf.scene);
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
return { model, clips: gltf.animations };
}
/**
* Preload multiple paths in parallel. Returns when all are cached.
* @param {string[]} paths
* @param {(loaded: number, total: number) => void} [onProgress]
*/
export async function preloadAll(paths, onProgress) {
let loaded = 0;
const total = paths.length;
await Promise.all(paths.map((path) =>
_load(path).then(() => {
loaded++;
if (onProgress) onProgress(loaded, total);
})
));
}
/**
* Dispose all cached models.
*/
export function disposeAll() {
cache.forEach((promise) => {
promise.then((gltf) => {
gltf.scene.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
});
});
cache.clear();
}
function _load(path) {
if (!cache.has(path)) {
cache.set(path, new Promise((resolve, reject) => {
loader.load(path, resolve, undefined,
(err) => reject(new Error(`Failed to load: ${path}${err.message || err}`))
);
}));
}
return cache.get(path);
}
@@ -0,0 +1,49 @@
import * as THREE from 'three';
import { LEVEL, COLORS } from '../core/Constants.js';
export class LevelBuilder {
constructor(scene) {
this.scene = scene;
this.buildLighting();
this.buildFog();
}
// Ground is handled by StreetGenerator (road + sidewalks + grass strips)
// No static ground plane needed for an endless runner
buildLighting() {
const ambient = new THREE.AmbientLight(COLORS.AMBIENT_LIGHT, COLORS.AMBIENT_INTENSITY);
this.scene.add(ambient);
const directional = new THREE.DirectionalLight(COLORS.DIR_LIGHT, COLORS.DIR_INTENSITY);
directional.position.set(5, 10, 7);
directional.castShadow = true;
// Extend shadow camera to cover more of the street
directional.shadow.camera.left = -20;
directional.shadow.camera.right = 20;
directional.shadow.camera.top = 20;
directional.shadow.camera.bottom = -20;
directional.shadow.camera.near = 0.5;
directional.shadow.camera.far = 50;
this.directionalLight = directional;
this.scene.add(directional);
}
buildFog() {
this.scene.fog = new THREE.Fog(LEVEL.FOG_COLOR, LEVEL.FOG_NEAR, LEVEL.FOG_FAR);
}
/** Update light position to follow the player */
updateLightTarget(playerPos) {
if (this.directionalLight) {
this.directionalLight.position.set(
playerPos.x + 5,
10,
playerPos.z - 7
);
this.directionalLight.target.position.copy(playerPos);
this.directionalLight.target.updateMatrixWorld();
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import { Game } from './core/Game.js';
import { eventBus, Events } from './core/EventBus.js';
import { gameState } from './core/GameState.js';
import { IS_MOBILE } from './core/Constants.js';
const game = new Game();
// Expose for Playwright testing
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.__EVENT_BUS__ = eventBus;
window.__EVENTS__ = Events;
// --- Combo HUD ---
const comboDisplay = document.getElementById('combo-display');
eventBus.on(Events.COMBO_CHANGED, ({ combo }) => {
if (combo >= 2 && comboDisplay) {
comboDisplay.textContent = `${combo}x COMBO!`;
comboDisplay.classList.add('visible');
} else if (comboDisplay) {
comboDisplay.classList.remove('visible');
}
});
// --- Mobile UI ---
if (IS_MOBILE) {
const throwBtn = document.getElementById('throw-btn');
const hints = document.getElementById('mobile-hints');
if (throwBtn) throwBtn.style.display = 'flex';
if (hints) hints.style.display = 'block';
// Throw button fires an envelope
if (throwBtn) {
throwBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
if (game.player) {
game.player.throwEnvelope();
}
}, { passive: false });
}
}
// --- AI-readable game state snapshot ---
window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const payload = {
// Coordinate system: x increases rightward, y increases upward, z toward camera (player runs -Z)
coords: 'origin:center x:right y:up z:toward-camera player-runs:-Z',
mode: gameState.gameOver ? 'game_over' : gameState.started ? 'playing' : 'menu',
score: gameState.score,
bestScore: gameState.bestScore,
lives: gameState.lives,
combo: gameState.combo,
bestCombo: gameState.bestCombo,
currentSpeed: Math.round(gameState.currentSpeed * 100) / 100,
housesHit: gameState.housesHit,
totalThrown: gameState.totalThrown,
};
// Add player info when in gameplay
if (gameState.started && game.player?.mesh) {
const pos = game.player.mesh.position;
payload.player = {
x: Math.round(pos.x * 100) / 100,
y: Math.round(pos.y * 100) / 100,
z: Math.round(pos.z * 100) / 100,
invincible: game.player.isInvincible,
envelopes: game.player.envelopes.length,
};
}
// Nearby entities (within view distance of player)
if (gameState.started && game.player?.mesh && game.streetGen) {
const pz = game.player.mesh.position.z;
// Nearby houses (within 30 units)
const nearHouses = game.streetGen.houses
.filter(h => Math.abs(h.mesh.position.z - pz) < 30)
.map(h => ({
x: Math.round(h.mesh.position.x),
z: Math.round(h.mesh.position.z),
hit: h.isHit,
side: h.side,
}));
if (nearHouses.length > 0) payload.houses = nearHouses;
// Nearby agents (within 20 units)
const nearAgents = game.streetGen.agents
.filter(a => a.alive && Math.abs(a.mesh.position.z - pz) < 20)
.map(a => ({
x: Math.round(a.mesh.position.x * 10) / 10,
z: Math.round(a.mesh.position.z * 10) / 10,
}));
if (nearAgents.length > 0) payload.agents = nearAgents;
// Nearby panic points (within 15 units)
const nearPP = game.panicPoints
.filter(pp => pp.alive && !pp.collected && Math.abs(pp.mesh.position.z - pz) < 15)
.map(pp => ({
x: Math.round(pp.mesh.position.x * 10) / 10,
z: Math.round(pp.mesh.position.z * 10) / 10,
}));
if (nearPP.length > 0) payload.panicPoints = nearPP;
}
return JSON.stringify(payload);
};
// --- Deterministic time-stepping hook ---
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};
@@ -0,0 +1,128 @@
// =============================================================================
// InputSystem.js -- Keyboard + touch input for auto-runner
//
// WASD / Arrow keys for left/right lane movement.
// Space / right-screen tap for throwing envelopes.
// =============================================================================
import { IS_MOBILE } from '../core/Constants.js';
export class InputSystem {
constructor() {
this.keys = {};
this._throwJustPressed = false;
this._throwConsumed = false;
this._gameActive = false;
// Keyboard
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
if (e.code.startsWith('Arrow') || e.code === 'Space') e.preventDefault();
if (e.code === 'Space' && !this._throwConsumed) {
this._throwJustPressed = true;
this._throwConsumed = true;
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
if (e.code === 'Space') {
this._throwConsumed = false;
}
});
// Touch input for mobile
this._touchThrow = false;
this._touchLeft = false;
this._touchRight = false;
this._activeTouches = new Map();
if (IS_MOBILE) {
this._setupTouch();
}
// Also support mouse clicks for throw (desktop testing)
window.addEventListener('mousedown', (e) => {
if (!this._gameActive) return;
// Right half of screen = throw
if (e.clientX > window.innerWidth / 2) {
this._throwJustPressed = true;
}
});
}
_setupTouch() {
const handler = (e) => {
if (!this._gameActive) return;
this._touchLeft = false;
this._touchRight = false;
this._touchThrow = false;
for (const touch of e.touches) {
const x = touch.clientX;
const halfW = window.innerWidth / 2;
if (x > halfW) {
// Right half = throw
this._touchThrow = true;
} else {
// Left half = dodge direction based on position within left half
const quarterW = halfW / 2;
if (x < quarterW) {
this._touchLeft = true;
} else {
this._touchRight = true;
}
}
}
};
window.addEventListener('touchstart', (e) => {
handler(e);
// Register throw press on touch start (right side)
for (const touch of e.changedTouches) {
if (touch.clientX > window.innerWidth / 2) {
this._throwJustPressed = true;
}
}
}, { passive: true });
window.addEventListener('touchmove', handler, { passive: true });
window.addEventListener('touchend', (e) => {
handler(e);
}, { passive: true });
}
isDown(code) { return !!this.keys[code]; }
setGameActive(active) {
this._gameActive = active;
}
update() {
// throwPressed is consumed once per frame
// It will be true for exactly one frame after space/tap
}
/** Consume the throw input -- returns true only once per press */
get throwPressed() {
if (this._throwJustPressed) {
this._throwJustPressed = false;
return true;
}
return false;
}
get forward() { return false; } // no forward control in auto-runner
get backward() { return false; } // no backward control in auto-runner
get left() {
return this.isDown('KeyA') || this.isDown('ArrowLeft') || this._touchLeft;
}
get right() {
return this.isDown('KeyD') || this.isDown('ArrowRight') || this._touchRight;
}
get shift() { return this.isDown('ShiftLeft') || this.isDown('ShiftRight'); }
get jump() { return this.isDown('Space'); }
get moveX() { return (this.right ? 1 : 0) - (this.left ? 1 : 0); }
get moveZ() { return 0; }
}
@@ -0,0 +1,220 @@
import * as THREE from 'three';
import { STREET, HOUSE, AGENT, LEVEL } from '../core/Constants.js';
import { gameState } from '../core/GameState.js';
import { House } from '../entities/House.js';
import { Agent } from '../entities/Agent.js';
export class StreetGenerator {
constructor(scene) {
this.scene = scene;
this.houses = [];
this.agents = [];
this.streetSegments = [];
// Track how far we have generated (houses)
this._generatedZ = 10;
// Track how far ahead street surface extends
this._lastStreetEnd = -HOUSE.SPAWN_DISTANCE - 20;
this._agentTimer = AGENT.SPAWN_INTERVAL;
// Shared materials (reuse for performance)
this._streetMat = new THREE.MeshLambertMaterial({ color: LEVEL.STREET_COLOR });
this._sidewalkMat = new THREE.MeshLambertMaterial({ color: LEVEL.SIDEWALK_COLOR });
this._grassMat = new THREE.MeshLambertMaterial({ color: LEVEL.GROUND_COLOR });
this._lineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
this._generateInitial();
}
_generateInitial() {
// Generate house rows from z=+10 to z=-SPAWN_DISTANCE
while (this._generatedZ > -HOUSE.SPAWN_DISTANCE) {
this._generateRow(this._generatedZ);
this._generatedZ -= HOUSE.SPACING_Z;
}
// Generate initial street surface
this._generateStreetSurface(20, this._lastStreetEnd);
}
_generateStreetSurface(startZ, endZ) {
const length = startZ - endZ;
const centerZ = (startZ + endZ) / 2;
// Main road
const roadGeo = new THREE.PlaneGeometry(STREET.WIDTH, length);
const road = new THREE.Mesh(roadGeo, this._streetMat);
road.rotation.x = -Math.PI / 2;
road.position.set(0, 0.01, centerZ);
road.receiveShadow = true;
this.scene.add(road);
this.streetSegments.push(road);
// Left sidewalk
const swGeo = new THREE.PlaneGeometry(STREET.SIDEWALK_WIDTH, length);
const swLeft = new THREE.Mesh(swGeo, this._sidewalkMat);
swLeft.rotation.x = -Math.PI / 2;
swLeft.position.set(-(STREET.WIDTH / 2 + STREET.SIDEWALK_WIDTH / 2), 0.02, centerZ);
swLeft.receiveShadow = true;
this.scene.add(swLeft);
this.streetSegments.push(swLeft);
// Right sidewalk
const swRight = new THREE.Mesh(swGeo, this._sidewalkMat);
swRight.rotation.x = -Math.PI / 2;
swRight.position.set(STREET.WIDTH / 2 + STREET.SIDEWALK_WIDTH / 2, 0.02, centerZ);
swRight.receiveShadow = true;
this.scene.add(swRight);
this.streetSegments.push(swRight);
// Left grass strip
const grassGeo = new THREE.PlaneGeometry(8, length);
const grassLeft = new THREE.Mesh(grassGeo, this._grassMat);
grassLeft.rotation.x = -Math.PI / 2;
grassLeft.position.set(-(STREET.WIDTH / 2 + STREET.SIDEWALK_WIDTH + 4), 0, centerZ);
grassLeft.receiveShadow = true;
this.scene.add(grassLeft);
this.streetSegments.push(grassLeft);
// Right grass strip
const grassRight = new THREE.Mesh(grassGeo, this._grassMat);
grassRight.rotation.x = -Math.PI / 2;
grassRight.position.set(STREET.WIDTH / 2 + STREET.SIDEWALK_WIDTH + 4, 0, centerZ);
grassRight.receiveShadow = true;
this.scene.add(grassRight);
this.streetSegments.push(grassRight);
// Lane markings (dashed center line)
for (let z = startZ; z > endZ; z -= (STREET.LANE_MARKING_LENGTH + STREET.LANE_MARKING_GAP)) {
const lineGeo = new THREE.PlaneGeometry(STREET.LANE_MARKING_WIDTH, STREET.LANE_MARKING_LENGTH);
const line = new THREE.Mesh(lineGeo, this._lineMat);
line.rotation.x = -Math.PI / 2;
line.position.set(0, 0.03, z);
this.scene.add(line);
this.streetSegments.push(line);
}
}
_generateRow(z) {
// Left house (random chance to skip for gaps)
if (Math.random() > 0.2) {
const house = new House(-STREET.HOUSE_OFFSET_X, z, 'left');
this.scene.add(house.mesh);
this.houses.push(house);
}
// Right house
if (Math.random() > 0.2) {
const house = new House(STREET.HOUSE_OFFSET_X, z, 'right');
this.scene.add(house.mesh);
this.houses.push(house);
}
}
update(delta, playerZ) {
// Generate new houses ahead of player
while (this._generatedZ > playerZ - HOUSE.SPAWN_DISTANCE) {
this._generateRow(this._generatedZ);
this._generatedZ -= HOUSE.SPACING_Z;
}
// Extend street surface as player advances
if (playerZ - 40 < this._lastStreetEnd) {
const newEnd = this._lastStreetEnd - STREET.SEGMENT_LENGTH;
this._generateStreetSurface(this._lastStreetEnd, newEnd);
this._lastStreetEnd = newEnd;
}
// Update houses
for (const house of this.houses) {
house.update(delta);
}
// Spawn agents
this._agentTimer -= delta;
if (this._agentTimer <= 0) {
this._spawnAgent(playerZ);
// Decrease interval as speed increases, but clamp
const speedRatio = gameState.currentSpeed / 25;
const interval = Math.max(
AGENT.MIN_SPAWN_INTERVAL,
AGENT.SPAWN_INTERVAL * (1 - speedRatio * 0.5)
);
this._agentTimer = interval + (Math.random() - 0.5) * interval * 0.5;
}
// Update agents
for (const agent of this.agents) {
agent.update(delta, playerZ);
}
// Cleanup entities behind the player
this._cleanup(playerZ);
}
_spawnAgent(playerZ) {
// Spawn on a random lane position ahead of player
const laneX = (Math.random() - 0.5) * (STREET.WIDTH - 1);
const spawnZ = playerZ - AGENT.SPAWN_DISTANCE;
const agent = new Agent(laneX, spawnZ);
this.scene.add(agent.mesh);
this.agents.push(agent);
}
_cleanup(playerZ) {
// Remove houses far behind the player
for (let i = this.houses.length - 1; i >= 0; i--) {
if (this.houses[i].mesh.position.z > playerZ + HOUSE.CLEANUP_DISTANCE) {
this.houses[i].dispose(this.scene);
this.houses.splice(i, 1);
}
}
// Remove dead agents
for (let i = this.agents.length - 1; i >= 0; i--) {
if (!this.agents[i].alive) {
this.agents[i].dispose(this.scene);
this.agents.splice(i, 1);
}
}
// Remove street segments far behind player
for (let i = this.streetSegments.length - 1; i >= 0; i--) {
const seg = this.streetSegments[i];
if (seg.position.z > playerZ + 40) {
seg.geometry.dispose();
this.scene.remove(seg);
this.streetSegments.splice(i, 1);
}
}
}
/** Get houses within range for collision checks */
getHousesInRange(z, range) {
return this.houses.filter(h =>
!h.isHit && Math.abs(h.mesh.position.z - z) < range
);
}
/** Get agents within range for collision checks */
getAgentsInRange(z, range) {
return this.agents.filter(a =>
a.alive && !a.hasCollided && Math.abs(a.mesh.position.z - z) < range
);
}
reset() {
for (const house of this.houses) house.dispose(this.scene);
for (const agent of this.agents) agent.dispose(this.scene);
for (const seg of this.streetSegments) {
seg.geometry.dispose();
this.scene.remove(seg);
}
this.houses = [];
this.agents = [];
this.streetSegments = [];
this._generatedZ = 10;
this._lastStreetEnd = -HOUSE.SPAWN_DISTANCE - 20;
this._agentTimer = AGENT.SPAWN_INTERVAL;
this._generateInitial();
}
}
+48
View File
@@ -0,0 +1,48 @@
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
export class Menu {
constructor() {
this.gameoverOverlay = document.getElementById('gameover-overlay');
this.restartBtn = document.getElementById('restart-btn');
this.finalScoreEl = document.getElementById('final-score');
this.bestScoreEl = document.getElementById('best-score');
this.housesHitEl = document.getElementById('houses-hit');
this.bestComboEl = document.getElementById('best-combo');
this.livesEl = document.getElementById('lives-display');
this.restartBtn.addEventListener('click', () => {
this.gameoverOverlay.classList.add('hidden');
eventBus.emit(Events.GAME_RESTART);
});
eventBus.on(Events.GAME_OVER, ({ score, housesHit, bestCombo }) =>
this.showGameOver(score, housesHit, bestCombo)
);
// Update lives HUD
eventBus.on(Events.LIVES_CHANGED, ({ lives }) => this.updateLives(lives));
// Initialize lives display
this.updateLives(gameState.lives);
}
showGameOver(score, housesHit, bestCombo) {
this.finalScoreEl.textContent = `Score: ${score}`;
this.bestScoreEl.textContent = `Best: ${gameState.bestScore}`;
if (this.housesHitEl) {
this.housesHitEl.textContent = `Houses Hit: ${housesHit || 0}`;
}
if (this.bestComboEl) {
this.bestComboEl.textContent = `Best Combo: ${bestCombo || 0}x`;
}
this.gameoverOverlay.classList.remove('hidden');
}
updateLives(lives) {
if (this.livesEl) {
// Show hearts for lives
this.livesEl.textContent = '\u2764'.repeat(Math.max(0, lives));
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3008,
},
build: {
target: 'esnext',
},
});