@"/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/6c6977e7-IMG_7213.JPG" @"/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/c0180cca-IMG_7212.JPG" Build this 3D game, for the browser.
This repo is configured to serve static files so make sure there is an index.html that loads everything else.
Make sure it is mobile-friendly (touch controls, works well on small screens).
You have an OpenAI API key and access to their image generation model APIs, use that for textures to use with your 3D models. Docs here: https://developers.openai.com/api/docs/guides/image-generation - use gpt-image-2
Work independently - do not ask me to make any further design decisions. Make sure the game is fun, a little surprising, has good raccoon heist vibes, and is visually pleasing.
Commit and push as often as possible so I can preview your work - start with an index.html that presents a title screen, then build from there.
Append to a notes.md file as you work, including your changes to that as part of every commit.
{
"file_path": "/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/6c6977e7-IMG_7213.JPG"
}{
"file_path": "/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/c0180cca-IMG_7212.JPG"
}env | grep -i -E 'openai|api_key' | sed 's/=.*/=***set***/'
total 596 drwxr-xr-x 3 root root 4096 Aug 5 14:53 . drwxr-xr-x 3 root root 4096 Aug 5 14:53 .. drwxr-xr-x 8 root root 4096 Aug 5 14:53 .git -rw-r--r-- 1 root root 281874 Aug 5 14:53 IMG_7212.jpeg -rw-r--r-- 1 root root 309796 Aug 5 14:53 IMG_7213.jpeg -rw-r--r-- 1 root root 999 Aug 5 14:53 README.md 3b11a53 Images to go with prompt 21bd738 README 5a9f092 Initial commit * claude/3d-raccoon-heist-game-50n293 main remotes/origin/claude/3d-raccoon-heist-game-50n293 remotes/origin/main On branch claude/3d-raccoon-heist-game-50n293 nothing to commit, working tree clean
1 # raccoon-heist 2 3 Seeing if Fable 5 can one-shot this game 4 5 Starting prompt (plus the two initial images): 6 7 > Build this 3D game, for the browser. 8 > 9 > This repo is configured to serve static files so make sure there is an index.html that loads everything else. 10 > 11 > Make sure it is mobile-friendly (touch controls, works well on small screens). 12 > 13 > You have an OpenAI API key and access to their image generation model APIs, use that for textures to use with your 3D models. Docs here: https://developers.openai.com/api/docs/guides/image-generation - use gpt-image-2 14 > 15 > Work independently - do not ask me to make any further design decisions. Make sure the game is fun, a little surprising, has good raccoon heist vibes, and is visually pleasing. 16 > 17 > Commit and push as often as possible so I can preview your work - start with an index.html that presents a title screen, then build from there. 18 > 19 > Append to a notes.md file as you work, including your changes to that as part of every commit. 20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a1024">
<title>Raccoon Heist</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #060a18;
font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
color: #dfe8ff;
touch-action: none;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
/* ---------- Title screen ---------- */
#title-screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background:
radial-gradient(ellipse 70% 45% at 78% 16%, rgba(190, 210, 255, 0.28), transparent 60%),
radial-gradient(ellipse 120% 70% at 50% 110%, rgba(48, 118, 168, 0.35), transparent 65%),
linear-gradient(180deg, #060a18 0%, #0a1024 45%, #10203c 100%);
z-index: 50;
transition: opacity 0.8s ease;
}
#title-screen.hidden { opacity: 0; pointer-events: none; }
.moon {
position: absolute;
top: 7%; right: 12%;
width: clamp(60px, 12vmin, 110px);
height: clamp(60px, 12vmin, 110px);
border-radius: 50%;
background: radial-gradient(circle at 38% 34%, #ffffff, #cfdcff 55%, #93a8d8 100%);
box-shadow: 0 0 60px 18px rgba(200, 216, 255, 0.35);
}
.stars { position: absolute; inset: 0; overflow: hidden; }
.stars span {
position: absolute;
width: 2px; height: 2px;
background: #fff; border-radius: 50%;
opacity: 0.8;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle { 0%,100% { opacity: 0.25; } 50% { opacity: 0.95; } }
.skyline {
position: absolute; bottom: 0; left: 0; right: 0;
height: 26vh;
background:
linear-gradient(transparent 0, transparent 100%),
repeating-linear-gradient(90deg,
#050912 0 12vw, #0a1226 12vw 13vw,
#071020 13vw 26vw, #0a1226 26vw 27vw);
clip-path: polygon(0 100%, 0 46%, 8% 46%, 8% 26%, 16% 26%, 16% 58%, 25% 58%, 25% 34%, 33% 34%, 33% 12%, 42% 12%, 42% 48%, 52% 48%, 52% 30%, 61% 30%, 61% 55%, 70% 55%, 70% 20%, 79% 20%, 79% 44%, 88% 44%, 88% 32%, 100% 32%, 100% 100%);
opacity: 0.9;
}
h1.game-title {
font-size: clamp(2.6rem, 11vw, 6rem);
letter-spacing: 0.04em;
text-align: center;
line-height: 1.02;
color: #f4f7ff;
text-shadow:
0 2px 0 #1b2a52,
0 6px 24px rgba(120, 160, 255, 0.45),
0 0 60px rgba(90, 140, 255, 0.3);
z-index: 2;
}
h1 .accent { color: #ffd23f; text-shadow: 0 2px 0 #6b4b00, 0 6px 28px rgba(255, 210, 63, 0.5); }
.tagline {
margin-top: 0.9rem;
font-size: clamp(0.85rem, 3.4vw, 1.15rem);
color: #9db4e6;
letter-spacing: 0.12em;
text-transform: uppercase;
text-align: center;
z-index: 2;
padding: 0 1rem;
}
.mask {
z-index: 2;
margin-bottom: 1.2rem;
font-size: clamp(2.2rem, 9vw, 4rem);
filter: drop-shadow(0 4px 14px rgba(0,0,0,0.6));
animation: bob 3.2s ease-in-out infinite;
}
@keyframes bob { 0%,100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-10px) rotate(4deg); } }
#play-btn {
margin-top: 2.4rem;
z-index: 2;
font-family: inherit;
font-size: clamp(1.1rem, 4.5vw, 1.5rem);
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.85em 2.6em;
color: #1a1200;
background: linear-gradient(180deg, #ffe27a, #ffc31f 60%, #f0a500);
border: none; border-radius: 999px;
box-shadow: 0 6px 0 #8a5d00, 0 12px 30px rgba(255, 195, 31, 0.35);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
#play-btn:active { transform: translateY(4px); box-shadow: 0 2px 0 #8a5d00, 0 6px 18px rgba(255, 195, 31, 0.3); }
#play-btn:disabled { filter: grayscale(0.6); opacity: 0.7; }
.subtext {
margin-top: 1.1rem;
z-index: 2;
font-size: clamp(0.7rem, 2.8vw, 0.85rem);
color: #6e84b8;
text-align: center;
padding: 0 1.5rem;
line-height: 1.6;
}
#loading-status {
margin-top: 0.8rem; z-index: 2;
font-size: 0.8rem; color: #ffd23f;
min-height: 1.2em;
}
</style>
</head>
<body>
<div id="title-screen">
<div class="stars" id="stars"></div>
<div class="moon"></div>
<div class="mask">🦝</div>
<h1 class="game-title">RACCOON<br><span class="accent">HEIST</span></h1>
<p class="tagline">Grab the loot · Dodge the law · Trash is treasure</p>
<button id="play-btn">START THE HEIST</button>
<p id="loading-status"></p>
<p class="subtext">WASD / arrows to scamper · on phones, use the touch stick<br>A furry crew caper under the midnight moon</p>
<div class="skyline"></div>
</div>
<script>
// Scatter twinkling stars on the title screen.
(function () {
const wrap = document.getElementById('stars');
for (let i = 0; i < 90; i++) {
const s = document.createElement('span');
s.style.left = Math.random() * 100 + '%';
s.style.top = Math.random() * 70 + '%';
s.style.animationDelay = (Math.random() * 3).toFixed(2) + 's';
s.style.transform = `scale(${0.6 + Math.random() * 1.6})`;
wrap.appendChild(s);
}
})();
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>
File created successfully at: /home/user/raccoon-heist/index.html (file state is current in your context — no need to Read it back)
# Raccoon Heist — build notes Working log for the 3D browser game build. Newest entries at the bottom. ## 2026-08-05 — Kickoff Read the two brief images: a low-poly isometric night scene of raccoons sneaking around trash cans outside glowing suburban windows (with a gold coin on the ground), plus a product blurb: a team of thieving raccoons pulling off light-hearted heists, dodging the police, escaping with the loot. **Plan:** - Three.js game, vendored locally (no CDN dependency at runtime). - Night-time suburban backyard heist: play a raccoon, sneak around houses and fences, grab loot (coins, shinies, leftover pizza), avoid patrolling flashlight cones, dump loot in your trash-can stash to bank it. - Mobile-first: virtual joystick + action button, also WASD/arrows + mouse on desktop. - Textures generated with OpenAI `gpt-image-2` (grass, brick, fence wood, roof shingles, asphalt path, trash-can metal) baked into `textures/` as small tileable PNGs so the deployed game never needs the API at runtime. - Surprises planned: a rival seagull that swoops for dropped loot, and a "trash panda frenzy" speed boost when you eat pizza. **Commit 1:** `index.html` title screen — pure CSS night sky, moon, twinkling stars, city skyline silhouette, bobbing 🦝, START button. `js/main.js` comes next; the script tag already points at it.
File created successfully at: /home/user/raccoon-heist/notes.md (file state is current in your context — no need to Read it back)
mkdir -p /home/user/raccoon-heist/js /home/user/raccoon-heist/textures /home/user/raccoon-heist/vendor && git add index.html notes.md && git commit -m "Title screen: night sky, moon, skyline, START button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push -u origin claude/3d-raccoon-heist-game-50n293
2 files changed, 195 insertions(+) create mode 100644 index.html create mode 100644 notes.md remote: remote: Create a pull request for 'claude/3d-raccoon-heist-game-50n293' on GitHub by visiting: remote: https://github.com/simonw/raccoon-heist/pull/new/claude/3d-raccoon-heist-game-50n293 remote: To https://github.com/simonw/raccoon-heist * [new branch] claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293 branch 'claude/3d-raccoon-heist-game-50n293' set up to track 'origin/claude/3d-raccoon-heist-game-50n293'.
curl -sSL -o vendor/three.module.js https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js && ls -la vendor/ && head -c 200 vendor/three.module.js
total 684
drwxr-xr-x 2 root root 4096 Aug 5 14:55 .
drwxr-xr-x 6 root root 4096 Aug 5 14:55 ..
-rw-r--r-- 1 root root 691648 Aug 5 14:55 three.module.js
/**
* @license
* Copyright 2010-2024 Three.js Authors
* SPDX-License-Identifier: MIT
*/
const t="170",e={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},n={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:python3 -c "import PIL; print(PIL.__version__)" 2>&1; which python3
Traceback (most recent call last): File "<string>", line 1, in <module> ModuleNotFoundError: No module named 'PIL' /usr/local/bin/python3
pip install --quiet pillow 2>&1 | tail -1; python3 -c "import PIL; print('pillow', PIL.__version__)"WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv pillow 12.3.0
#!/usr/bin/env python3
"""Generate tileable game textures with OpenAI gpt-image-2 and save 512px JPEGs."""
import base64, io, json, os, sys, urllib.request
API_KEY = os.environ["OPENAI_API_KEY"]
OUT = "/home/user/raccoon-heist/textures"
STYLE = ("Seamless tileable texture, top-down flat orthographic view, no perspective, "
"even lighting, video game texture map, stylized low-poly game art style, "
"moody nighttime color palette with cool blue tones. ")
TEXTURES = {
"grass": "Dark night-time lawn grass texture, deep blue-green stylized grass blades, subtle variation.",
"brick": "House wall of painted wooden siding boards, dark slate blue-grey horizontal clapboard siding, weathered, front-on view.",
"fence": "Old wooden fence planks texture, vertical weathered brown wood boards with grain and nails, front-on view.",
"roof": "Roof shingles texture, overlapping dark grey-blue asphalt shingles rows, front-on view.",
"path": "Cracked concrete pavement texture at night, grey-blue concrete slabs with subtle cracks and stains.",
"metal": "Galvanized metal trash can surface texture, brushed dull silver metal with vertical ridges, scuffs and scratches.",
"soil": "Dark garden soil dirt texture with small pebbles and leaf litter, night time, deep browns and blues.",
}
def gen(name, prompt):
body = json.dumps({
"model": "gpt-image-2",
"prompt": STYLE + prompt,
"size": "1024x1024",
"quality": "low",
"output_format": "jpeg",
"output_compression": 80,
"n": 1,
}).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/images/generations",
data=body,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=300) as r:
data = json.load(r)
b64 = data["data"][0]["b64_json"]
raw = base64.b64decode(b64)
from PIL import Image
img = Image.open(io.BytesIO(raw)).convert("RGB")
img = img.resize((512, 512), Image.LANCZOS)
path = os.path.join(OUT, f"{name}.jpg")
img.save(path, "JPEG", quality=82)
print(f"{name}: saved {os.path.getsize(path)} bytes")
if __name__ == "__main__":
names = sys.argv[1:] or list(TEXTURES)
for n in names:
try:
gen(n, TEXTURES[n])
except Exception as e:
print(f"{n}: FAILED {e}", file=sys.stderr)
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/gen_textures.py (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && python3 gen_textures.py grass 2>&1
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && python3 gen_textures.py brick fence roof path metal soil
Command running in background with ID: bjcj63elm. Output is being written to: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/bjcj63elm.output. You will be notified when it completes. To check interim output, use Read on that file path. Session cwd remains /home/user/raccoon-heist; directory changes made by the backgrounded command do not apply to subsequent commands.
// Procedural WebAudio: sneaky bass groove + sound effects. No audio assets needed.
let ctx = null;
let musicOn = true;
let master = null;
export function initAudio() {
if (ctx) return;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
master.gain.value = 0.5;
master.connect(ctx.destination);
startMusic();
} catch (e) {
ctx = null;
}
}
export function resumeAudio() {
if (ctx && ctx.state === 'suspended') ctx.resume();
}
function env(gainNode, t, attack, decay, peak = 1) {
gainNode.gain.setValueAtTime(0.0001, t);
gainNode.gain.linearRampToValueAtTime(peak, t + attack);
gainNode.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
}
function blip(freq, t, dur, type = 'sine', vol = 0.3, slide = 0) {
if (!ctx) return;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
env(g, t, 0.008, dur, vol);
o.connect(g).connect(master);
o.start(t); o.stop(t + dur + 0.05);
}
function noiseBurst(t, dur, vol = 0.2, freq = 1200) {
if (!ctx) return;
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
const f = ctx.createBiquadFilter();
f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = 0.8;
const g = ctx.createGain();
env(g, t, 0.005, dur, vol);
src.connect(f).connect(g).connect(master);
src.start(t); src.stop(t + dur + 0.05);
}
// ---------- Sound effects ----------
export const sfx = {
coin() { if (!ctx) return; const t = ctx.currentTime; blip(950, t, 0.09, 'square', 0.16); blip(1420, t + 0.07, 0.16, 'square', 0.16); },
gem() { if (!ctx) return; const t = ctx.currentTime; blip(660, t, 0.1, 'triangle', 0.22); blip(880, t + 0.08, 0.1, 'triangle', 0.22); blip(1320, t + 0.16, 0.2, 'triangle', 0.22); },
munch() { if (!ctx) return; const t = ctx.currentTime; noiseBurst(t, 0.08, 0.3, 500); noiseBurst(t + 0.1, 0.08, 0.25, 400); },
bank() { if (!ctx) return; const t = ctx.currentTime; [523, 659, 784, 1047].forEach((f, i) => blip(f, t + i * 0.07, 0.18, 'triangle', 0.2)); },
alert() { if (!ctx) return; const t = ctx.currentTime; blip(600, t, 0.25, 'sawtooth', 0.12, 500); },
caught() { if (!ctx) return; const t = ctx.currentTime; blip(400, t, 0.3, 'sawtooth', 0.2, -200); blip(300, t + 0.25, 0.4, 'sawtooth', 0.2, -180); noiseBurst(t, 0.3, 0.15, 300); },
squawk() { if (!ctx) return; const t = ctx.currentTime; blip(1100, t, 0.12, 'sawtooth', 0.14, 500); blip(900, t + 0.14, 0.15, 'sawtooth', 0.13, -300); },
frenzy() { if (!ctx) return; const t = ctx.currentTime; [440, 554, 659, 880, 1109].forEach((f, i) => blip(f, t + i * 0.05, 0.12, 'square', 0.14)); },
dash() { if (!ctx) return; noiseBurst(ctx.currentTime, 0.15, 0.18, 2000); },
win() { if (!ctx) return; const t = ctx.currentTime; [523, 659, 784, 1047, 784, 1047, 1319].forEach((f, i) => blip(f, t + i * 0.12, 0.25, 'triangle', 0.22)); },
lose() { if (!ctx) return; const t = ctx.currentTime; [392, 370, 349, 330].forEach((f, i) => blip(f, t + i * 0.22, 0.35, 'triangle', 0.2)); },
meow() { if (!ctx) return; const t = ctx.currentTime; blip(700, t, 0.25, 'sawtooth', 0.1, 250); },
};
// ---------- Sneaky bass groove ----------
// A little walking-bass jazz loop with brushed-hat noise. Scheduled bar by bar.
const BASS = [110, 0, 131, 110, 98, 0, 110, 131, 87, 0, 104, 87, 98, 0, 110, 98];
let step = 0, nextNoteTime = 0, schedTimer = null;
const TEMPO = 96;
function scheduleLoop() {
if (!ctx || !musicOn) return;
const stepDur = (60 / TEMPO) / 2; // eighth notes
while (nextNoteTime < ctx.currentTime + 0.3) {
const f = BASS[step % BASS.length];
if (f) {
const t = nextNoteTime;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'triangle';
o.frequency.value = f;
env(g, t, 0.01, stepDur * 0.9, 0.16);
o.connect(g).connect(master);
o.start(t); o.stop(t + stepDur);
}
// brushed hats on off-beats
if (step % 2 === 1) noiseBurst(nextNoteTime, 0.04, 0.03, 6000);
step++;
nextNoteTime += stepDur;
}
schedTimer = setTimeout(scheduleLoop, 120);
}
function startMusic() {
if (!ctx) return;
nextNoteTime = ctx.currentTime + 0.1;
step = 0;
scheduleLoop();
}
export function toggleMusic() {
musicOn = !musicOn;
if (musicOn && ctx) { nextNoteTime = ctx.currentTime + 0.1; scheduleLoop(); }
else if (schedTimer) clearTimeout(schedTimer);
return musicOn;
}
File created successfully at: /home/user/raccoon-heist/js/audio.js (file state is current in your context — no need to Read it back)
// Input: keyboard (WASD/arrows + Space dash) and a dynamic touch joystick
// that appears wherever the player touches the left 60% of the screen,
// plus a dash button on the right for touch devices.
export class Controls {
constructor() {
this.moveX = 0; // -1..1
this.moveZ = 0; // -1..1
this.dashPressed = false;
this._keys = new Set();
this._joyActive = false;
this._joyId = null;
this._joyOrigin = { x: 0, y: 0 };
this._joyVec = { x: 0, y: 0 };
this.isTouch = false;
window.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
this._keys.add(e.key.toLowerCase());
if (e.key === ' ') this.dashPressed = true;
});
window.addEventListener('keyup', (e) => this._keys.delete(e.key.toLowerCase()));
window.addEventListener('blur', () => this._keys.clear());
this._buildTouchUI();
}
_buildTouchUI() {
// Joystick visuals
const ring = document.createElement('div');
ring.id = 'joy-ring';
ring.style.cssText = 'position:fixed;width:110px;height:110px;border-radius:50%;border:3px solid rgba(255,255,255,0.35);background:rgba(255,255,255,0.06);display:none;z-index:40;pointer-events:none;transform:translate(-50%,-50%);backdrop-filter:blur(2px);';
const nub = document.createElement('div');
nub.id = 'joy-nub';
nub.style.cssText = 'position:fixed;width:52px;height:52px;border-radius:50%;background:rgba(255,210,63,0.85);box-shadow:0 2px 10px rgba(0,0,0,0.4);display:none;z-index:41;pointer-events:none;transform:translate(-50%,-50%);';
document.body.appendChild(ring);
document.body.appendChild(nub);
this._ring = ring;
this._nub = nub;
// Dash button (shown once touch is detected)
const dash = document.createElement('div');
dash.id = 'dash-btn';
dash.textContent = 'DASH';
dash.style.cssText = 'position:fixed;right:max(18px, env(safe-area-inset-right));bottom:max(26px, env(safe-area-inset-bottom));width:76px;height:76px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #ffe27a, #f0a500);color:#3a2800;font:700 15px/76px sans-serif;text-align:center;z-index:40;display:none;box-shadow:0 4px 0 #8a5d00, 0 8px 18px rgba(0,0,0,0.5);letter-spacing:0.05em;touch-action:none;';
document.body.appendChild(dash);
this._dashBtn = dash;
dash.addEventListener('touchstart', (e) => { e.preventDefault(); this.dashPressed = true; dash.style.transform = 'scale(0.92)'; }, { passive: false });
dash.addEventListener('touchend', () => { dash.style.transform = ''; });
const touchArea = document.body;
touchArea.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: false });
touchArea.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false });
touchArea.addEventListener('touchend', (e) => this._onTouchEnd(e), { passive: false });
touchArea.addEventListener('touchcancel', (e) => this._onTouchEnd(e), { passive: false });
}
_onTouchStart(e) {
this.isTouch = true;
this._dashBtn.style.display = 'block';
for (const t of e.changedTouches) {
if (this._joyActive) continue;
// ignore touches on UI elements (dash button, overlays with buttons)
const el = document.elementFromPoint(t.clientX, t.clientY);
if (el && (el.closest('#dash-btn') || el.closest('button') || el.closest('.overlay'))) continue;
if (t.clientX > window.innerWidth * 0.62) continue;
e.preventDefault();
this._joyActive = true;
this._joyId = t.identifier;
this._joyOrigin = { x: t.clientX, y: t.clientY };
this._joyVec = { x: 0, y: 0 };
this._ring.style.display = 'block';
this._nub.style.display = 'block';
this._ring.style.left = t.clientX + 'px';
this._ring.style.top = t.clientY + 'px';
this._nub.style.left = t.clientX + 'px';
this._nub.style.top = t.clientY + 'px';
}
}
_onTouchMove(e) {
if (!this._joyActive) return;
for (const t of e.changedTouches) {
if (t.identifier !== this._joyId) continue;
e.preventDefault();
const dx = t.clientX - this._joyOrigin.x;
const dy = t.clientY - this._joyOrigin.y;
const max = 48;
const len = Math.hypot(dx, dy) || 1;
const cl = Math.min(len, max);
const nx = (dx / len) * cl, ny = (dy / len) * cl;
this._joyVec = { x: nx / max, y: ny / max };
this._nub.style.left = (this._joyOrigin.x + nx) + 'px';
this._nub.style.top = (this._joyOrigin.y + ny) + 'px';
}
}
_onTouchEnd(e) {
for (const t of e.changedTouches) {
if (t.identifier === this._joyId) {
this._joyActive = false;
this._joyId = null;
this._joyVec = { x: 0, y: 0 };
this._ring.style.display = 'none';
this._nub.style.display = 'none';
}
}
}
update() {
let x = 0, z = 0;
const k = this._keys;
if (k.has('a') || k.has('arrowleft')) x -= 1;
if (k.has('d') || k.has('arrowright')) x += 1;
if (k.has('w') || k.has('arrowup')) z -= 1;
if (k.has('s') || k.has('arrowdown')) z += 1;
if (this._joyActive) { x = this._joyVec.x; z = this._joyVec.y; }
const len = Math.hypot(x, z);
if (len > 1) { x /= len; z /= len; }
this.moveX = x;
this.moveZ = z;
}
consumeDash() {
const d = this.dashPressed;
this.dashPressed = false;
return d;
}
}
File created successfully at: /home/user/raccoon-heist/js/controls.js (file state is current in your context — no need to Read it back)
// Procedural low-poly characters: raccoon, security guard, police car, seagull.
// Each builder returns a THREE.Group plus an animate(t, moveSpeed) hook.
import * as THREE from '../vendor/three.module.js';
const M = (color, opts = {}) => new THREE.MeshLambertMaterial({ color, ...opts });
const GREY = 0x8a8f9c, DARKGREY = 0x3c4048, LIGHT = 0xe8e4da, BLACK = 0x17181d;
export function makeRaccoon({ scale = 1, shirt = null } = {}) {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 10), M(GREY));
body.scale.set(1, 0.85, 1.25);
body.position.y = 0.62;
body.castShadow = true;
g.add(body);
if (shirt) {
const vest = new THREE.Mesh(new THREE.SphereGeometry(0.57, 12, 10), M(shirt));
vest.scale.set(1, 0.72, 1.1);
vest.position.set(0, 0.6, 0.12);
g.add(vest);
}
const head = new THREE.Group();
head.position.set(0, 1.15, 0.55);
g.add(head);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.38, 12, 10), M(GREY));
skull.castShadow = true;
head.add(skull);
// bandit mask
const mask = new THREE.Mesh(new THREE.SphereGeometry(0.385, 12, 8, 0, Math.PI * 2, Math.PI * 0.32, Math.PI * 0.3), M(BLACK));
mask.rotation.x = -0.35;
head.add(mask);
// muzzle
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), M(LIGHT));
muzzle.scale.set(1, 0.8, 1.1);
muzzle.position.set(0, -0.12, 0.28);
head.add(muzzle);
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), M(BLACK));
nose.position.set(0, -0.08, 0.46);
head.add(nose);
// eyes
for (const s of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), M(0xffffff, { emissive: 0x888888 }));
eye.position.set(0.15 * s, 0.06, 0.32);
head.add(eye);
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.22, 6), M(DARKGREY));
ear.position.set(0.24 * s, 0.34, -0.05);
ear.rotation.z = -0.25 * s;
head.add(ear);
}
// striped tail — alternating rings, curved upward
const tail = new THREE.Group();
tail.position.set(0, 0.62, -0.6);
for (let i = 0; i < 6; i++) {
const seg = new THREE.Mesh(new THREE.SphereGeometry(0.19 - i * 0.02, 8, 6), M(i % 2 ? BLACK : GREY));
seg.position.set(0, Math.sin(i * 0.38) * 0.5, -i * 0.19);
seg.castShadow = i < 3;
tail.add(seg);
}
g.add(tail);
// legs
const legs = [];
const legGeo = new THREE.CylinderGeometry(0.09, 0.11, 0.38, 6);
for (const [x, z] of [[-0.3, 0.35], [0.3, 0.35], [-0.3, -0.35], [0.3, -0.35]]) {
const leg = new THREE.Mesh(legGeo, M(DARKGREY));
leg.position.set(x, 0.2, z);
g.add(leg);
legs.push(leg);
}
// loot backpack anchor (banked visuals get stacked here)
const lootAnchor = new THREE.Group();
lootAnchor.position.set(0, 1.05, -0.15);
g.add(lootAnchor);
g.scale.setScalar(scale);
let phase = Math.random() * 10;
return {
group: g,
lootAnchor,
head,
tail,
animate(dt, speed) {
phase += dt * (4 + speed * 9);
const amp = Math.min(0.55, 0.12 + speed * 0.5);
legs[0].rotation.x = Math.sin(phase) * amp;
legs[3].rotation.x = Math.sin(phase) * amp;
legs[1].rotation.x = -Math.sin(phase) * amp;
legs[2].rotation.x = -Math.sin(phase) * amp;
body.position.y = 0.62 + Math.abs(Math.sin(phase)) * 0.05 * (0.3 + speed);
tail.rotation.x = -0.2 + Math.sin(phase * 0.5) * 0.15;
tail.rotation.z = Math.sin(phase * 0.7) * 0.12;
head.rotation.y = Math.sin(phase * 0.3) * 0.08;
},
};
}
export function makeGuard() {
const g = new THREE.Group();
const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.5, 1.15, 10), M(0x2b3a5e));
torso.position.y = 1.0;
torso.castShadow = true;
g.add(torso);
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), M(0x2b3a5e));
belly.position.set(0, 0.85, 0.08);
g.add(belly);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), M(0xd9a87c));
head.position.y = 1.85;
head.castShadow = true;
g.add(head);
const capTop = new THREE.Mesh(new THREE.CylinderGeometry(0.31, 0.33, 0.16, 10), M(0x1c2740));
capTop.position.y = 2.05;
g.add(capTop);
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.05, 10, 1, false, 0, Math.PI), M(0x1c2740));
brim.position.set(0, 1.98, 0.18);
g.add(brim);
const armGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.7, 6);
const armL = new THREE.Mesh(armGeo, M(0x2b3a5e));
armL.position.set(-0.55, 1.25, 0);
armL.rotation.z = 0.25;
g.add(armL);
// right arm holds the flashlight forward
const armR = new THREE.Mesh(armGeo, M(0x2b3a5e));
armR.position.set(0.5, 1.35, 0.3);
armR.rotation.x = -1.2;
g.add(armR);
const torch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 0.35, 8), M(0x222831));
torch.position.set(0.5, 1.45, 0.72);
torch.rotation.x = Math.PI / 2;
g.add(torch);
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.03, 8), M(0xfff2b0, { emissive: 0xffe89a }));
lens.position.set(0.5, 1.45, 0.9);
lens.rotation.x = Math.PI / 2;
g.add(lens);
const legGeo = new THREE.CylinderGeometry(0.13, 0.15, 0.5, 6);
const legs = [];
for (const s of [-1, 1]) {
const leg = new THREE.Mesh(legGeo, M(0x1c2740));
leg.position.set(0.2 * s, 0.25, 0);
g.add(leg);
legs.push(leg);
}
let phase = Math.random() * 10;
return {
group: g,
torchTip: new THREE.Vector3(0.5, 1.45, 0.9),
animate(dt, speed) {
phase += dt * (2 + speed * 6);
const amp = Math.min(0.5, speed * 0.9);
legs[0].rotation.x = Math.sin(phase) * amp;
legs[1].rotation.x = -Math.sin(phase) * amp;
armL.rotation.x = -Math.sin(phase) * amp * 0.7;
g.position.y = Math.abs(Math.sin(phase)) * 0.04 * speed;
},
};
}
export function makePoliceCar() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.62, 4.6), M(0x1a2233));
body.position.y = 0.62;
body.castShadow = true;
g.add(body);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(2.12, 0.2, 4.62), M(0xdfe6f2));
stripe.position.y = 0.68;
g.add(stripe);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.6, 2.1), M(0x9db8d8, { transparent: true, opacity: 0.9 }));
cabin.position.set(0, 1.2, -0.2);
cabin.castShadow = true;
g.add(cabin);
const wheelGeo = new THREE.CylinderGeometry(0.42, 0.42, 0.3, 10);
for (const [x, z] of [[-1, 1.5], [1, 1.5], [-1, -1.5], [1, -1.5]]) {
const w = new THREE.Mesh(wheelGeo, M(0x111318));
w.rotation.z = Math.PI / 2;
w.position.set(x, 0.42, z);
g.add(w);
}
// light bar
const barBase = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 0.4), M(0x222833));
barBase.position.set(0, 1.56, -0.2);
g.add(barBase);
const redMat = new THREE.MeshLambertMaterial({ color: 0xff2244, emissive: 0xff2244, emissiveIntensity: 1 });
const blueMat = new THREE.MeshLambertMaterial({ color: 0x2266ff, emissive: 0x2266ff, emissiveIntensity: 1 });
const red = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.36), redMat);
red.position.set(-0.3, 1.72, -0.2);
g.add(red);
const blue = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.36), blueMat);
blue.position.set(0.3, 1.72, -0.2);
g.add(blue);
// headlights
const hlMat = new THREE.MeshLambertMaterial({ color: 0xfff6cc, emissive: 0xfff0aa, emissiveIntensity: 0.9 });
for (const s of [-1, 1]) {
const hl = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.16, 0.08), hlMat);
hl.position.set(0.62 * s, 0.62, 2.32);
g.add(hl);
}
const redLight = new THREE.PointLight(0xff2244, 0, 14);
redLight.position.set(0, 2.2, -0.2);
g.add(redLight);
const blueLight = new THREE.PointLight(0x2266ff, 0, 14);
blueLight.position.set(0, 2.2, -0.2);
g.add(blueLight);
let t = 0;
return {
group: g,
animate(dt) {
t += dt;
const flash = Math.sin(t * 10) > 0;
redMat.emissiveIntensity = flash ? 1.6 : 0.15;
blueMat.emissiveIntensity = flash ? 0.15 : 1.6;
redLight.intensity = flash ? 2.2 : 0;
blueLight.intensity = flash ? 0 : 2.2;
},
};
}
export function makeSeagull() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.32, 10, 8), M(0xf2f4f8));
body.scale.set(1, 0.8, 1.5);
g.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), M(0xf2f4f8));
head.position.set(0, 0.22, 0.42);
g.add(head);
const beak = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 6), M(0xf7a922));
beak.rotation.x = Math.PI / 2;
beak.position.set(0, 0.2, 0.62);
g.add(beak);
const wingGeo = new THREE.BoxGeometry(0.9, 0.04, 0.42);
const wings = [];
for (const s of [-1, 1]) {
const wing = new THREE.Mesh(wingGeo, M(0xd8dde6));
wing.position.set(0.5 * s, 0.1, 0);
wing.geometry = wingGeo;
g.add(wing);
wings.push(wing);
}
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.04, 0.35), M(0xd8dde6));
tail.position.set(0, 0.05, -0.5);
g.add(tail);
let phase = 0;
return {
group: g,
animate(dt, flying = 1) {
phase += dt * 14 * flying;
wings[0].rotation.z = Math.sin(phase) * 0.7;
wings[1].rotation.z = -Math.sin(phase) * 0.7;
},
};
}
// Small loot meshes. kind: coin | gem | pizza | fish | donut | ring
export function makeLoot(kind) {
const g = new THREE.Group();
let mesh;
switch (kind) {
case 'gem': {
mesh = new THREE.Mesh(new THREE.OctahedronGeometry(0.32), new THREE.MeshLambertMaterial({ color: 0x35e0c0, emissive: 0x1a8f78, emissiveIntensity: 0.7 }));
mesh.position.y = 0.38;
break;
}
case 'pizza': {
mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.08, 3), new THREE.MeshLambertMaterial({ color: 0xf5b840, emissive: 0x7a4a00, emissiveIntensity: 0.25 }));
mesh.position.y = 0.1;
const pep = new THREE.MeshLambertMaterial({ color: 0xc0392b });
for (let i = 0; i < 3; i++) {
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.04, 8), pep);
p.position.set(Math.sin(i * 2.1) * 0.15, 0.16, Math.cos(i * 2.1) * 0.15);
g.add(p);
}
break;
}
case 'fish': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(0.26, 8, 6), new THREE.MeshLambertMaterial({ color: 0x7fb2d8, emissive: 0x2a5a7a, emissiveIntensity: 0.3 }));
mesh.scale.set(0.7, 0.7, 1.5);
mesh.position.y = 0.2;
const fin = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.28, 4), new THREE.MeshLambertMaterial({ color: 0x6aa0c8 }));
fin.rotation.x = -Math.PI / 2;
fin.position.set(0, 0.2, -0.45);
g.add(fin);
break;
}
case 'donut': {
mesh = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.11, 8, 14), new THREE.MeshLambertMaterial({ color: 0xe88ab0, emissive: 0x8a3055, emissiveIntensity: 0.3 }));
mesh.rotation.x = Math.PI / 2;
mesh.position.y = 0.14;
break;
}
case 'ring': {
mesh = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.06, 8, 14), new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0xb8860b, emissiveIntensity: 0.8 }));
mesh.position.y = 0.26;
const stone = new THREE.Mesh(new THREE.OctahedronGeometry(0.1), new THREE.MeshLambertMaterial({ color: 0xff5f9e, emissive: 0xaa2255, emissiveIntensity: 0.8 }));
stone.position.set(0, 0.5, 0);
g.add(stone);
break;
}
default: { // coin
mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 0.09, 14), new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0xcc9900, emissiveIntensity: 0.75 }));
mesh.position.y = 0.3;
mesh.rotation.x = Math.PI / 2;
}
}
g.add(mesh);
return g;
}
export const LOOT_TYPES = {
coin: { value: 10, label: 'coin', weight: 5 },
gem: { value: 30, label: 'gem', weight: 2 },
donut: { value: 15, label: 'donut', weight: 3 },
fish: { value: 20, label: 'fish', weight: 3 },
ring: { value: 50, label: 'ring', weight: 1 },
pizza: { value: 15, label: 'pizza', weight: 2 }, // also triggers FRENZY
};
File created successfully at: /home/user/raccoon-heist/js/actors.js (file state is current in your context — no need to Read it back)
cat /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/bjcj63elm.output && ls -la textures/
brick: saved 34431 bytes fence: saved 36704 bytes roof: saved 32247 bytes path: saved 33929 bytes metal: saved 42649 bytes soil: saved 41117 bytes total 284 drwxr-xr-x 2 root root 4096 Aug 5 14:58 . drwxr-xr-x 6 root root 4096 Aug 5 14:55 .. -rw-r--r-- 1 root root 34431 Aug 5 14:56 brick.jpg -rw-r--r-- 1 root root 36704 Aug 5 14:56 fence.jpg -rw-r--r-- 1 root root 46352 Aug 5 14:56 grass.jpg -rw-r--r-- 1 root root 42649 Aug 5 14:57 metal.jpg -rw-r--r-- 1 root root 33929 Aug 5 14:57 path.jpg -rw-r--r-- 1 root root 32247 Aug 5 14:57 roof.jpg -rw-r--r-- 1 root root 41117 Aug 5 14:58 soil.jpg
// Builds the night-time suburban heist map: ground, road, houses with glowing
// windows, fences, trash cans, dumpster hideout, trees. Returns collision
// obstacles, line-of-sight blockers, and gameplay anchor points.
import * as THREE from '../vendor/three.module.js';
const texLoader = new THREE.TextureLoader();
function loadTex(name, repeatX, repeatY, fallbackColor) {
return new Promise((resolve) => {
texLoader.load(
`textures/${name}.jpg`,
(t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(repeatX, repeatY);
t.colorSpace = THREE.SRGBColorSpace;
resolve(t);
},
undefined,
() => resolve(null) // fall back to flat color material
);
});
}
function mat(tex, fallbackColor, opts = {}) {
return tex
? new THREE.MeshLambertMaterial({ map: tex, ...opts })
: new THREE.MeshLambertMaterial({ color: fallbackColor, ...opts });
}
export const BOUNDS = { minX: -42, maxX: 42, minZ: -30, maxZ: 32 };
export async function buildWorld(scene) {
const [grassT, pathT, brickT, fenceT, roofT, metalT, soilT] = await Promise.all([
loadTex('grass', 12, 10), loadTex('path', 10, 2), loadTex('brick', 3, 1.4),
loadTex('fence', 2, 1), loadTex('roof', 3, 2), loadTex('metal', 1, 1),
loadTex('soil', 2, 2),
]);
const obstacles = []; // {type:'box',minX,maxX,minZ,maxZ} or {type:'circle',x,z,r}
const losBlockers = []; // subset of boxes that block guard sight (houses)
const windows = []; // {mesh, light, pos:Vector3, dir:Vector3}
// ---------- Ground ----------
const ground = new THREE.Mesh(new THREE.PlaneGeometry(96, 76), mat(grassT, 0x11302e));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Street strip where the police car patrols
const road = new THREE.Mesh(new THREE.PlaneGeometry(96, 8.4), mat(pathT, 0x2a3140));
road.rotation.x = -Math.PI / 2;
road.position.set(0, 0.02, -13);
road.receiveShadow = true;
scene.add(road);
// Sidewalk-ish soil patches for variety
for (const [x, z, w, h] of [[-24, 8, 10, 7], [18, 20, 12, 8], [-10, 26, 8, 6]]) {
const patch = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat(soilT, 0x201812));
patch.rotation.x = -Math.PI / 2;
patch.rotation.z = Math.random();
patch.position.set(x, 0.015, z);
patch.receiveShadow = true;
scene.add(patch);
}
// ---------- Houses (along the back, fronts facing the street) ----------
const houseDefs = [
{ x: -28, z: -25, w: 14, d: 9 },
{ x: 0, z: -25, w: 16, d: 9 },
{ x: 28, z: -25, w: 14, d: 9 },
];
const wallMat = mat(brickT, 0x2e3950);
const roofMat = mat(roofT, 0x27314a);
const winMatOn = new THREE.MeshLambertMaterial({ color: 0xffc75f, emissive: 0xffb347, emissiveIntensity: 1.1 });
const winMatDim = new THREE.MeshLambertMaterial({ color: 0x24334f, emissive: 0x101b30, emissiveIntensity: 0.4 });
for (const h of houseDefs) {
const wallH = 6;
const walls = new THREE.Mesh(new THREE.BoxGeometry(h.w, wallH, h.d), wallMat);
walls.position.set(h.x, wallH / 2, h.z);
walls.castShadow = true;
walls.receiveShadow = true;
scene.add(walls);
// simple prism roof
const roofGeo = new THREE.CylinderGeometry(0.01, (h.w / 2) * 1.16, 3.2, 4, 1);
const roof = new THREE.Mesh(roofGeo, roofMat);
roof.rotation.y = Math.PI / 4;
roof.scale.z = (h.d + 1.6) / (h.w * 1.16);
roof.position.set(h.x, wallH + 1.6, h.z);
roof.castShadow = true;
scene.add(roof);
// chimney
const chim = new THREE.Mesh(new THREE.BoxGeometry(1, 2.4, 1), wallMat);
chim.position.set(h.x + h.w * 0.28, wallH + 2.2, h.z - 1);
scene.add(chim);
// front windows + door (facing +z, toward the yard/street)
const faceZ = h.z + h.d / 2 + 0.06;
const winCount = Math.floor(h.w / 5);
for (let i = 0; i < winCount; i++) {
const wx = h.x - h.w / 2 + (i + 0.75) * (h.w / (winCount + 0.5));
const lit = Math.random() < 0.5;
const win = new THREE.Mesh(new THREE.PlaneGeometry(2, 1.7), (lit ? winMatOn : winMatDim).clone());
win.position.set(wx, 3.1, faceZ);
scene.add(win);
const frame = new THREE.Mesh(new THREE.PlaneGeometry(2.35, 2.05), new THREE.MeshLambertMaterial({ color: 0x161d2e }));
frame.position.set(wx, 3.1, faceZ - 0.03);
scene.add(frame);
let light = null;
if (lit) {
light = new THREE.PointLight(0xffb347, 5, 12, 1.6);
light.position.set(wx, 3.0, faceZ + 1.2);
scene.add(light);
}
windows.push({ mesh: win, light, lit, pos: new THREE.Vector3(wx, 1, faceZ + 0.5), dir: new THREE.Vector3(0, 0, 1) });
}
const door = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 3), new THREE.MeshLambertMaterial({ color: 0x3a2a1c }));
door.position.set(h.x, 1.5, faceZ);
scene.add(door);
const box = { type: 'box', minX: h.x - h.w / 2, maxX: h.x + h.w / 2, minZ: h.z - h.d / 2, maxZ: h.z + h.d / 2 };
obstacles.push(box);
losBlockers.push(box);
}
// ---------- Perimeter + yard fences ----------
const fenceMat = mat(fenceT, 0x4a3826);
const addFence = (x, z, len, horizontal) => {
const geo = new THREE.BoxGeometry(horizontal ? len : 0.3, 1.9, horizontal ? 0.3 : len);
const f = new THREE.Mesh(geo, fenceMat);
f.position.set(x, 0.95, z);
f.castShadow = true;
f.receiveShadow = true;
scene.add(f);
if (horizontal) obstacles.push({ type: 'box', minX: x - len / 2, maxX: x + len / 2, minZ: z - 0.35, maxZ: z + 0.35 });
else obstacles.push({ type: 'box', minX: x - 0.35, maxX: x + 0.35, minZ: z - len / 2, maxZ: z + len / 2 });
};
// perimeter (with the map bounds as backup clamp)
addFence(0, BOUNDS.maxZ + 0.5, 88, true);
addFence(BOUNDS.minX - 0.5, 1, 64, false);
addFence(BOUNDS.maxX + 0.5, 1, 64, false);
// yard dividers with sneak gaps
addFence(-14, 14, 0.3 ? 16 : 16, false); // vertical divider left
addFence(-14, 30, 4, false);
addFence(14, 10, 12, false);
addFence(14, 28, 6, false);
addFence(-2, 4, 16, true);
addFence(26, 4, 12, true);
addFence(-30, 18, 12, true);
// ---------- Trash cans ----------
const canMat = mat(metalT, 0x8a919c);
const lidMat = mat(metalT, 0x767d88);
const canAt = (x, z, tipped = false) => {
const can = new THREE.Group();
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.7, 1.9, 12), canMat);
body.position.y = 0.95;
body.castShadow = true;
can.add(body);
const lid = new THREE.Mesh(new THREE.CylinderGeometry(0.95, 0.95, 0.22, 12), lidMat);
lid.position.y = 2.0;
can.add(lid);
const knob = new THREE.Mesh(new THREE.SphereGeometry(0.14, 8, 6), lidMat);
knob.position.y = 2.18;
can.add(knob);
if (tipped) {
can.rotation.z = Math.PI / 2 - 0.15;
can.position.set(x, -0.1, z);
} else {
can.position.set(x, 0, z);
}
can.rotation.y = Math.random() * Math.PI;
scene.add(can);
obstacles.push({ type: 'circle', x, z, r: 1.0 });
};
canAt(-21, -6); canAt(-19, -5.4); canAt(8, -6, true); canAt(35, -5);
canAt(-36, 12); canAt(20, 16); canAt(2, 24, true); canAt(33, 26);
// ---------- Dumpster hideout (bank your loot here) ----------
const hideout = new THREE.Group();
const dump = new THREE.Mesh(new THREE.BoxGeometry(5, 2.4, 2.6), mat(metalT, 0x2f6b4f));
dump.position.y = 1.3;
dump.castShadow = true;
hideout.add(dump);
// green paint tint over metal texture
dump.material = dump.material.clone();
dump.material.color = new THREE.Color(0x59a37a);
const lid1 = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.18, 2.7), mat(metalT, 0x3a7a5c));
lid1.material = lid1.material.clone();
lid1.material.color = new THREE.Color(0x4c8f6b);
lid1.position.set(-1.25, 2.52, 0);
hideout.add(lid1);
const lid2 = lid1.clone();
lid2.position.x = 1.25;
lid2.rotation.x = -0.9; // propped open — that's the door
lid2.position.y = 2.9;
lid2.position.z = -0.8;
hideout.add(lid2);
// glowing eyes peeking from the open lid
for (const s of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 6), new THREE.MeshBasicMaterial({ color: 0xffe9a0 }));
eye.position.set(1.25 + s * 0.22, 2.1, -0.4);
hideout.add(eye);
}
const hideoutPos = new THREE.Vector3(0, 0, 30);
hideout.position.copy(hideoutPos);
scene.add(hideout);
obstacles.push({ type: 'box', minX: -2.7, maxX: 2.7, minZ: 28.6, maxZ: 31.4 });
// ---------- Trees & bushes ----------
const trunkMat = mat(fenceT, 0x3a2c1e);
const leafMat = new THREE.MeshLambertMaterial({ color: 0x1d4d3a });
const leafMat2 = new THREE.MeshLambertMaterial({ color: 0x173f45 });
const treeAt = (x, z, s = 1) => {
const tr = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.35 * s, 0.5 * s, 3 * s, 7), trunkMat);
trunk.position.y = 1.5 * s;
trunk.castShadow = true;
tr.add(trunk);
for (let i = 0; i < 3; i++) {
const blob = new THREE.Mesh(new THREE.SphereGeometry((1.7 - i * 0.35) * s, 8, 7), i % 2 ? leafMat : leafMat2);
blob.position.set(Math.sin(i * 2.4) * 0.7 * s, (3.4 + i * 1.0) * s, Math.cos(i * 2.4) * 0.7 * s);
blob.castShadow = i === 0;
tr.add(blob);
}
tr.position.set(x, 0, z);
scene.add(tr);
obstacles.push({ type: 'circle', x, z, r: 0.7 * s });
};
treeAt(-38, 24, 1.2); treeAt(38, 8, 1.0); treeAt(-24, 2, 0.9); treeAt(30, 20, 1.3); treeAt(-6, 16, 1.1); treeAt(40, 30, 0.9);
const bushAt = (x, z, s = 1) => {
const b = new THREE.Mesh(new THREE.SphereGeometry(0.9 * s, 8, 6), Math.random() < 0.5 ? leafMat : leafMat2);
b.position.set(x, 0.5 * s, z);
b.scale.y = 0.75;
b.castShadow = true;
scene.add(b);
obstacles.push({ type: 'circle', x, z, r: 0.75 * s });
};
bushAt(-32, 6); bushAt(12, 2); bushAt(-8, 28); bushAt(24, 30); bushAt(-40, 16); bushAt(6, 12); bushAt(36, 14); bushAt(-18, 22);
// ---------- Sky, moon, lighting ----------
scene.background = new THREE.Color(0x0a1024);
scene.fog = new THREE.Fog(0x0a1024, 40, 95);
const moonGroup = new THREE.Group();
const moon = new THREE.Mesh(new THREE.SphereGeometry(3.4, 16, 14), new THREE.MeshBasicMaterial({ color: 0xeef2ff, fog: false }));
moon.position.set(30, 38, -55);
moonGroup.add(moon);
const moonHalo = new THREE.Mesh(new THREE.SphereGeometry(4.6, 16, 14), new THREE.MeshBasicMaterial({ color: 0xaabbee, transparent: true, opacity: 0.22, fog: false }));
moonHalo.position.copy(moon.position);
moonGroup.add(moonHalo);
scene.add(moonGroup);
// stars
const starGeo = new THREE.BufferGeometry();
const starPos = [];
for (let i = 0; i < 260; i++) {
const a = Math.random() * Math.PI * 2;
const r = 70 + Math.random() * 60;
starPos.push(Math.cos(a) * r, 22 + Math.random() * 70, -40 - Math.random() * 80);
}
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3));
const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xcfd8ff, size: 0.5, fog: false }));
scene.add(stars);
const ambient = new THREE.AmbientLight(0x3a4a78, 0.85);
scene.add(ambient);
const moonLight = new THREE.DirectionalLight(0x9db8ff, 0.9);
moonLight.position.set(28, 42, -30);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(1024, 1024);
moonLight.shadow.camera.left = -50;
moonLight.shadow.camera.right = 50;
moonLight.shadow.camera.top = 45;
moonLight.shadow.camera.bottom = -45;
moonLight.shadow.camera.far = 120;
moonLight.shadow.bias = -0.002;
scene.add(moonLight);
const hemi = new THREE.HemisphereLight(0x24365e, 0x0c1220, 0.5);
scene.add(hemi);
// fireflies
const ffGeo = new THREE.BufferGeometry();
const ffPos = [];
for (let i = 0; i < 60; i++) {
ffPos.push(BOUNDS.minX + Math.random() * (BOUNDS.maxX - BOUNDS.minX), 0.5 + Math.random() * 2.2, -6 + Math.random() * (BOUNDS.maxZ + 6));
}
ffGeo.setAttribute('position', new THREE.Float32BufferAttribute(ffPos, 3));
const fireflies = new THREE.Points(ffGeo, new THREE.PointsMaterial({ color: 0xd8ffa0, size: 0.28, transparent: true, opacity: 0.9 }));
scene.add(fireflies);
return {
obstacles,
losBlockers,
windows,
hideoutPos,
ambient,
moonLight,
hemi,
fireflies,
// guard patrol routes (clockwise-ish around the yards)
patrolRoutes: [
[new THREE.Vector3(-30, 0, -6), new THREE.Vector3(-30, 0, 26), new THREE.Vector3(-8, 0, 22), new THREE.Vector3(-6, 0, -4)],
[new THREE.Vector3(30, 0, -4), new THREE.Vector3(34, 0, 24), new THREE.Vector3(8, 0, 20), new THREE.Vector3(6, 0, -6)],
[new THREE.Vector3(-20, 0, 10), new THREE.Vector3(20, 0, 10), new THREE.Vector3(22, 0, 28), new THREE.Vector3(-22, 0, 28)],
],
roadZ: -13,
};
}
File created successfully at: /home/user/raccoon-heist/js/world.js (file state is current in your context — no need to Read it back)
// Raccoon Heist — main game. Boots Three.js after the title screen,
// runs the night-heist loop: sneak, grab loot, dodge the law, bank it.
import * as THREE from '../vendor/three.module.js';
import { buildWorld, BOUNDS } from './world.js';
import { makeRaccoon, makeGuard, makePoliceCar, makeSeagull, makeLoot, LOOT_TYPES } from './actors.js';
import { Controls } from './controls.js';
import { initAudio, resumeAudio, sfx, toggleMusic } from './audio.js';
const NIGHT_LEN = 150; // seconds per night
const PLAYER_RADIUS = 0.55;
const MAX_CARRY = 8;
// ---------------------------------------------------------------- HUD & CSS
const css = document.createElement('style');
css.textContent = `
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 30; display: none;
font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif; }
.hud-chip { position: fixed; background: rgba(8, 14, 32, 0.72); border: 1px solid rgba(140,170,255,0.25);
border-radius: 14px; padding: 6px 12px; color: #eaf0ff; font-weight: 700;
font-size: clamp(13px, 3.4vw, 17px); backdrop-filter: blur(3px); }
#hud-bank { top: max(10px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); }
#hud-carry { top: max(10px, env(safe-area-inset-top)); right: max(10px, env(safe-area-inset-right)); }
#hud-night { top: max(10px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);
text-align: center; padding: 6px 14px; }
#hud-timebar { width: clamp(90px, 26vw, 180px); height: 6px; background: rgba(255,255,255,0.15);
border-radius: 3px; margin-top: 4px; overflow: hidden; }
#hud-timefill { height: 100%; width: 100%; background: linear-gradient(90deg, #8fb4ff, #ffd23f); border-radius: 3px; }
#vignette { position: fixed; inset: 0; pointer-events: none; z-index: 25; opacity: 0;
background: radial-gradient(ellipse at center, transparent 55%, rgba(255, 30, 30, 0.55) 100%);
transition: opacity 0.25s; }
#toast { position: fixed; left: 50%; bottom: 18%; transform: translateX(-50%); z-index: 35;
background: rgba(8, 14, 32, 0.85); color: #ffd23f; border: 1px solid rgba(255,210,63,0.4);
padding: 10px 20px; border-radius: 999px; font-weight: 700; font-size: clamp(14px, 3.6vw, 18px);
opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap;
font-family: "Trebuchet MS", system-ui, sans-serif; max-width: 92vw; text-overflow: ellipsis; overflow: hidden; }
.overlay { position: fixed; inset: 0; z-index: 45; display: flex; flex-direction: column; align-items: center;
justify-content: center; background: rgba(5, 8, 20, 0.82); color: #eaf0ff; text-align: center;
font-family: "Trebuchet MS", system-ui, sans-serif; backdrop-filter: blur(4px); padding: 20px; }
.overlay h2 { font-size: clamp(1.8rem, 8vw, 3.2rem); margin-bottom: 0.4em; text-shadow: 0 4px 20px rgba(120,160,255,0.4); }
.overlay p { color: #9db4e6; margin-bottom: 1.6em; font-size: clamp(0.95rem, 3.6vw, 1.2rem); line-height: 1.5; }
.overlay .stars { font-size: clamp(1.6rem, 7vw, 2.6rem); margin-bottom: 0.5em; letter-spacing: 0.15em; }
.overlay button { font-family: inherit; font-size: clamp(1rem, 4vw, 1.3rem); font-weight: 700; padding: 0.8em 2.2em;
color: #1a1200; background: linear-gradient(180deg, #ffe27a, #ffc31f 60%, #f0a500); border: none;
border-radius: 999px; box-shadow: 0 5px 0 #8a5d00, 0 10px 24px rgba(255,195,31,0.3); cursor: pointer; }
.overlay button:active { transform: translateY(3px); box-shadow: 0 2px 0 #8a5d00; }
.float-label { position: fixed; z-index: 32; pointer-events: none; font: 800 16px "Trebuchet MS", sans-serif;
color: #ffd23f; text-shadow: 0 2px 6px #000; transition: transform 0.9s ease-out, opacity 0.9s; }
#music-btn { position: fixed; bottom: max(12px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left));
z-index: 36; width: 42px; height: 42px; border-radius: 50%; border: 1px solid rgba(140,170,255,0.3);
background: rgba(8,14,32,0.7); color: #eaf0ff; font-size: 20px; line-height: 42px; text-align: center;
pointer-events: auto; cursor: pointer; }
`;
document.head.appendChild(css);
const hud = document.createElement('div');
hud.id = 'hud';
hud.innerHTML = `
<div class="hud-chip" id="hud-bank">💰 <span id="bank-val">0</span> / <span id="goal-val">0</span></div>
<div class="hud-chip" id="hud-night">🌙 <span id="night-val">Night 1</span><div id="hud-timebar"><div id="hud-timefill"></div></div></div>
<div class="hud-chip" id="hud-carry">🎒 <span id="carry-val">0</span>/${MAX_CARRY}</div>
<div id="music-btn" title="music">🎷</div>
`;
document.body.appendChild(hud);
const vignette = document.createElement('div');
vignette.id = 'vignette';
document.body.appendChild(vignette);
const toastEl = document.createElement('div');
toastEl.id = 'toast';
document.body.appendChild(toastEl);
let toastTimer = null;
function toast(msg, ms = 2600) {
toastEl.textContent = msg;
toastEl.style.opacity = '1';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.style.opacity = '0'; }, ms);
}
function floatLabel(text, screenX, screenY, color = '#ffd23f') {
const el = document.createElement('div');
el.className = 'float-label';
el.textContent = text;
el.style.left = screenX + 'px';
el.style.top = screenY + 'px';
el.style.color = color;
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.transform = 'translateY(-70px)';
el.style.opacity = '0';
});
setTimeout(() => el.remove(), 950);
}
// ---------------------------------------------------------------- Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.style.cssText = 'position:fixed;inset:0;z-index:1;display:block;';
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 220);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---------------------------------------------------------------- Game state
const controls = new Controls();
let world = null;
let player = null;
const playerPos = new THREE.Vector3(0, 0, 26);
let playerAngle = Math.PI;
let playerSpeedVis = 0;
let dashTime = -10, dashCooldownUntil = 0, frenzyUntil = -10;
const carried = []; // {kind, value, mesh}
let banked = 0, goal = 200, night = 1;
let nightTime = 0;
let state = 'title'; // title | playing | caught | results
let caughtTimer = 0;
const guards = []; // {api, pos, angle, waypoints, wpIndex, alert, mode, loseSightT, cone, exclaim}
let policeCar = null, carX = -40, carDir = 1;
const lootItems = []; // {kind, value, group, x, z, taken}
let seagull = null; // {api, state, target, t, from}
let seagullTimer = 18;
let windowEvent = null; // {win, t, circle}
let windowTimer = 14;
let crew = [];
let lastFrame = performance.now();
let camShake = 0;
const $ = (id) => document.getElementById(id);
// ---------------------------------------------------------------- Helpers
function circleVsObstacles(x, z, r) {
// returns corrected [x, z] pushed out of all obstacles
for (const o of world.obstacles) {
if (o.type === 'box') {
const nx = Math.max(o.minX, Math.min(x, o.maxX));
const nz = Math.max(o.minZ, Math.min(z, o.maxZ));
const dx = x - nx, dz = z - nz;
const d2 = dx * dx + dz * dz;
if (d2 < r * r) {
if (d2 > 1e-6) {
const d = Math.sqrt(d2);
x = nx + (dx / d) * r;
z = nz + (dz / d) * r;
} else {
// center inside box: push out along smallest penetration
const pl = x - o.minX, pr = o.maxX - x, pt = z - o.minZ, pb = o.maxZ - z;
const m = Math.min(pl, pr, pt, pb);
if (m === pl) x = o.minX - r; else if (m === pr) x = o.maxX + r;
else if (m === pt) z = o.minZ - r; else z = o.maxZ + r;
}
}
} else {
const dx = x - o.x, dz = z - o.z;
const rr = r + o.r;
const d2 = dx * dx + dz * dz;
if (d2 < rr * rr && d2 > 1e-6) {
const d = Math.sqrt(d2);
x = o.x + (dx / d) * rr;
z = o.z + (dz / d) * rr;
}
}
}
x = Math.max(BOUNDS.minX, Math.min(BOUNDS.maxX, x));
z = Math.max(BOUNDS.minZ, Math.min(BOUNDS.maxZ, z));
return [x, z];
}
function losBlocked(x1, z1, x2, z2) {
for (const b of world.losBlockers) {
// 2D segment vs AABB slab test
const dx = x2 - x1, dz = z2 - z1;
let tmin = 0, tmax = 1;
if (Math.abs(dx) < 1e-8) {
if (x1 < b.minX || x1 > b.maxX) continue;
} else {
let t1 = (b.minX - x1) / dx, t2 = (b.maxX - x1) / dx;
if (t1 > t2) [t1, t2] = [t2, t1];
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) continue;
}
if (Math.abs(dz) < 1e-8) {
if (z1 < b.minZ || z1 > b.maxZ) continue;
} else {
let t1 = (b.minZ - z1) / dz, t2 = (b.maxZ - z1) / dz;
if (t1 > t2) [t1, t2] = [t2, t1];
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) continue;
}
return true;
}
return false;
}
function randomLootSpot() {
for (let tries = 0; tries < 40; tries++) {
const x = BOUNDS.minX + 2 + Math.random() * (BOUNDS.maxX - BOUNDS.minX - 4);
const z = -8 + Math.random() * (BOUNDS.maxZ - 4 + 8);
if (Math.hypot(x - world.hideoutPos.x, z - world.hideoutPos.z) < 6) continue;
let ok = true;
for (const o of world.obstacles) {
if (o.type === 'box') {
if (x > o.minX - 1 && x < o.maxX + 1 && z > o.minZ - 1 && z < o.maxZ + 1) { ok = false; break; }
} else if (Math.hypot(x - o.x, z - o.z) < o.r + 1) { ok = false; break; }
}
if (ok) return [x, z];
}
return [0, 10];
}
function pickLootKind() {
const entries = Object.entries(LOOT_TYPES);
const total = entries.reduce((s, [, v]) => s + v.weight, 0);
let r = Math.random() * total;
for (const [kind, v] of entries) {
r -= v.weight;
if (r <= 0) return kind;
}
return 'coin';
}
function worldToScreen(v3) {
const v = v3.clone().project(camera);
return [(v.x * 0.5 + 0.5) * window.innerWidth, (-v.y * 0.5 + 0.5) * window.innerHeight];
}
// ---------------------------------------------------------------- Setup
function spawnLoot(count) {
for (const l of lootItems) scene.remove(l.group);
lootItems.length = 0;
for (let i = 0; i < count; i++) {
const kind = pickLootKind();
const [x, z] = randomLootSpot();
const group = makeLoot(kind);
group.position.set(x, 0, z);
scene.add(group);
lootItems.push({ kind, value: LOOT_TYPES[kind].value, group, x, z, taken: false });
}
}
function makeVisionCone(range, halfAngleDeg) {
const angle = THREE.MathUtils.degToRad(halfAngleDeg);
const geo = new THREE.ConeGeometry(Math.tan(angle) * range, range, 24, 1, true);
const matC = new THREE.MeshBasicMaterial({
color: 0xfff2b0, transparent: true, opacity: 0.13, side: THREE.DoubleSide,
depthWrite: false,
});
const cone = new THREE.Mesh(geo, matC);
cone.rotation.x = -Math.PI / 2; // point along +z
cone.position.set(0, 0.9, range / 2);
const wrap = new THREE.Group();
wrap.add(cone);
return { wrap, mat: matC };
}
function addGuard(route, speedBoost = 0) {
const api = makeGuard();
scene.add(api.group);
const cone = makeVisionCone(11, 26);
api.group.add(cone.wrap);
const exclaim = document.createElement('div');
exclaim.className = 'float-label';
exclaim.style.transition = 'none';
exclaim.style.fontSize = '26px';
exclaim.style.display = 'none';
exclaim.textContent = '❗';
document.body.appendChild(exclaim);
const g = {
api, cone, exclaim,
pos: route[0].clone(),
angle: 0,
waypoints: route,
wpIndex: 1,
alert: 0,
mode: 'patrol',
loseSightT: 0,
baseSpeed: 2.3 + speedBoost,
chaseSpeed: 4.9 + speedBoost * 1.4,
};
guards.push(g);
}
function clearGuards() {
for (const g of guards) { scene.remove(g.api.group); g.exclaim.remove(); }
guards.length = 0;
}
async function setupGame() {
world = await buildWorld(scene);
player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group);
// crew raccoons at the hideout
for (const [dx, dz, s] of [[-3.6, 0.6, 0.8], [3.6, 0.2, 0.75]]) {
const c = makeRaccoon({ scale: s, shirt: [0x5a3a6e, 0x2f6b4f][crew.length] });
c.group.position.set(world.hideoutPos.x + dx, 0, world.hideoutPos.z + dz);
c.group.rotation.y = Math.random() * Math.PI * 2;
scene.add(c.group);
crew.push(c);
}
policeCar = makePoliceCar();
policeCar.group.position.set(carX, 0, world.roadZ);
scene.add(policeCar.group);
seagull = { api: makeSeagull(), state: 'away', target: null, t: 0 };
seagull.api.group.position.set(0, 30, -40);
scene.add(seagull.api.group);
const shadow = new THREE.Mesh(new THREE.CircleGeometry(0.8, 16), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 }));
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = 0.03;
scene.add(shadow);
seagull.shadow = shadow;
// window-event ground glow
const glow = new THREE.Mesh(new THREE.CircleGeometry(5.2, 24), new THREE.MeshBasicMaterial({ color: 0xffd88a, transparent: true, opacity: 0, depthWrite: false }));
glow.rotation.x = -Math.PI / 2;
glow.position.y = 0.04;
scene.add(glow);
windowEvent = { active: false, t: 0, win: null, circle: glow, cx: 0, cz: 0 };
}
function startNight(n) {
night = n;
goal = 150 + (n - 1) * 120;
nightTime = 0;
banked = 0;
for (const c of carried) player.lootAnchor.remove(c.mesh);
carried.length = 0;
playerPos.set(0, 0, 26);
playerAngle = Math.PI;
frenzyUntil = -10;
clearGuards();
const routes = world.patrolRoutes;
const boost = (n - 1) * 0.25;
if (n === 1) addGuard(routes[2], boost);
else if (n === 2) { addGuard(routes[0], boost); addGuard(routes[1], boost); }
else { addGuard(routes[0], boost); addGuard(routes[1], boost); addGuard(routes[2], boost); }
spawnLoot(16 + n * 4);
carX = -40; carDir = 1;
seagullTimer = 15 + Math.random() * 15;
windowTimer = 10 + Math.random() * 12;
$('night-val').textContent = `Night ${n}`;
$('goal-val').textContent = goal;
$('bank-val').textContent = '0';
$('carry-val').textContent = '0';
state = 'playing';
hud.style.display = 'block';
if (n === 1) toast('Grab shinies 💎 — stash them in the crew dumpster! 🗑️', 4200);
else toast(`Night ${n}: more heat, bigger score. Stay in the shadows.`, 3600);
}
// ---------------------------------------------------------------- Results
function showResults(won) {
state = 'results';
const overlay = document.createElement('div');
overlay.className = 'overlay';
const ratio = banked / goal;
const stars = won ? (ratio >= 2 ? '⭐⭐⭐' : ratio >= 1.4 ? '⭐⭐' : '⭐') : '';
if (won) {
sfx.win();
overlay.innerHTML = `
<h2>NIGHT ${night} CLEAR! 🦝</h2>
<div class="stars">${stars}</div>
<p>The crew banked <b style="color:#ffd23f">${banked}</b> in shiny loot (goal ${goal}).<br>
Word on the street: an even bigger score tomorrow…</p>
<button id="next-btn">NIGHT ${night + 1} ➜</button>`;
} else {
sfx.lose();
overlay.innerHTML = `
<h2>THE SUN CAME UP ☀️</h2>
<p>Only <b style="color:#ffd23f">${banked}</b> of ${goal} banked before dawn.<br>
The crew scattered into the hedges. Tonight, we go again.</p>
<button id="next-btn">RETRY NIGHT ${night}</button>`;
}
document.body.appendChild(overlay);
overlay.querySelector('#next-btn').addEventListener('click', () => {
overlay.remove();
resumeAudio();
startNight(won ? night + 1 : night);
});
}
// ---------------------------------------------------------------- Caught
function onCaught(guard) {
state = 'caught';
caughtTimer = 2.0;
camShake = 0.7;
sfx.caught();
vignette.style.opacity = '1';
const dropCount = Math.ceil(carried.length / 2);
for (let i = 0; i < dropCount; i++) {
const item = carried.pop();
player.lootAnchor.remove(item.mesh);
// scatter it back on the ground near the catch point
const a = Math.random() * Math.PI * 2, d = 2 + Math.random() * 3;
let [lx, lz] = circleVsObstacles(playerPos.x + Math.cos(a) * d, playerPos.z + Math.sin(a) * d, 0.5);
const group = makeLoot(item.kind);
group.position.set(lx, 0, lz);
scene.add(group);
lootItems.push({ kind: item.kind, value: item.value, group, x: lx, z: lz, taken: false });
}
updateCarryHUD();
toast(carried.length ? 'Busted! You dropped some loot! 🚨' : 'Busted! The guard shooed you off! 🚨', 3000);
for (const g of guards) { g.alert = 0; g.mode = 'patrol'; }
}
function updateCarryHUD() {
$('carry-val').textContent = carried.length;
$('hud-carry').style.borderColor = carried.length >= MAX_CARRY ? 'rgba(255,90,90,0.8)' : 'rgba(140,170,255,0.25)';
}
// ---------------------------------------------------------------- Per-frame updates
const tmpV = new THREE.Vector3();
function updatePlayer(dt) {
controls.update();
let mx = controls.moveX, mz = controls.moveZ;
const now = performance.now() / 1000;
if (controls.consumeDash() && now > dashCooldownUntil && (mx || mz || true)) {
dashTime = now;
dashCooldownUntil = now + 1.4;
sfx.dash();
}
const dashing = now - dashTime < 0.28;
const frenzy = now < frenzyUntil;
const carryPenalty = 1 - Math.min(0.35, carried.length * 0.045);
let speed = 7.2 * carryPenalty * (frenzy ? 1.55 : 1) * (dashing ? 2.1 : 1);
const mlen = Math.hypot(mx, mz);
if (mlen > 0.05) {
playerAngle = Math.atan2(mx, mz);
playerSpeedVis = Math.min(1, mlen) * (dashing ? 1.6 : 1);
} else if (dashing) {
// dash continues in facing direction
mx = Math.sin(playerAngle); mz = Math.cos(playerAngle);
playerSpeedVis = 1.4;
} else {
playerSpeedVis *= Math.max(0, 1 - dt * 8);
speed = 0;
}
if (speed > 0) {
let nx = playerPos.x + mx * speed * dt;
let nz = playerPos.z + mz * speed * dt;
[nx, nz] = circleVsObstacles(nx, nz, PLAYER_RADIUS);
playerPos.x = nx; playerPos.z = nz;
}
player.group.position.copy(playerPos);
const targetRot = playerAngle;
let dr = targetRot - player.group.rotation.y;
while (dr > Math.PI) dr -= Math.PI * 2;
while (dr < -Math.PI) dr += Math.PI * 2;
player.group.rotation.y += dr * Math.min(1, dt * 14);
player.api?.animate?.(dt, playerSpeedVis);
player.animate(dt, playerSpeedVis);
// carried loot bobs on the back
carried.forEach((c, i) => {
c.mesh.position.y = 0.25 + i * 0.34 + Math.sin(now * 6 + i) * 0.03;
c.mesh.rotation.y += dt * 2;
});
// pickup
for (const l of lootItems) {
if (l.taken) continue;
if (Math.hypot(l.x - playerPos.x, l.z - playerPos.z) < 1.15) {
if (l.kind === 'pizza') {
l.taken = true;
scene.remove(l.group);
frenzyUntil = now + 6;
sfx.frenzy();
toast('🍕 PIZZA FRENZY! Zoom zoom!', 2200);
continue;
}
if (carried.length >= MAX_CARRY) {
toast('Paws full! Bank it at the dumpster 🗑️', 1800);
continue;
}
l.taken = true;
scene.remove(l.group);
const mini = makeLoot(l.kind);
mini.scale.setScalar(0.65);
player.lootAnchor.add(mini);
carried.push({ kind: l.kind, value: l.value, mesh: mini });
(l.kind === 'coin' ? sfx.coin : l.kind === 'gem' || l.kind === 'ring' ? sfx.gem : sfx.munch)();
const [sx, sy] = worldToScreen(tmpV.set(playerPos.x, 1.5, playerPos.z));
floatLabel(`+${l.value}`, sx, sy);
updateCarryHUD();
}
}
// prune taken loot
for (let i = lootItems.length - 1; i >= 0; i--) if (lootItems[i].taken) lootItems.splice(i, 1);
// banking
if (carried.length && Math.hypot(playerPos.x - world.hideoutPos.x, playerPos.z - world.hideoutPos.z) < 3.4) {
let total = 0;
for (const c of carried) { total += c.value; player.lootAnchor.remove(c.mesh); }
carried.length = 0;
banked += total;
sfx.bank();
$('bank-val').textContent = banked;
updateCarryHUD();
const [sx, sy] = worldToScreen(tmpV.set(world.hideoutPos.x, 3, world.hideoutPos.z));
floatLabel(`BANKED +${total}!`, sx, sy, '#7fff9e');
camShake = Math.max(camShake, 0.15);
for (const c of crew) c.group.position.y = 0.5; // lil celebratory hop
if (banked >= goal) toast('Goal reached! Keep looting or savor the win 😎', 3000);
}
// frenzy visual: tail wag speed & vignette tint
player.tail.rotation.z = frenzy ? Math.sin(now * 20) * 0.5 : player.tail.rotation.z;
}
function updateGuards(dt) {
const now = performance.now() / 1000;
let maxAlertRatio = 0;
for (const g of guards) {
// --- movement
let speed = 0;
if (g.mode === 'patrol' || g.mode === 'return') {
const wp = g.waypoints[g.wpIndex];
tmpV.set(wp.x - g.pos.x, 0, wp.z - g.pos.z);
const d = tmpV.length();
if (d < 0.8) { g.wpIndex = (g.wpIndex + 1) % g.waypoints.length; }
else {
tmpV.normalize();
const targetAngle = Math.atan2(tmpV.x, tmpV.z);
let da = targetAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
g.angle += da * Math.min(1, dt * 3);
speed = g.baseSpeed;
g.pos.x += Math.sin(g.angle) * speed * dt;
g.pos.z += Math.cos(g.angle) * speed * dt;
}
} else if (g.mode === 'chase') {
tmpV.set(playerPos.x - g.pos.x, 0, playerPos.z - g.pos.z);
const d = tmpV.length();
tmpV.normalize();
const targetAngle = Math.atan2(tmpV.x, tmpV.z);
let da = targetAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
g.angle += da * Math.min(1, dt * 6);
speed = g.chaseSpeed;
g.pos.x += Math.sin(g.angle) * speed * dt;
g.pos.z += Math.cos(g.angle) * speed * dt;
if (d < 1.35 && state === 'playing') onCaught(g);
}
[g.pos.x, g.pos.z] = circleVsObstacles(g.pos.x, g.pos.z, 0.6);
g.api.group.position.set(g.pos.x, 0, g.pos.z);
g.api.group.rotation.y = g.angle;
g.api.animate(dt, speed / 4);
// --- vision
const dx = playerPos.x - g.pos.x, dz = playerPos.z - g.pos.z;
const dist = Math.hypot(dx, dz);
const inRange = dist < 11;
let sees = false;
if (inRange) {
const dirAngle = Math.atan2(dx, dz);
let da = dirAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
}
if (sees && state === 'playing') {
const rate = 55 + (1 - dist / 11) * 130;
g.alert = Math.min(100, g.alert + rate * dt);
if (g.alert >= 100 && g.mode !== 'chase') {
g.mode = 'chase';
sfx.alert();
toast('You’ve been spotted! RUN! 🏃💨', 2000);
}
} else {
g.alert = Math.max(0, g.alert - 26 * dt);
if (g.mode === 'chase') {
g.loseSightT += dt;
if (g.loseSightT > 3.5 || g.alert <= 0) {
g.mode = 'patrol';
g.loseSightT = 0;
// head to nearest waypoint
let best = 0, bd = 1e9;
g.waypoints.forEach((wp, i) => {
const d = Math.hypot(wp.x - g.pos.x, wp.z - g.pos.z);
if (d < bd) { bd = d; best = i; }
});
g.wpIndex = best;
}
}
}
if (sees) g.loseSightT = 0;
// cone color: yellow -> orange -> red
const a = g.alert / 100;
maxAlertRatio = Math.max(maxAlertRatio, a);
g.cone.mat.color.setHSL(0.13 - a * 0.13, 1, 0.62);
g.cone.mat.opacity = 0.11 + a * 0.14;
// exclamation UI
if (g.alert > 25) {
const [sx, sy] = worldToScreen(tmpV.set(g.pos.x, 2.9, g.pos.z));
g.exclaim.style.display = 'block';
g.exclaim.style.left = (sx - 10) + 'px';
g.exclaim.style.top = (sy - 20) + 'px';
g.exclaim.textContent = g.mode === 'chase' ? '‼️' : '❓';
} else {
g.exclaim.style.display = 'none';
}
}
vignette.style.opacity = state === 'caught' ? '1' : String(maxAlertRatio * 0.8);
}
function updatePoliceCar(dt) {
const speed = 7 + night * 1.2;
carX += carDir * speed * dt;
if (carX > 46) { carDir = -1; }
if (carX < -46) { carDir = 1; }
policeCar.group.position.set(carX, 0, world.roadZ);
policeCar.group.rotation.y = carDir > 0 ? Math.PI / 2 : -Math.PI / 2;
policeCar.animate(dt);
// headlight beam catch: on the road, ahead of the car
if (state === 'playing') {
const ahead = (playerPos.x - carX) * carDir;
if (Math.abs(playerPos.z - world.roadZ) < 4 && ahead > 0 && ahead < 12) {
for (const g of guards) g.alert = Math.min(100, g.alert + 240 * dt);
if (Math.hypot(playerPos.x - carX, playerPos.z - world.roadZ) < 3) {
onCaught(null);
}
}
}
}
function updateSeagull(dt) {
const s = seagull;
s.api.animate(dt, s.state === 'away' ? 0.3 : 1);
if (s.state === 'away') {
seagullTimer -= dt;
if (seagullTimer <= 0 && lootItems.length > 2) {
// pick a target loot
s.target = lootItems[Math.floor(Math.random() * lootItems.length)];
s.state = 'swoop';
s.t = 0;
s.from = new THREE.Vector3(playerPos.x + 30, 26, playerPos.z - 30);
sfx.squawk();
toast('🐦 Seagull! It wants YOUR loot!', 2200);
}
return;
}
if (s.state === 'swoop') {
s.t += dt / 3.2;
if (!s.target || s.target.taken) {
s.state = 'flee'; s.t = 0;
s.grabbed = null;
return;
}
const tx = s.target.x, tz = s.target.z;
const p = s.t;
const x = THREE.MathUtils.lerp(s.from.x, tx, p);
const z = THREE.MathUtils.lerp(s.from.z, tz, p);
const y = THREE.MathUtils.lerp(s.from.y, 0.5, p * p);
s.api.group.position.set(x, y, z);
s.api.group.lookAt(tx, 0.5, tz);
s.shadow.position.set(tx, 0.03, tz);
s.shadow.material.opacity = Math.min(0.45, p * 0.6);
s.shadow.scale.setScalar(0.4 + p * 0.8);
if (p >= 1) {
// grab!
s.target.taken = true;
scene.remove(s.target.group);
const mini = makeLoot(s.target.kind);
mini.scale.setScalar(0.6);
mini.position.set(0, -0.3, 0.2);
s.api.group.add(mini);
s.grabbed = mini;
sfx.squawk();
s.state = 'flee';
s.t = 0;
s.shadow.material.opacity = 0;
}
return;
}
if (s.state === 'flee') {
s.t += dt / 4;
s.api.group.position.y += dt * 8;
s.api.group.position.x -= dt * 14;
s.api.group.position.z -= dt * 6;
if (s.t >= 1) {
if (s.grabbed) { s.api.group.remove(s.grabbed); s.grabbed = null; }
s.state = 'away';
seagullTimer = 20 + Math.random() * 22;
s.api.group.position.set(0, 30, -40);
}
}
}
function updateWindowEvent(dt) {
const we = windowEvent;
if (!we.active) {
windowTimer -= dt;
if (windowTimer <= 0 && world.windows.length) {
we.active = true;
we.t = 5;
we.win = world.windows[Math.floor(Math.random() * world.windows.length)];
we.cx = we.win.pos.x + we.win.dir.x * 3.4;
we.cz = we.win.pos.z + we.win.dir.z * 3.4;
we.circle.position.set(we.cx, 0.04, we.cz);
we.win.mesh.material.emissive = new THREE.Color(0xffe9b0);
we.win.mesh.material.emissiveIntensity = 2.0;
sfx.meow();
toast('👀 A homeowner is peeking out!', 2200);
}
return;
}
we.t -= dt;
const pulse = 0.32 + Math.sin(performance.now() / 130) * 0.08;
we.circle.material.opacity = we.t > 0.6 ? pulse : pulse * (we.t / 0.6);
if (state === 'playing' && Math.hypot(playerPos.x - we.cx, playerPos.z - we.cz) < 5.2) {
for (const g of guards) g.alert = Math.min(100, g.alert + 65 * dt);
}
if (we.t <= 0) {
we.active = false;
we.circle.material.opacity = 0;
we.win.mesh.material.emissiveIntensity = we.win.lit ? 1.1 : 0.4;
we.win.mesh.material.emissive = new THREE.Color(we.win.lit ? 0xffb347 : 0x101b30);
windowTimer = 16 + Math.random() * 18;
}
}
function updateDawn(dt) {
nightTime += dt;
const frac = Math.min(1, nightTime / NIGHT_LEN);
$('hud-timefill').style.width = ((1 - frac) * 100) + '%';
// last 30 seconds: sky begins to lighten
const dawn = Math.max(0, (nightTime - (NIGHT_LEN - 30)) / 30);
if (dawn > 0) {
const sky = new THREE.Color(0x0a1024).lerp(new THREE.Color(0x6a5a7e), dawn);
scene.background = sky;
scene.fog.color = sky;
world.ambient.intensity = 0.85 + dawn * 0.9;
world.ambient.color.setHex(0x3a4a78).lerp(new THREE.Color(0xcf9a6a), dawn);
if (dawn > 0.65 && Math.floor(nightTime * 2) !== Math.floor((nightTime - dt) * 2) && Math.floor(nightTime) % 5 === 0) {
// gentle tick reminder handled by bar; skip extra toasts
}
}
if (nightTime >= NIGHT_LEN && state === 'playing') {
showResults(banked >= goal);
}
}
// ---------------------------------------------------------------- Camera
function updateCamera(dt) {
const portrait = window.innerHeight > window.innerWidth;
const dy = portrait ? 19 : 15;
const dz = portrait ? 14 : 12;
tmpV.set(playerPos.x, dy, playerPos.z + dz);
camera.position.lerp(tmpV, Math.min(1, dt * 4));
if (camShake > 0) {
camShake = Math.max(0, camShake - dt * 1.6);
camera.position.x += (Math.random() - 0.5) * camShake * 0.6;
camera.position.y += (Math.random() - 0.5) * camShake * 0.4;
}
camera.lookAt(playerPos.x, 1, playerPos.z - 2);
}
// ---------------------------------------------------------------- Main loop
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
if (state === 'title') return;
const t = now / 1000;
// idle animations always run
for (const l of lootItems) {
l.group.position.y = Math.sin(t * 2.4 + l.x) * 0.08;
l.group.rotation.y += dt * 1.6;
}
for (const c of crew) {
c.animate(dt, 0.05);
c.group.position.y = Math.max(0, c.group.position.y - dt * 2);
}
if (world.fireflies) {
const pos = world.fireflies.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, 0.6 + Math.sin(t * 0.7 + i * 1.3) * 0.5 + Math.sin(t * 0.31 + i) * 0.4);
}
pos.needsUpdate = true;
world.fireflies.material.opacity = 0.5 + Math.sin(t * 2) * 0.3;
}
if (state === 'playing') {
updatePlayer(dt);
updateGuards(dt);
updatePoliceCar(dt);
updateSeagull(dt);
updateWindowEvent(dt);
updateDawn(dt);
} else if (state === 'caught') {
caughtTimer -= dt;
updateGuards(dt * 0.3);
updatePoliceCar(dt);
if (caughtTimer <= 0) {
playerPos.set(0, 0, 26);
player.group.position.copy(playerPos);
vignette.style.opacity = '0';
state = 'playing';
}
updateDawn(dt);
} else if (state === 'results') {
updatePoliceCar(dt);
for (const g of guards) g.api.animate(dt, 0.1);
}
updateCamera(dt);
renderer.render(scene, camera);
}
// ---------------------------------------------------------------- Boot
const playBtn = document.getElementById('play-btn');
const statusEl = document.getElementById('loading-status');
let worldReady = false, worldLoading = false;
async function ensureWorld() {
if (worldReady || worldLoading) return;
worldLoading = true;
statusEl.textContent = 'Casing the joint…';
try {
await setupGame();
worldReady = true;
statusEl.textContent = '';
} catch (e) {
statusEl.textContent = 'Load hiccup — tap START to retry.';
worldLoading = false;
console.error(e);
}
}
ensureWorld();
playBtn.addEventListener('click', async () => {
playBtn.disabled = true;
initAudio();
resumeAudio();
await ensureWorld();
if (!worldReady) { playBtn.disabled = false; return; }
document.getElementById('title-screen').classList.add('hidden');
startNight(1);
playBtn.disabled = false;
});
document.getElementById('music-btn').addEventListener('click', () => {
const on = toggleMusic();
document.getElementById('music-btn').style.opacity = on ? '1' : '0.4';
});
requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });
File created successfully at: /home/user/raccoon-heist/js/main.js (file state is current in your context — no need to Read it back)
addFence(-14, 14, 0.3 ? 16 : 16, false); // vertical divider left
addFence(-14, 14, 16, false); // vertical divider left
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (controls.consumeDash() && now > dashCooldownUntil && (mx || mz || true)) { if (controls.consumeDash() && now > dashCooldownUntil) {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
player.api?.animate?.(dt, playerSpeedVis); player.animate(dt, playerSpeedVis);
player.animate(dt, playerSpeedVis);
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
python3 -m http.server 8642 >/dev/null 2>&1 &
sleep 1; curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8642/index.html// Smoke test: load the game, click START, simulate movement, screenshot.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
const errors = [];
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.screenshot({ path: OUT + '/shot-title.png' });
await page.click('#play-btn');
await page.waitForTimeout(2500);
await page.screenshot({ path: OUT + '/shot-game.png' });
// move the raccoon around
await page.keyboard.down('w');
await page.waitForTimeout(1500);
await page.keyboard.up('w');
await page.keyboard.down('a');
await page.waitForTimeout(1200);
await page.keyboard.up('a');
await page.keyboard.press(' ');
await page.waitForTimeout(800);
await page.screenshot({ path: OUT + '/shot-move.png' });
// let events fire for a while
await page.waitForTimeout(12000);
await page.screenshot({ path: OUT + '/shot-later.png' });
console.log(errors.length ? errors.join('\n') : 'NO ERRORS');
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/smoke.js (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && (npm ls playwright --depth=0 2>/dev/null || npm install --silent playwright 2>&1 | tail -1) && node smoke.js
/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad `-- (empty) CONSOLE: Failed to load resource: the server responded with a status of 404 (File not found) Shell cwd was reset to /home/user/raccoon-heist
{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-title.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-move.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-later.png"
}const ambient = new THREE.AmbientLight(0x3a4a78, 0.85); scene.add(ambient); const moonLight = new THREE.DirectionalLight(0x9db8ff, 0.9);
const ambient = new THREE.AmbientLight(0x4a5c92, 1.25); scene.add(ambient); const moonLight = new THREE.DirectionalLight(0xaec4ff, 1.35);
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
const hemi = new THREE.HemisphereLight(0x24365e, 0x0c1220, 0.5); scene.add(hemi);
const hemi = new THREE.HemisphereLight(0x35508c, 0x141c30, 0.85); scene.add(hemi);
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
const portrait = window.innerHeight > window.innerWidth; const dy = portrait ? 19 : 15; const dz = portrait ? 14 : 12;
const portrait = window.innerHeight > window.innerWidth; const dy = portrait ? 16 : 12; const dz = portrait ? 12.5 : 12.5;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group); player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group);
// soft glow that follows the hero so they always read clearly
const heroLight = new THREE.PointLight(0xbfd4ff, 6, 9, 1.4);
heroLight.position.set(0, 3, 0);
player.group.add(heroLight);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node smoke.js
CONSOLE: Failed to load resource: the server responded with a status of 404 (File not found) Shell cwd was reset to /home/user/raccoon-heist
{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-later.png"
}Much better — glowing windows, visible flashlight cone, readable raccoon. Committing this playable build now.
cat >> notes.md << 'EOF' ## Playable core build - Vendored `three@0.170` module build into `vendor/` (no CDN at runtime). - Generated 7 tileable textures with OpenAI `gpt-image-2` (low quality, 1024px, downscaled to 512px JPEG): grass, siding, fence wood, roof shingles, cracked concrete, trash-can metal, soil. Stylized night palette came out great. - `js/actors.js`: procedural low-poly characters — raccoon (bandit mask, striped tail, trotting legs, loot stacks on its back), security guard with flashlight, police car with flashing light bar, seagull, and six loot types. - `js/world.js`: the map — three houses with glowing windows, street for the police car, yard fences with sneak gaps, trash cans, trees/bushes, the crew's green dumpster hideout (with glowing eyes inside), moon, stars, fireflies, fog. Also exports collision obstacles + line-of-sight blockers. - `js/main.js`: game loop — WASD/joystick movement with collision + dash, auto-pickup loot (max 8 carried, slows you down), bank at the dumpster, guard vision cones with alert build-up → chase → caught (drop half your loot), police headlight sweeps, seagull loot-theft event, homeowner window-peek event, pizza frenzy speed boost, dawn timer with sky lightening, night-by-night escalation (more guards, bigger goals), win/lose overlays with star ratings. - `js/controls.js`: dynamic touch joystick (appears where you touch, left 62% of screen) + DASH button; keyboard WASD/arrows + Space. - `js/audio.js`: procedural WebAudio — sneaky walking-bass jazz loop, coin/gem plings, alarm, squawk, win/lose jingles. No audio files. - Smoke-tested with Playwright + SwiftShader: no JS errors; brightened lighting and lowered camera angle after first screenshots read too dark. EOF git add -A && git commit -m "Playable core: 3D night heist with guards, loot, events, touch controls Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push -u origin claude/3d-raccoon-heist-game-50n293 && git log --oneline -3
14 files changed, 1802 insertions(+) create mode 100644 js/actors.js create mode 100644 js/audio.js create mode 100644 js/controls.js create mode 100644 js/main.js create mode 100644 js/world.js create mode 100644 textures/brick.jpg create mode 100644 textures/fence.jpg create mode 100644 textures/grass.jpg create mode 100644 textures/metal.jpg create mode 100644 textures/path.jpg create mode 100644 textures/roof.jpg create mode 100644 textures/soil.jpg create mode 100644 vendor/three.module.js To https://github.com/simonw/raccoon-heist b0c9e08..d221ef0 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293 branch 'claude/3d-raccoon-heist-game-50n293' set up to track 'origin/claude/3d-raccoon-heist-game-50n293'. d221ef0 Playable core: 3D night heist with guards, loot, events, touch controls b0c9e08 Title screen: night sky, moon, skyline, START button 3b11a53 Images to go with prompt
requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });
// tiny debug/testing hook (also handy in devtools)
window.__rh = {
get state() { return state; },
get banked() { return banked; },
get carriedCount() { return carried.length; },
get nightTime() { return nightTime; },
get guards() { return guards.map((g) => ({ mode: g.mode, alert: g.alert, x: g.pos.x, z: g.pos.z })); },
get loot() { return lootItems.map((l) => ({ x: l.x, z: l.z, kind: l.kind })); },
teleport(x, z) { playerPos.set(x, 0, z); },
};The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
// Gameplay verification: pickup, banking, guard chase/caught, mobile joystick.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
async function desktopTest(browser, errors) {
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.click('#play-btn');
await page.waitForTimeout(1500);
// --- pickup: teleport next to a loot item, walk into it
const loot = await page.evaluate(() => window.__rh.loot[0]);
await page.evaluate(([x, z]) => window.__rh.teleport(x - 2, z), [loot.x, loot.z]);
await page.keyboard.down('d');
await page.waitForTimeout(900);
await page.keyboard.up('d');
let carried = await page.evaluate(() => window.__rh.carriedCount);
console.log('carried after walkover:', carried, carried > 0 ? 'PASS' : 'FAIL (may be pizza)');
// grab a few more to be safe
for (let i = 0; i < 3; i++) {
const l = await page.evaluate(() => window.__rh.loot[1]);
if (!l) break;
await page.evaluate(([x, z]) => window.__rh.teleport(x - 1.5, z), [l.x, l.z]);
await page.keyboard.down('d');
await page.waitForTimeout(600);
await page.keyboard.up('d');
}
carried = await page.evaluate(() => window.__rh.carriedCount);
console.log('carried after multi:', carried);
// --- banking
await page.evaluate(() => window.__rh.teleport(0, 25));
await page.keyboard.down('s');
await page.waitForTimeout(1200);
await page.keyboard.up('s');
const banked = await page.evaluate(() => window.__rh.banked);
console.log('banked:', banked, banked > 0 ? 'PASS' : 'FAIL');
// --- guard chase: teleport right in front of a guard
await page.evaluate(() => {
const g = window.__rh.guards[0];
// 3 units ahead of guard's position, roughly toward map center
window.__rh.teleport(g.x, g.z + 0.1);
});
// stand close; alert should spike then chase then caught
await page.waitForTimeout(4000);
const gstate = await page.evaluate(() => ({ guards: window.__rh.guards, state: window.__rh.state }));
console.log('guard after standing close:', JSON.stringify(gstate));
await page.screenshot({ path: OUT + '/shot-chase.png' });
await page.close();
}
async function mobileTest(browser, errors) {
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
deviceScaleFactor: 2,
});
page.on('pageerror', (e) => errors.push('MOBILE PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.screenshot({ path: OUT + '/shot-mobile-title.png' });
await page.tap('#play-btn');
await page.waitForTimeout(1500);
await page.screenshot({ path: OUT + '/shot-mobile-game.png' });
const before = await page.evaluate(() => { const g = window.__rh; return { s: g.state }; });
const posBefore = await page.evaluate(() => JSON.stringify(window.__rh.loot.length));
// simulate joystick drag: touch at (120, 600), drag up
const start = await page.evaluate(() => JSON.stringify([window.innerWidth, window.innerHeight]));
const client = await page.context().newCDPSession(page);
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 120, y: 620, id: 1 }] });
for (let i = 1; i <= 8; i++) {
await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: 120, y: 620 - i * 6, id: 1 }] });
await page.waitForTimeout(40);
}
const p1 = await page.evaluate(() => { const r = window.__rh; return 'joy-active'; });
await page.waitForTimeout(1200);
await page.screenshot({ path: OUT + '/shot-mobile-joy.png' });
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
console.log('mobile ran, state:', before.s, start, posBefore, p1);
await page.close();
}
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
await desktopTest(browser, errors);
await mobileTest(browser, errors);
console.log(errors.length ? errors.join('\n') : 'NO PAGE ERRORS');
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/gameplay-test.js (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node gameplay-test.js
carried after walkover: 1 PASS
carried after multi: 4
banked: 80 PASS
guard after standing close: {"guards":[{"mode":"patrol","alert":0,"x":-14.95,"z":10.659334780557502}],"state":"playing"}
mobile ran, state: playing [390,844] 20 joy-active
NO PAGE ERRORS
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-joy.png"
}The joystick works and the HUD fits, but the raccoon isn't visible in the portrait shots — I need to find out where the camera is pointing. Let me debug.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(8000);
const info = await page.evaluate(() => {
const r = window.__rh;
return JSON.stringify(r.debug ? r.debug() : 'no debug');
});
console.log(info);
await page.screenshot({ path: OUT + '/shot-mobile-idle.png' });
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/debug-cam.js (file state is current in your context — no need to Read it back)
teleport(x, z) { playerPos.set(x, 0, z); },
}; teleport(x, z) { playerPos.set(x, 0, z); },
debug() {
return {
player: playerPos.toArray(),
playerGroup: player ? player.group.position.toArray() : null,
cam: camera.position.toArray(),
fps: 1 / Math.max(0.001, lastDt),
};
},
};The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
let lastFrame = performance.now(); let camShake = 0;
let lastFrame = performance.now(); let lastDt = 0.016; let camShake = 0;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
const dt = Math.min(0.05, (now - lastFrame) / 1000); lastFrame = now;
const dt = Math.min(0.05, (now - lastFrame) / 1000); lastDt = dt; lastFrame = now;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js
{"player":[0,0,26],"playerGroup":[0,0,26],"cam":[0,15.996123638857293,38.490672506000365],"fps":20}
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-idle.png"
}The camera numbers are correct but the raccoon isn't on screen — contradiction. Let me project the player's position to screen coordinates to pin this down.
debug() {
return {
player: playerPos.toArray(),
playerGroup: player ? player.group.position.toArray() : null,
cam: camera.position.toArray(),
fps: 1 / Math.max(0.001, lastDt),
};
}, debug() {
const [sx, sy] = worldToScreen(new THREE.Vector3(playerPos.x, 1, playerPos.z));
return {
player: playerPos.toArray(),
playerScreen: [Math.round(sx), Math.round(sy)],
screen: [window.innerWidth, window.innerHeight],
cam: camera.position.toArray(),
camQuat: camera.quaternion.toArray().map((v) => +v.toFixed(3)),
visible: player ? player.group.visible : null,
inScene: player ? player.group.parent === scene : null,
fps: Math.round(1 / Math.max(0.001, lastDt)),
};
},The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js
{"player":[0,0,26],"playerScreen":[195,482],"screen":[390,844],"cam":[0,15.99242898214315,38.48178223828195],"camQuat":[-0.391,0,0,0.921],"visible":true,"inScene":true,"fps":20}
Shell cwd was reset to /home/user/raccoon-heistcd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > tp-test.js << 'EOF'
const { chromium } = require('playwright');
const OUT = __dirname;
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(3000);
await page.evaluate(() => window.__rh.teleport(0, 10));
await page.waitForTimeout(3000);
console.log(await page.evaluate(() => JSON.stringify(window.__rh.debug())));
await page.screenshot({ path: OUT + '/shot-tp.png' });
await browser.close();
})();
EOF
node tp-test.js{"player":[0,0,10],"playerScreen":[195,481],"screen":[390,844],"cam":[0,15.990532171197016,22.837506007132458],"camQuat":[-0.385,0,0,0.923],"visible":true,"inScene":true,"fps":20}
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-tp.png"
}The rendered content doesn't match the camera transform — very suspicious. I'll expose the scene and THREE for a direct marker test.
// tiny debug/testing hook (also handy in devtools)
window.__rh = {// tiny debug/testing hook (also handy in devtools)
window.__rhScene = scene;
window.__rhCamera = camera;
window.__THREE = THREE;
window.__rh = {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > marker-test.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(2500);
await page.evaluate(() => {
const T = window.__THREE;
const m = new T.Mesh(new T.BoxGeometry(2, 6, 2), new T.MeshBasicMaterial({ color: 0xff0000, fog: false }));
m.position.set(0, 3, 26); // at the player spawn
window.__rhScene.add(m);
// count scene children + report canvas size
const c = document.querySelector('canvas');
window.__mk = { children: window.__rhScene.children.length, canvas: [c.width, c.height, c.clientWidth, c.clientHeight] };
});
await page.waitForTimeout(1500);
console.log(await page.evaluate(() => JSON.stringify({ ...window.__mk, dbg: window.__rh.debug() })));
await page.screenshot({ path: __dirname + '/shot-marker.png' });
await browser.close();
})();
EOF
node marker-test.js{"children":104,"canvas":[780,1688,780,1688],"dbg":{"player":[0,0,26],"playerScreen":[195,485],"screen":[390,844],"cam":[0,15.731023412975222,37.85277508747163],"camQuat":[-0.397,0,0,0.918],"visible":true,"inScene":true,"fps":20}}
Shell cwd was reset to /home/user/raccoon-heistconst renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.style.cssText = 'position:fixed;inset:0;z-index:1;display:block;';
document.body.appendChild(renderer.domElement);const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
// set base styles BEFORE setSize — setSize writes inline width/height styles
// that must not be clobbered (clobbering them breaks high-DPR phones).
renderer.domElement.style.cssText = 'position:fixed;top:0;left:0;z-index:1;display:block;';
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
} if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
// guards notice a raccoon brushing right past them, cone or not
if (dist < 2.4) sees = true;
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js && node gameplay-test.js
{"player":[0,0,26],"playerScreen":[195,482],"screen":[390,844],"cam":[0,15.996898911085834,38.492538004800295],"camQuat":[-0.391,0,0,0.921],"visible":true,"inScene":true,"fps":20}
carried after walkover: 1 PASS
carried after multi: 3
banked: 70 PASS
guard after standing close: {"guards":[{"mode":"patrol","alert":0,"x":-14.95,"z":11.048708423111204}],"state":"caught"}
mobile ran, state: playing [390,844] 20 joy-active
NO PAGE ERRORS
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-idle.png"
}