{"project":{"id":"CEQmBZ5","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Voxel Road Hopper","visible":true,"contributors":"","githubRepo":null,"forkedFrom":null,"isTemplate":false,"tags":"","files":{"folder":"","files":[{"name":"index.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Voxel Road Hopper</title>\n  <link rel=\"stylesheet\" href=\"css/styles.css\">\n  <script type=\"importmap\">\n    {\n      \"imports\": {\n        \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\"\n      }\n    }\n  </script>\n</head>\n<body>\n  <div id=\"game-container\">\n    <canvas id=\"game-canvas\"></canvas>\n\n    <div id=\"hud\" class=\"hud\">\n      <div class=\"hud__score\" id=\"score\">0</div>\n      <button class=\"hud__mute\" id=\"mute-btn\" aria-label=\"Toggle sound\">&#128266;</button>\n    </div>\n\n    <div id=\"start-overlay\" class=\"overlay overlay--visible\">\n      <h1 class=\"overlay__title\">VOXEL ROAD<br>HOPPER</h1>\n      <p class=\"overlay__hint\">Arrow keys / WASD to hop &middot; Tap to go forward</p>\n      <button class=\"overlay__btn\" id=\"start-btn\">PLAY</button>\n    </div>\n\n    <div id=\"gameover-overlay\" class=\"overlay\">\n      <h2 class=\"overlay__title overlay__title--small\">GAME OVER</h2>\n      <div class=\"overlay__score-block\">\n        <div class=\"overlay__score-label\">SCORE</div>\n        <div class=\"overlay__score\" id=\"final-score\">0</div>\n        <div class=\"overlay__score-label\">BEST</div>\n        <div class=\"overlay__best\" id=\"best-score\">0</div>\n      </div>\n      <button class=\"overlay__btn\" id=\"retry-btn\">PLAY AGAIN</button>\n    </div>\n  </div>\n\n  <script type=\"module\" src=\"js/main.js\"></script>\n</body>\n</html>\n"},{"name":"README.md","content":"# Blnq Project\n"}],"folders":[{"folder":"css","files":[{"name":"styles.css","content":":root {\n  --color-bg: #6ec6e8;\n  --color-text: #ffffff;\n  --color-accent: #ff5a3c;\n  --color-overlay: rgba(20, 40, 60, 0.55);\n  --font-stack: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;\n}\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nhtml, body {\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: var(--color-bg);\n  font-family: var(--font-stack);\n  touch-action: manipulation;\n  -webkit-user-select: none;\n  user-select: none;\n}\n\n#game-container {\n  position: relative;\n  width: 100%;\n  height: 100%;\n}\n\n#game-canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* ---------- HUD ---------- */\n.hud {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  padding: 18px 22px;\n  pointer-events: none;\n}\n\n.hud__score {\n  font-size: 56px;\n  font-weight: 900;\n  color: var(--color-text);\n  text-shadow: 0 3px 0 rgba(0, 0, 0, 0.35);\n  line-height: 1;\n}\n\n.hud__mute {\n  pointer-events: auto;\n  background: rgba(0, 0, 0, 0.25);\n  border: none;\n  border-radius: 10px;\n  font-size: 22px;\n  width: 46px;\n  height: 46px;\n  cursor: pointer;\n  color: var(--color-text);\n}\n\n.hud__mute:hover {\n  background: rgba(0, 0, 0, 0.4);\n}\n\n/* ---------- Overlays ---------- */\n.overlay {\n  position: absolute;\n  inset: 0;\n  display: none;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 26px;\n  background: var(--color-overlay);\n  text-align: center;\n  z-index: 10;\n}\n\n.overlay--visible {\n  display: flex;\n}\n\n.overlay__title {\n  font-size: clamp(40px, 8vw, 76px);\n  font-weight: 900;\n  color: var(--color-text);\n  letter-spacing: 2px;\n  text-shadow: 0 5px 0 rgba(0, 0, 0, 0.35);\n  line-height: 1.05;\n}\n\n.overlay__title--small {\n  font-size: clamp(34px, 6vw, 58px);\n}\n\n.overlay__hint {\n  font-family: Arial, sans-serif;\n  font-size: 16px;\n  color: var(--color-text);\n  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);\n}\n\n.overlay__btn {\n  font-family: var(--font-stack);\n  font-size: 24px;\n  font-weight: 900;\n  letter-spacing: 1px;\n  color: var(--color-text);\n  background: var(--color-accent);\n  border: none;\n  border-radius: 12px;\n  padding: 16px 44px;\n  cursor: pointer;\n  box-shadow: 0 6px 0 #c23a22;\n  transition: transform 0.08s ease, box-shadow 0.08s ease;\n}\n\n.overlay__btn:hover {\n  transform: translateY(2px);\n  box-shadow: 0 4px 0 #c23a22;\n}\n\n.overlay__btn:active {\n  transform: translateY(6px);\n  box-shadow: 0 0 0 #c23a22;\n}\n\n.overlay__score-block {\n  color: var(--color-text);\n}\n\n.overlay__score-label {\n  font-size: 16px;\n  letter-spacing: 3px;\n  opacity: 0.85;\n}\n\n.overlay__score {\n  font-size: 64px;\n  font-weight: 900;\n  text-shadow: 0 4px 0 rgba(0, 0, 0, 0.35);\n  margin-bottom: 14px;\n}\n\n.overlay__best {\n  font-size: 36px;\n  font-weight: 900;\n  text-shadow: 0 3px 0 rgba(0, 0, 0, 0.35);\n}\n"}],"folders":[]},{"folder":"js","files":[{"name":"audio.js","content":"// Tiny WebAudio synth — Crossy Road style plinks and effects, no assets.\nexport class AudioManager {\n  constructor() {\n    this.ctx = null;\n    this.muted = false;\n    this.master = null;\n  }\n\n  ensure() {\n    if (!this.ctx) {\n      const AC = window.AudioContext || window.webkitAudioContext;\n      this.ctx = new AC();\n      this.master = this.ctx.createGain();\n      this.master.gain.value = 0.5;\n      this.master.connect(this.ctx.destination);\n    }\n    if (this.ctx.state === 'suspended') this.ctx.resume();\n  }\n\n  toggleMute() {\n    this.muted = !this.muted;\n    if (this.master) this.master.gain.value = this.muted ? 0 : 0.5;\n    return this.muted;\n  }\n\n  tone({ freq = 440, dur = 0.1, type = 'square', vol = 0.3, slide = 0, delay = 0 }) {\n    if (!this.ctx || this.muted) return;\n    const t0 = this.ctx.currentTime + delay;\n    const osc = this.ctx.createOscillator();\n    const gain = this.ctx.createGain();\n    osc.type = type;\n    osc.frequency.setValueAtTime(freq, t0);\n    if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t0 + dur);\n    gain.gain.setValueAtTime(vol, t0);\n    gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);\n    osc.connect(gain).connect(this.master);\n    osc.start(t0);\n    osc.stop(t0 + dur + 0.02);\n  }\n\n  noise({ dur = 0.3, vol = 0.3, delay = 0, lowpass = 1000 }) {\n    if (!this.ctx || this.muted) return;\n    const t0 = this.ctx.currentTime + delay;\n    const len = Math.floor(this.ctx.sampleRate * dur);\n    const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n    const data = buf.getChannelData(0);\n    for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n    const src = this.ctx.createBufferSource();\n    src.buffer = buf;\n    const filter = this.ctx.createBiquadFilter();\n    filter.type = 'lowpass';\n    filter.frequency.value = lowpass;\n    const gain = this.ctx.createGain();\n    gain.gain.setValueAtTime(vol, t0);\n    gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);\n    src.connect(filter).connect(gain).connect(this.master);\n    src.start(t0);\n  }\n\n  // ---- game events ----\n  hop() {\n    this.tone({ freq: 660, dur: 0.07, type: 'square', vol: 0.18, slide: 240 });\n  }\n\n  scoreMilestone() {\n    this.tone({ freq: 880, dur: 0.08, vol: 0.2 });\n    this.tone({ freq: 1320, dur: 0.1, vol: 0.2, delay: 0.08 });\n  }\n\n  splash() {\n    this.noise({ dur: 0.45, vol: 0.5, lowpass: 800 });\n    this.tone({ freq: 300, dur: 0.3, type: 'sine', vol: 0.3, slide: -220 });\n  }\n\n  squash() {\n    this.noise({ dur: 0.2, vol: 0.6, lowpass: 500 });\n    this.tone({ freq: 160, dur: 0.25, type: 'sawtooth', vol: 0.35, slide: -120 });\n  }\n\n  trainWarning() {\n    this.tone({ freq: 740, dur: 0.12, vol: 0.15 });\n    this.tone({ freq: 740, dur: 0.12, vol: 0.15, delay: 0.2 });\n    this.tone({ freq: 740, dur: 0.12, vol: 0.15, delay: 0.4 });\n  }\n\n  trainPass() {\n    this.noise({ dur: 0.9, vol: 0.25, lowpass: 600 });\n  }\n\n  gameOver() {\n    this.tone({ freq: 440, dur: 0.15, type: 'square', vol: 0.25 });\n    this.tone({ freq: 330, dur: 0.15, type: 'square', vol: 0.25, delay: 0.15 });\n    this.tone({ freq: 220, dur: 0.3, type: 'square', vol: 0.25, delay: 0.3 });\n  }\n\n  start() {\n    this.tone({ freq: 523, dur: 0.09, vol: 0.2 });\n    this.tone({ freq: 659, dur: 0.09, vol: 0.2, delay: 0.09 });\n    this.tone({ freq: 784, dur: 0.12, vol: 0.2, delay: 0.18 });\n  }\n}\n"},{"name":"lanes.js","content":"// Lane generation — grass / road / rail / river strips, Crossy Road style.\n// World axes: player moves forward along -Z? No — we use +X as \"forward\" like\n// Crossy Road's diagonal view. Simpler: lanes advance along +Z is awkward for\n// the camera, so: forward = -Z, lanes are rows at integer z, tiles at integer x.\nimport * as THREE from 'three';\nimport {\n  box, mat,\n  createCar, createTruck, createTrain, createLog, createTree, createRock, createSignal,\n} from './models.js';\n\nexport const TILE = 1;\nexport const LANE_WIDTH = 30; // tiles wide (visual), playable area narrower\nexport const PLAY_MIN_X = -4;\nexport const PLAY_MAX_X = 4;\n\nconst GRASS_LIGHT = 0xbcec6a;\nconst GRASS_DARK = 0xaadd5c;\nconst ROAD = 0x4b5161;\nconst ROAD_LINE = 0xe3e6ec;\nconst RAIL_BED = 0x8b7b5e;\nconst WATER = 0x3fa8f0;\n\nlet laneIndexCounter = 0;\n\nfunction rand(min, max) { return min + Math.random() * (max - min); }\nfunction randInt(min, max) { return Math.floor(rand(min, max + 1)); }\nfunction pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }\n\n// ------------------------------------------------------------------ base strips\nfunction grassStrip(z, light) {\n  const m = box(LANE_WIDTH, 0.5, TILE, light ? GRASS_LIGHT : GRASS_DARK, 0, -0.25, z, false, true);\n  return m;\n}\n\nfunction roadStrip(z) {\n  return box(LANE_WIDTH, 0.5, TILE, ROAD, 0, -0.27, z, false, true);\n}\n\nfunction waterStrip(z) {\n  const m = box(LANE_WIDTH, 0.4, TILE, WATER, 0, -0.34, z, false, true);\n  return m;\n}\n\n// ------------------------------------------------------------------ Lane class\nexport class Lane {\n  constructor(type, z, scene, prevType) {\n    this.type = type;            // 'grass' | 'road' | 'rail' | 'river'\n    this.z = z;                  // world z of this row (negative going forward)\n    this.index = laneIndexCounter++;\n    this.group = new THREE.Group();\n    this.obstacles = new Set();  // static blockers (tree/rock) — tile x values\n    this.movers = [];            // {mesh, speed, dir, length} vehicles or logs\n    this.scene = scene;\n\n    this.build(prevType);\n    scene.add(this.group);\n  }\n\n  build(prevType) {\n    const z = this.z;\n    const g = this.group;\n\n    if (this.type === 'grass') {\n      g.add(grassStrip(z, Math.abs(Math.round(z)) % 2 === 0));\n      // trees & rocks outside + sparse inside playable area\n      const used = new Set();\n      // dense decoration outside playable bounds\n      for (let x = -Math.floor(LANE_WIDTH / 2) + 1; x < PLAY_MIN_X; x++) {\n        if (Math.random() < 0.45) this.addStatic(x, z, Math.random() < 0.85 ? createTree(randInt(1, 2)) : createRock(), used, false);\n      }\n      for (let x = PLAY_MAX_X + 1; x < Math.floor(LANE_WIDTH / 2); x++) {\n        if (Math.random() < 0.45) this.addStatic(x, z, Math.random() < 0.85 ? createTree(randInt(1, 2)) : createRock(), used, false);\n      }\n      // sparse blockers inside, never fully blocking\n      const insideCount = randInt(0, 3);\n      const candidates = [];\n      for (let x = PLAY_MIN_X; x <= PLAY_MAX_X; x++) candidates.push(x);\n      for (let i = 0; i < insideCount && candidates.length > 3; i++) {\n        const x = pick(candidates);\n        candidates.splice(candidates.indexOf(x), 1);\n        this.addStatic(x, z, Math.random() < 0.8 ? createTree(randInt(1, 2)) : createRock(), used, true);\n      }\n    } else if (this.type === 'road') {\n      g.add(roadStrip(z));\n      // dashed center line on the edge between consecutive road lanes (cosmetic)\n      if (prevType === 'road') {\n        for (let x = -14; x <= 14; x += 2) {\n          g.add(box(0.6, 0.02, 0.1, ROAD_LINE, x, -0.01, z - 0.5, false, false));\n        }\n      }\n      this.setupVehicles();\n    } else if (this.type === 'rail') {\n      g.add(roadStrip(z));\n      g.add(box(LANE_WIDTH, 0.04, 0.12, 0xc9cdd4, 0, 0.0, z - 0.22, false, false));\n      g.add(box(LANE_WIDTH, 0.04, 0.12, 0xc9cdd4, 0, 0.0, z + 0.22, false, false));\n      for (let x = -14; x <= 14; x++) {\n        g.add(box(0.7, 0.03, 0.22, RAIL_BED, x, -0.02, z, false, false));\n      }\n      this.setupTrain();\n    } else if (this.type === 'river') {\n      g.add(waterStrip(z));\n      this.setupLogs();\n    }\n  }\n\n  addStatic(x, z, mesh, used, isBlocker) {\n    if (used.has(x)) return;\n    used.add(x);\n    mesh.position.set(x, 0, z);\n    mesh.rotation.y = Math.floor(Math.random() * 4) * (Math.PI / 2);\n    this.group.add(mesh);\n    if (isBlocker || (x >= PLAY_MIN_X && x <= PLAY_MAX_X)) this.obstacles.add(x);\n  }\n\n  // ---------------------------------------------------------------- vehicles\n  setupVehicles() {\n    this.dir = Math.random() < 0.5 ? 1 : -1;\n    this.speed = rand(2.2, 4.6);\n    const isTruckLane = Math.random() < 0.25;\n    const count = isTruckLane ? randInt(1, 2) : randInt(2, 3);\n    const colorBase = randInt(0, 5);\n    const spacing = LANE_WIDTH / count;\n    for (let i = 0; i < count; i++) {\n      const mesh = isTruckLane ? createTruck() : createCar(colorBase + i);\n      mesh.rotation.y = this.dir === 1 ? Math.PI / 2 : -Math.PI / 2;\n      const x = -LANE_WIDTH / 2 + i * spacing + rand(0, spacing * 0.5);\n      mesh.position.set(x, 0, this.z);\n      this.group.add(mesh);\n      this.movers.push({ mesh, length: mesh.userData.length, half: mesh.userData.length / 2 });\n    }\n  }\n\n  // ---------------------------------------------------------------- train\n  setupTrain() {\n    this.dir = Math.random() < 0.5 ? 1 : -1;\n    this.speed = rand(14, 18);\n    this.trainTimer = rand(1.5, 5);   // seconds until train passes\n    this.trainActive = false;\n    this.warnTime = 1.1;              // signal blinks this long before train\n    const mesh = createTrain(randInt(3, 5));\n    mesh.rotation.y = this.dir === 1 ? Math.PI / 2 : -Math.PI / 2;\n    mesh.visible = false;\n    mesh.position.set(0, 0, this.z);\n    this.group.add(mesh);\n    this.train = { mesh, length: mesh.userData.length, half: mesh.userData.length / 2 };\n\n    const sig = createSignal();\n    sig.position.set(PLAY_MAX_X + 1.2, 0, this.z);\n    this.group.add(sig);\n    this.signal = sig;\n  }\n\n  // ---------------------------------------------------------------- logs\n  setupLogs() {\n    this.dir = Math.random() < 0.5 ? 1 : -1;\n    this.speed = rand(1.4, 2.6);\n    const count = randInt(2, 3);\n    const spacing = LANE_WIDTH / count;\n    for (let i = 0; i < count; i++) {\n      const tiles = randInt(2, 4);\n      const mesh = createLog(tiles);\n      mesh.position.set(-LANE_WIDTH / 2 + i * spacing + rand(0, spacing * 0.4), 0, this.z);\n      this.group.add(mesh);\n      mesh.rotation.y = Math.PI / 2; // logs float along x\n      this.movers.push({ mesh, length: mesh.userData.length, half: mesh.userData.length / 2 });\n    }\n  }\n\n  // ---------------------------------------------------------------- update\n  update(dt, game) {\n    const half = LANE_WIDTH / 2 + 3;\n    if (this.type === 'road' || this.type === 'river') {\n      for (const m of this.movers) {\n        m.mesh.position.x += this.speed * this.dir * dt;\n        if (this.dir === 1 && m.mesh.position.x > half) m.mesh.position.x = -half;\n        if (this.dir === -1 && m.mesh.position.x < -half) m.mesh.position.x = half;\n      }\n    } else if (this.type === 'rail') {\n      this.trainTimer -= dt;\n      const warning = this.trainTimer <= this.warnTime && this.trainTimer > 0;\n      if (this.signal) {\n        const blinkOn = warning && Math.floor(performance.now() / 180) % 2 === 0;\n        const l = this.signal.userData.lampLeft.material;\n        const r = this.signal.userData.lampRight.material;\n        l.color.setHex(blinkOn && Math.floor(performance.now() / 360) % 2 === 0 ? 0xff2222 : 0x550000);\n        r.color.setHex(blinkOn && Math.floor(performance.now() / 360) % 2 !== 0 ? 0xff2222 : 0x550000);\n        l.emissive = l.emissive || new THREE.Color(0x000000);\n      }\n      if (warning && !this.warned) {\n        this.warned = true;\n        game?.audio?.trainWarning();\n      }\n      if (this.trainTimer <= 0 && !this.trainActive) {\n        this.trainActive = true;\n        this.train.mesh.visible = true;\n        this.train.mesh.position.x = this.dir === 1 ? -half - this.train.half : half + this.train.half;\n        game?.audio?.trainPass();\n      }\n      if (this.trainActive) {\n        this.train.mesh.position.x += this.speed * this.dir * dt;\n        const off = this.dir === 1\n          ? this.train.mesh.position.x - this.train.half > half\n          : this.train.mesh.position.x + this.train.half < -half;\n        if (off) {\n          this.trainActive = false;\n          this.warned = false;\n          this.train.mesh.visible = false;\n          this.trainTimer = rand(3, 9);\n        }\n      }\n    }\n  }\n\n  // Vehicle collision check at player position (x, this lane).\n  hitsVehicle(px) {\n    if (this.type === 'road') {\n      for (const m of this.movers) {\n        if (Math.abs(m.mesh.position.x - px) < m.half * 0.78 + 0.3) return true;\n      }\n    } else if (this.type === 'rail' && this.trainActive) {\n      const t = this.train;\n      if (Math.abs(t.mesh.position.x - px) < t.half + 0.4) return true;\n    }\n    return false;\n  }\n\n  // Returns the log mover under px, or null (river lanes).\n  logAt(px) {\n    for (const m of this.movers) {\n      if (Math.abs(m.mesh.position.x - px) < m.half + 0.35) return m;\n    }\n    return null;\n  }\n\n  isBlocked(x) {\n    return this.obstacles.has(Math.round(x));\n  }\n\n  dispose() {\n    this.scene.remove(this.group);\n    this.group.traverse((o) => {\n      if (o.geometry) o.geometry.dispose();\n    });\n  }\n}\n\n// ------------------------------------------------------------------ generator\nexport class LaneGenerator {\n  constructor(scene) {\n    this.scene = scene;\n    this.lanes = new Map(); // row -> Lane (row 0 = start, increasing forward)\n    this.nextRow = 0;\n    this.prevType = 'grass';\n    this.runLeft = 0;\n    this.runType = 'grass';\n    laneIndexCounter = 0;\n  }\n\n  laneAt(row) { return this.lanes.get(row); }\n\n  ensure(upToRow) {\n    while (this.nextRow <= upToRow) {\n      this.generateNext();\n    }\n  }\n\n  generateNext() {\n    const row = this.nextRow++;\n    let type;\n    if (row < 4) {\n      type = 'grass';\n    } else {\n      if (this.runLeft <= 0) {\n        // choose a new run\n        const r = Math.random();\n        if (this.runType !== 'grass' && r < 0.38) {\n          this.runType = 'grass'; this.runLeft = randInt(1, 2);\n        } else if (r < 0.55) {\n          this.runType = 'road'; this.runLeft = randInt(1, 3);\n        } else if (r < 0.72) {\n          this.runType = 'river'; this.runLeft = randInt(1, 2);\n        } else if (r < 0.82) {\n          this.runType = 'rail'; this.runLeft = 1;\n        } else {\n          this.runType = 'grass'; this.runLeft = randInt(1, 2);\n        }\n      }\n      type = this.runType;\n      this.runLeft--;\n    }\n    const z = -row; // forward is -Z\n    const lane = new Lane(type, z, this.scene, this.prevType);\n    // start rows: clear the spawn tile\n    if (row <= 4) lane.obstacles.delete(0);\n    this.prevType = type;\n    this.lanes.set(row, lane);\n  }\n\n  // remove lanes far behind the player\n  cull(behindRow) {\n    for (const [row, lane] of this.lanes) {\n      if (row < behindRow) {\n        lane.dispose();\n        this.lanes.delete(row);\n      }\n    }\n  }\n\n  update(dt, game) {\n    for (const lane of this.lanes.values()) lane.update(dt, game);\n  }\n\n  reset() {\n    for (const lane of this.lanes.values()) lane.dispose();\n    this.lanes.clear();\n    this.nextRow = 0;\n    this.prevType = 'grass';\n    this.runLeft = 0;\n    this.runType = 'grass';\n  }\n}\n"},{"name":"main.js","content":"// Voxel Road Hopper — Crossy Road style endless hopper.\n// Orthographic ~isometric camera, flat-shaded voxel world, forward = -Z.\nimport * as THREE from 'three';\nimport { LaneGenerator } from './lanes.js';\nimport { Player } from './player.js';\nimport { AudioManager } from './audio.js';\n\nconst canvas = document.getElementById('game-canvas');\nconst scoreEl = document.getElementById('score');\nconst startOverlay = document.getElementById('start-overlay');\nconst gameoverOverlay = document.getElementById('gameover-overlay');\nconst finalScoreEl = document.getElementById('final-score');\nconst bestScoreEl = document.getElementById('best-score');\nconst startBtn = document.getElementById('start-btn');\nconst retryBtn = document.getElementById('retry-btn');\nconst muteBtn = document.getElementById('mute-btn');\n\n// ------------------------------------------------------------------ renderer\nconst renderer = new THREE.WebGLRenderer({ canvas, antialias: true });\nrenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\nrenderer.shadowMap.enabled = true;\nrenderer.shadowMap.type = THREE.PCFSoftShadowMap;\n\nconst scene = new THREE.Scene();\nscene.background = new THREE.Color(0x6ec6e8);\n\n// ------------------------------------------------------------------ camera\n// Crossy Road: orthographic, looking down at ~50°, rotated ~30° around Y\n// so the world reads as a diagonal isometric-ish view.\nconst camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -100, 200);\nconst CAM_OFFSET = new THREE.Vector3(-5.2, 9.2, 6.6); // left-behind-above the player\nconst VIEW_SIZE = 5.4;\n\nfunction resize() {\n  const w = window.innerWidth;\n  const h = window.innerHeight;\n  renderer.setSize(w, h, false);\n  const aspect = w / h;\n  camera.left = -VIEW_SIZE * aspect;\n  camera.right = VIEW_SIZE * aspect;\n  camera.top = VIEW_SIZE;\n  camera.bottom = -VIEW_SIZE;\n  camera.updateProjectionMatrix();\n}\nwindow.addEventListener('resize', resize);\nresize();\n\n// ------------------------------------------------------------------ lights\nscene.add(new THREE.AmbientLight(0xffffff, 0.55));\nconst hemi = new THREE.HemisphereLight(0xcfeaff, 0x9ec96a, 0.35);\nscene.add(hemi);\n\nconst sun = new THREE.DirectionalLight(0xfff4e0, 1.15);\nsun.castShadow = true;\nsun.shadow.mapSize.set(2048, 2048);\nsun.shadow.camera.left = -14;\nsun.shadow.camera.right = 14;\nsun.shadow.camera.top = 14;\nsun.shadow.camera.bottom = -14;\nsun.shadow.camera.near = 1;\nsun.shadow.camera.far = 60;\nsun.shadow.bias = -0.0004;\nscene.add(sun);\nscene.add(sun.target);\n\n// ------------------------------------------------------------------ game state\nconst audio = new AudioManager();\nconst gen = new LaneGenerator(scene);\nconst player = new Player(scene);\n\n// static grass backdrop behind the start row (fills the void below/behind)\nimport { box, createTree } from './models.js';\nconst backdrop = new THREE.Group();\nfor (let r = 1; r <= 8; r++) {\n  backdrop.add(box(30, 0.5, 1, r % 2 ? 0xbcec6a : 0xaadd5c, 0, -0.25, r, false, true));\n  for (let x = -14; x <= 14; x++) {\n    const inPlay = x >= -4 && x <= 4;\n    if (Math.random() < (inPlay ? (r > 1 ? 0.18 : 0) : 0.4)) {\n      const t = createTree(1 + Math.floor(Math.random() * 2));\n      t.position.set(x, 0, r);\n      backdrop.add(t);\n    }\n  }\n}\nscene.add(backdrop);\n\nconst game = {\n  state: 'menu', // 'menu' | 'playing' | 'dead'\n  score: 0,\n  best: Number(localStorage.getItem('vrh-best') || 0),\n  audio,\n  camZ: 0,       // camera forward progress (world z, negative)\n  idleTimer: 0,  // eagle-style pressure: camera creeps forward\n};\n\nbestScoreEl.textContent = game.best;\n\ngen.ensure(18);\n\nfunction setScore(v) {\n  game.score = v;\n  scoreEl.textContent = v;\n}\n\nfunction startGame() {\n  gen.reset();\n  gen.ensure(18);\n  player.reset();\n  setScore(0);\n  game.state = 'playing';\n  game.camZ = 0;\n  game.idleTimer = 0;\n  startOverlay.classList.remove('overlay--visible');\n  gameoverOverlay.classList.remove('overlay--visible');\n  audio.ensure();\n  audio.start();\n}\n\nfunction endGame(type) {\n  if (game.state !== 'playing') return;\n  game.state = 'dead';\n  if (type === 'squash') { player.squash(); audio.squash(); }\n  else if (type === 'drown') { player.drown(); audio.splash(); }\n  else { player.vanish(); audio.splash(); }\n  audio.gameOver();\n  if (game.score > game.best) {\n    game.best = game.score;\n    localStorage.setItem('vrh-best', String(game.best));\n  }\n  finalScoreEl.textContent = game.score;\n  bestScoreEl.textContent = game.best;\n  setTimeout(() => gameoverOverlay.classList.add('overlay--visible'), 650);\n}\n\n// ------------------------------------------------------------------ input\nconst KEYMAP = {\n  ArrowUp: 'up', KeyW: 'up', w: 'up', W: 'up', ' ': 'up', Space: 'up',\n  ArrowDown: 'down', KeyS: 'down', s: 'down', S: 'down',\n  ArrowLeft: 'left', KeyA: 'left', a: 'left', A: 'left',\n  ArrowRight: 'right', KeyD: 'right', d: 'right', D: 'right',\n};\n\nfunction handleDir(dir) {\n  if (game.state === 'menu') { startGame(); return; }\n  if (game.state === 'dead') return;\n  const hopped = player.tryHop(dir, gen);\n  if (hopped) {\n    audio.ensure();\n    audio.hop();\n    game.idleTimer = 0;\n    if (player.row > game.score) {\n      setScore(player.row);\n      if (game.score % 25 === 0) audio.scoreMilestone();\n    }\n  }\n}\n\nwindow.addEventListener('keydown', (e) => {\n  const dir = KEYMAP[e.code] || KEYMAP[e.key];\n  if (!dir) return;\n  e.preventDefault();\n  handleDir(dir);\n});\n\n// tap / swipe\nlet touchStart = null;\ncanvas.addEventListener('pointerdown', (e) => { touchStart = { x: e.clientX, y: e.clientY, t: performance.now() }; });\ncanvas.addEventListener('pointerup', (e) => {\n  if (!touchStart) return;\n  const dx = e.clientX - touchStart.x;\n  const dy = e.clientY - touchStart.y;\n  const dist = Math.hypot(dx, dy);\n  let dir = 'up';\n  if (dist > 24) {\n    if (Math.abs(dx) > Math.abs(dy)) dir = dx > 0 ? 'right' : 'left';\n    else dir = dy > 0 ? 'down' : 'up';\n  }\n  touchStart = null;\n  handleDir(dir);\n});\n\nstartBtn.addEventListener('click', startGame);\nretryBtn.addEventListener('click', startGame);\nmuteBtn.addEventListener('click', () => {\n  audio.ensure();\n  const muted = audio.toggleMute();\n  muteBtn.innerHTML = muted ? '&#128263;' : '&#128266;';\n});\n\n// ------------------------------------------------------------------ loop\nconst clock = new THREE.Clock();\n\nfunction tick() {\n  requestAnimationFrame(tick);\n  const dt = Math.min(clock.getDelta(), 0.05);\n\n  gen.update(dt, game);\n\n  if (game.state === 'playing') {\n    const event = player.update(dt, gen);\n    if (event === 'drown') endGame('drown');\n    else if (event === 'offscreen') endGame('offscreen');\n\n    // vehicle / train collision\n    if (!player.dead) {\n      const lane = gen.laneAt(player.row);\n      if (lane && !player.hopping && lane.hitsVehicle(player.x)) {\n        endGame('squash');\n      } else if (lane && player.hopping) {\n        // mid-hop: check both rows roughly at current position\n        const zRow = Math.round(-player.mesh.position.z);\n        const l2 = gen.laneAt(zRow);\n        if (l2 && l2.hitsVehicle(player.mesh.position.x)) endGame('squash');\n      }\n    }\n\n    // keep world generated ahead, cull behind\n    gen.ensure(player.row + 16);\n    gen.cull(player.row - 10);\n\n    // camera pressure: slowly advance even if idle (the \"eagle\" mechanic, soft version)\n    game.idleTimer += dt;\n    const targetZ = -player.row;\n    game.camZ += (targetZ - game.camZ) * Math.min(1, dt * 4);\n    if (game.idleTimer > 3) game.camZ -= dt * 0.55; // creep forward when idle\n\n    // losing the camera = death (fell too far behind)\n    if (player.z > game.camZ + 5.5) {\n      endGame('offscreen');\n    }\n  }\n\n  // camera follow (also in menu/dead for nice framing)\n  const focusX = THREE.MathUtils.clamp(player.mesh.position.x * 0.4, -1.6, 1.6);\n  const focus = new THREE.Vector3(focusX, 0, game.camZ - 1.5);\n  camera.position.copy(focus).add(CAM_OFFSET);\n  camera.lookAt(focus);\n\n  // sun follows so shadows stay crisp near the player\n  sun.position.set(focus.x - 6, 14, focus.z + 6);\n  sun.target.position.set(focus.x, 0, focus.z);\n\n  renderer.render(scene, camera);\n}\n\ntick();\n"},{"name":"models.js","content":"// Voxel model builders — Crossy Road style blocky meshes.\n// Every model is built from BoxGeometry \"voxels\" with flat-shaded MeshLambertMaterial.\nimport * as THREE from 'three';\n\nconst matCache = new Map();\nexport function mat(color) {\n  if (!matCache.has(color)) {\n    matCache.set(color, new THREE.MeshLambertMaterial({ color }));\n  }\n  return matCache.get(color);\n}\n\nexport function box(w, h, d, color, x = 0, y = 0, z = 0, castShadow = true, receiveShadow = true) {\n  const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat(color));\n  m.position.set(x, y, z);\n  m.castShadow = castShadow;\n  m.receiveShadow = receiveShadow;\n  return m;\n}\n\n// ---------------------------------------------------------------- Chicken\n// The iconic white chicken. Units: 1 tile = 1 world unit.\nexport function createChicken() {\n  const g = new THREE.Group();\n\n  const WHITE = 0xfdfdfd;\n  const DARK = 0x4a4a4a;\n  const RED = 0xe53b2c;\n  const ORANGE = 0xf2a000;\n\n  // body\n  g.add(box(0.52, 0.42, 0.66, WHITE, 0, 0.46, 0));\n  // head block\n  g.add(box(0.4, 0.5, 0.32, WHITE, 0, 0.86, -0.12));\n  // comb\n  g.add(box(0.12, 0.14, 0.22, RED, 0, 1.17, -0.12));\n  // beak\n  g.add(box(0.14, 0.12, 0.14, ORANGE, 0, 0.92, -0.34));\n  // wattle\n  g.add(box(0.1, 0.1, 0.08, RED, 0, 0.8, -0.3));\n  // eyes\n  g.add(box(0.05, 0.08, 0.08, DARK, 0.205, 0.96, -0.18));\n  g.add(box(0.05, 0.08, 0.08, DARK, -0.205, 0.96, -0.18));\n  // wings\n  g.add(box(0.08, 0.26, 0.4, 0xe8e8e8, 0.3, 0.5, 0.04));\n  g.add(box(0.08, 0.26, 0.4, 0xe8e8e8, -0.3, 0.5, 0.04));\n  // tail\n  g.add(box(0.3, 0.24, 0.12, 0xe8e8e8, 0, 0.62, 0.36));\n  // legs\n  g.add(box(0.07, 0.25, 0.07, ORANGE, 0.12, 0.13, 0.05));\n  g.add(box(0.07, 0.25, 0.07, ORANGE, -0.12, 0.13, 0.05));\n\n  return g;\n}\n\n// ---------------------------------------------------------------- Cars\nconst CAR_COLORS = [0xe8431f, 0x2f9be0, 0xf2b200, 0x8e4fd0, 0x39b54a, 0xff7ab8];\n\nexport function createCar(colorIndex = 0) {\n  const g = new THREE.Group();\n  const color = CAR_COLORS[colorIndex % CAR_COLORS.length];\n  const DARK = 0x2a2a2e;\n  const GLASS = 0xbfe6f5;\n\n  // chassis\n  g.add(box(0.95, 0.32, 1.7, color, 0, 0.36, 0));\n  // cabin\n  g.add(box(0.85, 0.32, 0.85, color, 0, 0.66, 0.06));\n  // windows (slightly inset)\n  g.add(box(0.87, 0.2, 0.6, GLASS, 0, 0.68, 0.06));\n  // wheels\n  const wy = 0.16;\n  g.add(box(0.18, 0.3, 0.3, DARK, 0.45, wy, -0.5));\n  g.add(box(0.18, 0.3, 0.3, DARK, -0.45, wy, -0.5));\n  g.add(box(0.18, 0.3, 0.3, DARK, 0.45, wy, 0.5));\n  g.add(box(0.18, 0.3, 0.3, DARK, -0.45, wy, 0.5));\n  // headlights\n  g.add(box(0.14, 0.1, 0.05, 0xfff6c9, 0.3, 0.36, -0.87));\n  g.add(box(0.14, 0.1, 0.05, 0xfff6c9, -0.3, 0.36, -0.87));\n\n  g.userData.length = 1.8;\n  return g;\n}\n\nexport function createTruck() {\n  const g = new THREE.Group();\n  const CAB = 0x2f9be0;\n  const BODY = 0xf4f4f4;\n  const DARK = 0x2a2a2e;\n\n  // cab\n  g.add(box(0.95, 0.6, 0.8, CAB, 0, 0.52, -1.0));\n  g.add(box(0.85, 0.22, 0.6, 0xbfe6f5, 0, 0.72, -1.0));\n  // cargo box\n  g.add(box(1.0, 0.85, 1.9, BODY, 0, 0.66, 0.45));\n  // chassis bar\n  g.add(box(0.8, 0.16, 2.9, DARK, 0, 0.18, 0));\n  // wheels\n  const wy = 0.16;\n  [-1.15, 0.0, 1.05].forEach((z) => {\n    g.add(box(0.18, 0.3, 0.32, DARK, 0.46, wy, z));\n    g.add(box(0.18, 0.3, 0.32, DARK, -0.46, wy, z));\n  });\n  // headlights\n  g.add(box(0.14, 0.1, 0.05, 0xfff6c9, 0.3, 0.42, -1.42));\n  g.add(box(0.14, 0.1, 0.05, 0xfff6c9, -0.3, 0.42, -1.42));\n\n  g.userData.length = 3.0;\n  return g;\n}\n\n// ---------------------------------------------------------------- Train\nexport function createTrain(carCount = 4) {\n  const g = new THREE.Group();\n  const BODY = 0xd6453a;\n  const DARK = 0x33333a;\n  const carLen = 2.4;\n  for (let i = 0; i < carCount; i++) {\n    const z = (i - (carCount - 1) / 2) * (carLen + 0.18);\n    g.add(box(0.95, 0.7, carLen, BODY, 0, 0.62, z));\n    g.add(box(0.99, 0.18, carLen, DARK, 0, 0.2, z));\n    g.add(box(0.97, 0.22, 0.7, 0xf0d9b5, 0, 0.84, z - 0.5));\n    g.add(box(0.97, 0.22, 0.7, 0xf0d9b5, 0, 0.84, z + 0.5));\n  }\n  g.userData.length = carCount * (carLen + 0.18);\n  return g;\n}\n\n// ---------------------------------------------------------------- Log\nexport function createLog(tiles = 2) {\n  const g = new THREE.Group();\n  const BROWN = 0x9a6433;\n  const len = tiles * 1.0 - 0.1;\n  g.add(box(0.8, 0.3, len, BROWN, 0, 0.0, 0));\n  // end caps (lighter)\n  g.add(box(0.7, 0.22, 0.06, 0xc89055, 0, 0.0, len / 2 + 0.01, false));\n  g.add(box(0.7, 0.22, 0.06, 0xc89055, 0, 0.0, -len / 2 - 0.01, false));\n  g.userData.length = len;\n  return g;\n}\n\n// ---------------------------------------------------------------- Tree\nexport function createTree(height = 1) {\n  const g = new THREE.Group();\n  const TRUNK = 0x8a5a2b;\n  const LEAF = 0x3eb24a;\n  const LEAF_DARK = 0x2f9c3c;\n\n  g.add(box(0.3, 0.5, 0.3, TRUNK, 0, 0.25, 0));\n  let y = 0.5;\n  const layers = 1 + height;\n  for (let i = 0; i < layers; i++) {\n    const s = 0.85 - i * 0.18;\n    const h = 0.42;\n    g.add(box(s, h, s, i % 2 ? LEAF_DARK : LEAF, 0, y + h / 2, 0));\n    y += h;\n  }\n  return g;\n}\n\n// ---------------------------------------------------------------- Rock\nexport function createRock() {\n  const g = new THREE.Group();\n  g.add(box(0.55, 0.4, 0.55, 0xb8bcc2, 0, 0.2, 0));\n  g.add(box(0.35, 0.25, 0.35, 0xcdd2d8, 0.08, 0.5, -0.05));\n  return g;\n}\n\n// ---------------------------------------------------------------- Lily pad (decor on river edges) / coin not in original — skip.\n\n// ---------------------------------------------------------------- Rail signal\nexport function createSignal() {\n  const g = new THREE.Group();\n  const POLE = 0x5b5b63;\n  g.add(box(0.12, 1.1, 0.12, POLE, 0, 0.55, 0));\n  g.add(box(0.5, 0.3, 0.12, 0x3a3a40, 0, 1.18, 0));\n  const left = box(0.16, 0.16, 0.06, 0x550000, -0.12, 1.18, -0.06, false);\n  const right = box(0.16, 0.16, 0.06, 0x550000, 0.12, 1.18, -0.06, false);\n  left.material = new THREE.MeshLambertMaterial({ color: 0x550000 });\n  right.material = new THREE.MeshLambertMaterial({ color: 0x550000 });\n  g.add(left, right);\n  g.userData.lampLeft = left;\n  g.userData.lampRight = right;\n  return g;\n}\n"},{"name":"player.js","content":"// Player — the hopping chicken. Grid moves with a squash-and-stretch hop arc.\nimport * as THREE from 'three';\nimport { createChicken } from './models.js';\nimport { PLAY_MIN_X, PLAY_MAX_X } from './lanes.js';\n\nconst HOP_TIME = 0.16;\nconst HOP_HEIGHT = 0.45;\n\nexport class Player {\n  constructor(scene) {\n    this.mesh = createChicken();\n    scene.add(this.mesh);\n    this.reset();\n  }\n\n  reset() {\n    this.row = 0;          // forward grid row (z = -row)\n    this.col = 0;          // sideways tile\n    this.x = 0;            // continuous x (drifts on logs)\n    this.hopping = false;\n    this.hopT = 0;\n    this.from = { x: 0, z: 0 };\n    this.to = { x: 0, z: 0 };\n    this.dead = false;\n    this.deathType = null;\n    this.onLog = null;     // log mover we're riding\n    this.logOffset = 0;\n    this.facing = 0;       // radians; 0 = forward (-Z)\n    this.mesh.visible = true;\n    this.mesh.scale.set(1, 1, 1);\n    this.mesh.position.set(0, 0, 0);\n    this.mesh.rotation.set(0, 0, 0);\n    this.queued = null;\n  }\n\n  get z() { return -this.row; }\n\n  // Attempt a hop. dir: 'up' | 'down' | 'left' | 'right'.\n  // Returns true if hop started.\n  tryHop(dir, gen) {\n    if (this.dead) return false;\n    if (this.hopping) { this.queued = dir; return false; }\n\n    let dRow = 0, dCol = 0, face = this.facing;\n    if (dir === 'up') { dRow = 1; face = 0; }\n    else if (dir === 'down') { dRow = -1; face = Math.PI; }\n    else if (dir === 'left') { dCol = -1; face = Math.PI / 2; }\n    else if (dir === 'right') { dCol = 1; face = -Math.PI / 2; }\n\n    const targetRow = this.row + dRow;\n    if (targetRow < 0) { this.facing = face; this.bump(); return false; }\n\n    // current x rounded for grid targeting (we may be drifting on a log)\n    const baseX = this.onLog ? this.x : this.col;\n    let targetX = Math.round(baseX) + dCol;\n    if (targetX < PLAY_MIN_X || targetX > PLAY_MAX_X) { this.facing = face; this.bump(); return false; }\n\n    const targetLane = gen.laneAt(targetRow);\n    if (!targetLane) return false;\n    if (targetLane.isBlocked(targetX)) { this.facing = face; this.bump(); return false; }\n\n    // start the hop\n    this.facing = face;\n    this.from.x = this.onLog ? this.x : this.col;\n    this.from.z = this.z;\n    this.row = targetRow;\n    this.col = targetX;\n    this.to.x = targetX;\n    this.to.z = -targetRow;\n    this.onLog = null;\n    this.hopping = true;\n    this.hopT = 0;\n    return true;\n  }\n\n  bump() {\n    // small refused-move wiggle\n    this.mesh.rotation.y = this.facing;\n  }\n\n  // dt update; gen = LaneGenerator. Returns event string or null:\n  // 'landed' | 'drown' | 'offscreen'\n  update(dt, gen) {\n    if (this.dead) return null;\n    let event = null;\n\n    if (this.hopping) {\n      this.hopT += dt / HOP_TIME;\n      const t = Math.min(this.hopT, 1);\n      const x = this.from.x + (this.to.x - this.from.x) * t;\n      const z = this.from.z + (this.to.z - this.from.z) * t;\n      const y = Math.sin(t * Math.PI) * HOP_HEIGHT;\n      this.x = x;\n      this.mesh.position.set(x, y, z);\n      // squash & stretch\n      const stretch = 1 + Math.sin(t * Math.PI) * 0.18;\n      this.mesh.scale.set(1 / Math.sqrt(stretch), stretch, 1 / Math.sqrt(stretch));\n\n      if (t >= 1) {\n        this.hopping = false;\n        this.mesh.scale.set(1, 1, 1);\n        this.mesh.position.y = 0;\n        event = 'landed';\n        // landing logic: river → must be on a log\n        const lane = gen.laneAt(this.row);\n        if (lane && lane.type === 'river') {\n          const log = lane.logAt(this.x);\n          if (log) {\n            this.onLog = log;\n            this.logOffset = this.x - log.mesh.position.x;\n            this.mesh.position.y = 0.18;\n          } else {\n            event = 'drown';\n          }\n        }\n        if (this.queued && event !== 'drown') {\n          const q = this.queued;\n          this.queued = null;\n          // small delay feel is fine — hop immediately\n          this.tryHop(q, gen);\n        } else {\n          this.queued = null;\n        }\n      }\n    } else if (this.onLog) {\n      // drift with the log\n      this.x = this.onLog.mesh.position.x + this.logOffset;\n      this.mesh.position.x = this.x;\n      this.mesh.position.y = 0.18;\n      if (this.x < PLAY_MIN_X - 1.2 || this.x > PLAY_MAX_X + 1.2) {\n        event = 'offscreen';\n      }\n    } else {\n      this.x = this.col;\n      this.mesh.position.set(this.x, 0, this.z);\n    }\n\n    // smooth facing rotation\n    const target = this.facing;\n    let diff = target - this.mesh.rotation.y;\n    while (diff > Math.PI) diff -= Math.PI * 2;\n    while (diff < -Math.PI) diff += Math.PI * 2;\n    this.mesh.rotation.y += diff * Math.min(1, dt * 18);\n\n    return event;\n  }\n\n  // death animations\n  squash() {\n    this.dead = true;\n    this.deathType = 'squash';\n    this.mesh.scale.set(1.6, 0.12, 1.6);\n    this.mesh.position.y = 0;\n  }\n\n  drown() {\n    this.dead = true;\n    this.deathType = 'drown';\n    this.mesh.position.y = -0.45;\n    this.mesh.scale.set(0.9, 0.5, 0.9);\n  }\n\n  vanish() {\n    this.dead = true;\n    this.deathType = 'offscreen';\n    this.mesh.visible = false;\n  }\n}\n"}],"folders":[]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[]}]},"variants":null,"createdAt":"2026-06-10T12:08:47.058Z","updatedAt":"2026-06-10T12:08:55.385Z","hasThumbnail":true}}