mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
output/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3008,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user