// Sled Volt renderer: a low-poly snowy mountain drawn with three.js r170 (vendored, MIT). Every model is built here // from primitives with vertex colours (no downloaded art), drawn with InstancedMesh pools so a busy screen stays near // 40 draw calls. The world bends down and sideways ahead of you in the vertex shader (the "rolling snow" look). // Reads the run state from core.js; never changes it. (c) 2026 CyberMax. All rights reserved. import * as THREE from './vendor/three.module.min.js'; import { LANE_W, KINDS, BIOME_LEN, biomeAt, itemX, itemZ, SKY_Y, RAIL_Y } from './core.js'; const AHEAD = 250, BEHIND = 18; const C = (h) => new THREE.Color(h); const BEND = { value: new THREE.Vector2(0.0009, 0) }; // ------------------------------------------------------------------ geometry helpers function part(geo, color, m) { let g = geo.index ? geo.toNonIndexed() : geo.clone(); if (m) g.applyMatrix4(m); const n = g.attributes.position.count; const col = new Float32Array(n * 3); const c = C(color); for (let i = 0; i < n; i++) { col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; } g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.deleteAttribute('uv'); g.deleteAttribute('normal'); return g; } function merge(parts) { let n = 0; for (const p of parts) n += p.attributes.position.count; const pos = new Float32Array(n * 3), col = new Float32Array(n * 3); let o = 0; for (const p of parts) { pos.set(p.attributes.position.array, o * 3); col.set(p.attributes.color.array, o * 3); o += p.attributes.position.count; } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.computeVertexNormals(); // non-indexed: flat facets g.computeBoundingSphere(); return g; } const M4 = () => new THREE.Matrix4(); function T(x, y, z, rx = 0, ry = 0, rz = 0, sx = 1, sy = 1, sz = 1) { return M4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)); } const box = (w, h, d) => new THREE.BoxGeometry(w, h, d); const cyl = (rt, rb, h, s = 7) => new THREE.CylinderGeometry(rt, rb, h, s); const cone = (r, h, s = 7) => new THREE.ConeGeometry(r, h, s); const ico = (r, d = 0) => new THREE.IcosahedronGeometry(r, d); const sph = (r, w = 7, h = 5) => new THREE.SphereGeometry(r, w, h); const oct = (r) => new THREE.OctahedronGeometry(r); const dod = (r) => new THREE.DodecahedronGeometry(r); function bendify(mat) { mat.onBeforeCompile = (sh) => { sh.uniforms.uBend = BEND; sh.vertexShader = 'uniform vec2 uBend;\n' + sh.vertexShader.replace('#include ', '#include \n float bz = min(0.0, mvPosition.z + 10.0);\n mvPosition.y -= bz * bz * uBend.x;\n mvPosition.x += bz * bz * uBend.y;\n gl_Position = projectionMatrix * mvPosition;'); }; mat.customProgramCacheKey = () => 'bend'; return mat; } // ------------------------------------------------------------------ models (all original, built from code) const SNOW = '#F4F8FF', SNOW2 = '#DCE8F7', PINE = '#2F7D5B', PINE2 = '#3E9A6C', BARK = '#7A4E2D', ROCK = '#8B93A6', ICE = '#9FE3FF', ICE2 = '#6CC6F0'; function mTree(s = 1) { return merge([ part(cyl(0.45, 0.6, 2.4), BARK, T(0, 1.2, 0)), part(cone(3.0, 4.2), PINE, T(0, 4.0, 0)), part(cone(2.3, 3.6), PINE2, T(0, 6.4, 0)), part(cone(1.6, 3.0), PINE, T(0, 8.6, 0)), part(cone(1.25, 1.2), SNOW, T(0, 10.4, 0)), part(cone(2.25, 0.7, 7), SNOW, T(0, 5.95, 0)), part(cone(1.6, 0.6, 7), SNOW, T(0, 8.05, 0)), ].map((g) => (s !== 1 ? g.applyMatrix4(T(0, 0, 0, 0, 0, 0, s, s, s)) : g))); } function mRock() { return merge([part(dod(1.5), ROCK, T(0, 1.15, 0, 0.3, 0.5, 0, 1.25, 1.0, 1.0)), part(dod(1.1), '#A5ADBE', T(0.9, 0.8, 0.3, 0.6, 0.2, 0, 0.9, 0.8, 0.9)), part(sph(1.15, 7, 3), SNOW, T(-0.1, 2.15, 0, 0, 0, 0, 1.2, 0.45, 1.0))]); } function mLog() { return merge([part(cyl(0.75, 0.75, 5.0, 8), BARK, T(0, 0.75, 0, 0, 0, Math.PI / 2)), part(cyl(0.6, 0.6, 5.05, 8), '#D9A066', T(0, 0.75, 0, 0, 0, Math.PI / 2, 1, 1, 0.2)), part(box(1.0, 0.25, 0.25), '#FFD233', T(-1.6, 1.52, 0)), part(box(1.0, 0.25, 0.25), '#222', T(-0.6, 1.52, 0)), part(box(1.0, 0.25, 0.25), '#FFD233', T(0.4, 1.52, 0)), part(box(1.0, 0.25, 0.25), '#222', T(1.4, 1.52, 0)), part(sph(0.9, 7, 3), SNOW, T(0, 1.45, 0, 0, 0, 0, 2.2, 0.35, 0.8))]); } function mFence() { const p = []; for (const x of [-2.4, 0, 2.4]) p.push(part(box(0.35, 2.2, 0.35), '#8A5A33', T(x, 1.1, 0))); for (let i = 0; i < 6; i++) p.push(part(box(0.86, 0.42, 0.22), i % 2 ? '#1E1E24' : '#FFC928', T(-2.15 + i * 0.86, 1.85, 0))); p.push(part(box(5.2, 0.32, 0.2), '#B07A48', T(0, 1.0, 0))); p.push(part(box(5.4, 0.22, 0.5), SNOW, T(0, 2.12, 0))); return merge(p); } function mBranch() { return merge([part(cyl(0.38, 0.5, 6.4, 7), BARK, T(0, 3.0, 0, 0, 0, Math.PI / 2 + 0.06)), part(box(6.2, 0.3, 0.8), SNOW, T(0, 3.42, 0)), part(cone(0.7, 1.3, 5), PINE, T(-2.6, 3.3, 0, 0, 0, Math.PI / 2)), part(cone(0.6, 1.0, 5), PINE2, T(2.4, 3.2, 0.2, 0, 0, -Math.PI / 2)), part(box(0.05, 1.0, 0.9), '#FF6A1A', T(-1.2, 2.25, 0, 0, 0, 0.05)), part(box(0.05, 1.0, 0.9), '#FF6A1A', T(1.2, 2.25, 0, 0, 0, -0.05)), part(box(0.6, 0.6, 0.05), '#FF6A1A', T(0, 2.3, 0, 0, 0, Math.PI / 4))]); } function mBanner() { const p = [part(box(0.25, 4.6, 0.25), '#5B3A21', T(-2.9, 2.3, 0)), part(box(0.25, 4.6, 0.25), '#5B3A21', T(2.9, 2.3, 0)), part(box(5.8, 0.1, 0.1), '#333', T(0, 3.95, 0))]; const cols = ['#FF4D4D', '#FFC928', '#3FD18A', '#4DA6FF', '#B26BFF', '#FF8A3D']; for (let i = 0; i < 6; i++) p.push(part(cone(0.42, 1.25, 3), cols[i], T(-2.4 + i * 0.96, 3.3, 0, Math.PI, 0, 0))); p.push(part(box(5.8, 0.6, 0.25), '#FF6A1A', T(0, 2.4, 0, 0, 0, 0, 1, 0.2, 1))); return merge(p); } function mIcicles() { const p = [part(box(6.0, 0.7, 1.2), '#7FB8D8', T(0, 4.3, 0)), part(box(6.2, 0.25, 1.3), SNOW, T(0, 4.7, 0))]; for (let i = 0; i < 9; i++) p.push(part(cone(0.32, 1.7 + (i % 3) * 0.35, 5), i % 2 ? ICE : '#C9F1FF', T(-2.6 + i * 0.65, 3.0 - (i % 3) * 0.17, (i % 2) * 0.3 - 0.15, Math.PI, 0, 0))); p.push(part(box(0.05, 0.7, 0.7), '#FF6A1A', T(-2.9, 3.9, 0.7)), part(box(0.05, 0.7, 0.7), '#FF6A1A', T(2.9, 3.9, 0.7))); return merge(p); } function mBush() { return merge([part(ico(1.2, 0), '#3E8F5E', T(-0.8, 0.8, 0)), part(ico(1.4, 0), '#4FA56C', T(0.6, 0.9, 0.2)), part(sph(1.0, 6, 3), SNOW, T(0, 1.6, 0, 0, 0, 0, 1.7, 0.5, 1))]); } function mDrift() { return merge([part(sph(1.6, 8, 4), SNOW, T(0, 0.15, 0, 0, 0, 0, 1.7, 0.6, 1.1)), part(sph(1.0, 7, 3), SNOW2, T(0.9, 0.3, 0.3, 0, 0, 0, 1, 0.5, 1))]); } function mSnowman() { return merge([part(sph(1.5, 9, 6), SNOW, T(0, 1.4, 0)), part(sph(1.1, 9, 6), SNOW, T(0, 3.5, 0)), part(sph(0.8, 9, 6), SNOW, T(0, 5.0, 0)), part(cone(0.18, 0.9, 6), '#FF7A1A', T(0, 5.0, 0.95, Math.PI / 2, 0, 0)), part(sph(0.1, 5, 3), '#222', T(-0.3, 5.25, 0.68)), part(sph(0.1, 5, 3), '#222', T(0.3, 5.25, 0.68)), part(cyl(0.55, 0.55, 0.7, 8), '#222', T(0, 6.0, 0)), part(cyl(0.85, 0.85, 0.1, 8), '#222', T(0, 5.68, 0)), part(cyl(0.9, 0.9, 0.35, 8), '#E8413C', T(0, 4.25, 0)), part(box(0.35, 1.2, 0.12), '#E8413C', T(0.5, 3.8, 0.8, 0.2, 0, 0.1)), part(cyl(0.08, 0.08, 2.0, 4), BARK, T(1.6, 3.6, 0, 0, 0, 1.0)), part(cyl(0.08, 0.08, 2.0, 4), BARK, T(-1.6, 3.6, 0, 0, 0, -1.0))]); } function mSled(color, accent) { return [part(box(2.2, 0.32, 4.4), color, T(0, 0.55, 0)), part(box(2.0, 0.28, 0.9), accent, T(0, 0.8, -1.9, -0.5, 0, 0)), part(box(0.18, 0.18, 4.8), '#3A3F55', T(-0.95, 0.15, 0.1)), part(box(0.18, 0.18, 4.8), '#3A3F55', T(0.95, 0.15, 0.1)), part(box(0.18, 0.5, 0.18), '#3A3F55', T(-0.95, 0.35, -1.2)), part(box(0.18, 0.5, 0.18), '#3A3F55', T(0.95, 0.35, -1.2)), part(box(0.18, 0.5, 0.18), '#3A3F55', T(-0.95, 0.35, 1.3)), part(box(0.18, 0.5, 0.18), '#3A3F55', T(0.95, 0.35, 1.3)), part(cyl(0.12, 0.12, 2.0, 5), '#3A3F55', T(0, 0.15, -2.45, 0, 0, Math.PI / 2)), part(box(2.24, 0.1, 0.5), accent, T(0, 0.73, 0.6))]; } function mRider(r) { const p = [part(cyl(0.75, 0.95, 1.7, 8), r.coat, T(0, 1.75, 0.3)), part(sph(0.62, 8, 6), r.skin, T(0, 2.95, 0.2)), part(cyl(0.68, 0.7, 0.18, 8), r.coat, T(0, 2.45, 0.25)), part(sph(0.64, 8, 4), r.hat, T(0, 3.2, 0.2, 0, 0, 0, 1, 0.75, 1)), part(sph(0.22, 6, 4), '#FFFFFF', T(0, 3.72, 0.2)), part(box(1.5, 0.35, 0.35), r.hat, T(0, 2.42, -0.15)), part(cyl(0.22, 0.22, 1.3, 6), r.coat, T(-0.7, 1.9, -0.25, 1.1, 0, 0.2)), part(cyl(0.22, 0.22, 1.3, 6), r.coat, T(0.7, 1.9, -0.25, 1.1, 0, -0.2)), part(sph(0.22, 5, 3), '#2B2F45', T(-0.75, 1.55, -0.85)), part(sph(0.22, 5, 3), '#2B2F45', T(0.75, 1.55, -0.85)), part(box(0.5, 0.5, 1.6), '#2B2F45', T(-0.4, 0.95, -0.6)), part(box(0.5, 0.5, 1.6), '#2B2F45', T(0.4, 0.95, -0.6)), part(sph(0.09, 4, 3), '#222', T(-0.22, 3.0, -0.38)), part(sph(0.09, 4, 3), '#222', T(0.22, 3.0, -0.38))]; if (r.ears) p.push(part(sph(0.22, 5, 3), r.hat, T(-0.45, 3.55, 0.2)), part(sph(0.22, 5, 3), r.hat, T(0.45, 3.55, 0.2))); if (r.shades) p.push(part(box(0.95, 0.2, 0.1), '#111', T(0, 3.0, -0.42))); if (r.yeti) p.push(part(cone(0.16, 0.5, 5), '#FFFFFF', T(-0.42, 3.6, 0.2, 0, 0, 0.3)), part(cone(0.16, 0.5, 5), '#FFFFFF', T(0.42, 3.6, 0.2, 0, 0, -0.3))); return p; } function mSledder() { return merge([...mSled('#3D7BFF', '#FFFFFF'), ...mRider({ coat: '#9B59FF', hat: '#FFD233', skin: '#E9B892' }), part(box(0.5, 0.5, 0.1), '#FF3B3B', T(-0.8, 0.9, 2.25)), part(box(0.5, 0.5, 0.1), '#FF3B3B', T(0.8, 0.9, 2.25))]); } function mPost() { return merge([part(cyl(0.2, 0.3, 8.4, 6), '#2B2F45', T(0, 4.2, 0)), part(box(1.6, 0.2, 0.2), '#2B2F45', T(0.6, 8.3, 0)), part(cyl(0.55, 0.75, 0.6, 6), '#2B2F45', T(1.3, 8.0, 0)), part(cyl(0.5, 0.6, 0.9, 6), '#3A3F55', T(0, 0.45, 0)), part(cyl(0.12, 0.12, 2.2, 4), '#E8413C', T(0, 6.0, 0.25, 0, 0, 0.6)), part(box(1.2, 0.5, 0.1), '#3FD18A', T(0, 6.6, 0.25))]); } function mPillar() { return merge([part(cyl(1.2, 1.6, 14, 6), ICE2, T(0, 7, 0)), part(cyl(0.9, 1.25, 14.2, 6), '#B8EEFF', T(0.25, 7.1, 0.2, 0, 0.4, 0, 0.6, 1, 0.6)), part(cone(1.0, 2.2, 6), '#D6F6FF', T(0.6, 1.0, 0.6, 0.2, 0, -0.3))]); } function mCrystal() { return merge([part(oct(1.3), '#7EDCFF', T(0, 1.4, 0, 0, 0, 0, 0.8, 1.4, 0.8)), part(oct(1.0), '#B48CFF', T(-1.0, 0.9, 0.3, 0, 0.5, 0.4, 0.7, 1.2, 0.7)), part(oct(0.9), '#8CF0FF', T(1.1, 0.8, -0.2, 0, 0.2, -0.4, 0.7, 1.1, 0.7)), part(box(4.2, 0.18, 0.5), '#FFC928', T(0, 0.12, 0.9))]); } function mIcelog() { return merge([part(box(5.4, 1.4, 1.5), ICE2, T(0, 0.7, 0)), part(box(5.5, 0.35, 1.6), '#C9F1FF', T(0, 1.45, 0)), part(box(0.9, 0.36, 0.1), '#FFC928', T(-1.8, 0.8, 0.8)), part(box(0.9, 0.36, 0.1), '#1E1E24', T(-0.6, 0.8, 0.8)), part(box(0.9, 0.36, 0.1), '#FFC928', T(0.6, 0.8, 0.8)), part(box(0.9, 0.36, 0.1), '#1E1E24', T(1.8, 0.8, 0.8))]); } function mBoulder() { return merge([part(ico(1.6, 0), '#A9DDF2', T(0, 1.3, 0, 0.4, 0.3, 0, 1.2, 0.85, 1)), part(sph(1.0, 6, 3), SNOW, T(0, 2.3, 0, 0, 0, 0, 1.5, 0.45, 1.2))]); } function mBall() { return merge([part(ico(2.2, 1), SNOW, T(0, 2.2, 0)), part(ico(0.8, 0), SNOW2, T(1.2, 3.4, 0.5)), part(ico(0.6, 0), '#C8D6EA', T(-1.4, 1.2, 1.0))]); } function mPylon() { return merge([part(box(1.0, 13, 1.0), '#5C6478', T(0, 6.5, 0)), part(box(1.6, 0.6, 1.6), '#3A3F55', T(0, 0.3, 0)), part(box(7.0, 0.5, 0.5), '#E8413C', T(0, 12.8, 0)), part(cyl(0.35, 0.35, 0.5, 8), '#222', T(-3.2, 12.4, 0, Math.PI / 2, 0, 0)), part(cyl(0.35, 0.35, 0.5, 8), '#222', T(3.2, 12.4, 0, Math.PI / 2, 0, 0)), part(box(1.2, 0.4, 0.1), '#FFC928', T(0, 4.0, 0.55))]); } function mRamp() { const g = new THREE.BufferGeometry(); const v = [-2.4, 0, 3, 2.4, 0, 3, 2.4, 2.2, -3, -2.4, 0, 3, 2.4, 2.2, -3, -2.4, 2.2, -3, -2.4, 0, -3, 2.4, 2.2, -3, 2.4, 0, -3, -2.4, 0, -3, -2.4, 2.2, -3, 2.4, 2.2, -3, -2.4, 0, 3, -2.4, 2.2, -3, -2.4, 0, -3, 2.4, 0, 3, 2.4, 0, -3, 2.4, 2.2, -3]; g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(v), 3)); const w = part(g, '#FF8A1F'); const p = [w]; for (let i = 0; i < 3; i++) p.push(part(box(4.7, 0.06, 0.7), i % 2 ? '#1E1E24' : '#FFE14D', T(0, 0.45 + i * 0.66, 1.7 - i * 1.8, -0.35, 0, 0))); return merge(p); } function mRail() { return merge([part(box(0.5, 0.35, 1), '#C0C7D6', T(0, RAIL_Y - 0.1, 0)), part(box(0.18, 0.12, 1), '#FFFFFF', T(0, RAIL_Y + 0.12, 0))]); } function mRailPost() { return merge([part(box(0.25, RAIL_Y - 0.2, 0.25), '#5C6478', T(0, (RAIL_Y - 0.2) / 2, 0)), part(box(1.1, 0.15, 0.6), '#3A3F55', T(0, 0.07, 0))]); } function mGap() { return merge([part(box(5.4, 0.1, 9.0), '#0E1A33', T(0, 0.03, 4.5)), part(box(5.6, 0.18, 0.5), '#9FE3FF', T(0, 0.08, 0.1)), part(box(5.6, 0.18, 0.5), '#9FE3FF', T(0, 0.08, 8.9)), part(box(4.2, 0.1, 6.0), '#06101F', T(0, 0.05, 4.5))]); } function mHole() { return merge([part(cyl(2.7, 2.7, 0.1, 10), '#C9F1FF', T(0, 0.04, 4.5, 0, 0, 0, 1, 1, 1.75)), part(cyl(2.3, 2.3, 0.12, 10), '#0B2A4A', T(0, 0.06, 4.5, 0, 0, 0, 1, 1, 1.8)), part(cyl(1.4, 1.4, 0.14, 8), '#06182E', T(0.3, 0.07, 4.2, 0, 0, 0, 1, 1, 1.9))]); } function mStar() { const sh = new THREE.Shape(); for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI * 2 + Math.PI / 2; const r = i % 2 ? 0.48 : 1.0; i ? sh.lineTo(Math.cos(a) * r, Math.sin(a) * r) : sh.moveTo(Math.cos(a) * r, Math.sin(a) * r); } const g = new THREE.ExtrudeGeometry(sh, { depth: 0.22, bevelEnabled: false }); g.translate(0, 0, -0.11); return merge([part(g, '#FFC63A'), part(cyl(0.3, 0.3, 0.3, 6), '#FFF3B0', T(0, 0, 0, Math.PI / 2, 0, 0))]); } function mFrost() { return merge([part(oct(0.9), '#55E6FF', T(0, 0, 0, 0, 0, 0, 0.75, 1.3, 0.75)), part(oct(0.5), '#E8FFFF', T(0, 0, 0, 0, 0.8, 0, 0.9, 1.5, 0.9))]); } function mTile() { return merge([part(box(LANE_W, 0.4, 10.2), '#FFFFFF', T(0, -0.2, 0)), part(box(0.22, 0.42, 10.2), '#D4E0F0', T(-LANE_W / 2 + 0.11, -0.19, 0)), part(box(0.22, 0.42, 10.2), '#D4E0F0', T(LANE_W / 2 - 0.11, -0.19, 0))]); } function mPlank() { const p = []; for (let i = 0; i < 5; i++) p.push(part(box(LANE_W - 0.2, 0.35, 1.8), i % 2 ? '#A0703F' : '#B98250', T(0, -0.15, -4 + i * 2.05))); p.push(part(box(0.3, 0.4, 10.2), '#5E3B1E', T(-LANE_W / 2 + 0.2, -0.1, 0)), part(box(0.3, 0.4, 10.2), '#5E3B1E', T(LANE_W / 2 - 0.2, -0.1, 0))); return merge(p); } function mBank() { return merge([part(box(10, 1.6, 10.2), '#FFFFFF', T(5.0, 0.2, 0)), part(box(6, 3.2, 10.2), '#FFFFFF', T(9.5, 1.0, 0, 0, 0, 0.15)), part(box(70, 1, 10.2), '#FFFFFF', T(46, 1.6, 0))]); } function mBridgeRail() { return merge([part(box(0.3, 2.6, 0.3), '#5E3B1E', T(0, 1.3, 0)), part(box(0.18, 0.18, 10.2), '#C9A26B', T(0, 2.4, 0)), part(box(0.12, 0.12, 10.2), '#C9A26B', T(0, 1.4, 0))]); } function mArch() { return merge([part(box(3, 16, 10.4), '#5E7FA8', T(-12.5, 8, 0)), part(box(3, 16, 10.4), '#5E7FA8', T(12.5, 8, 0)), part(box(28, 2.5, 10.4), '#4C6A92', T(0, 16.5, 0)), part(oct(2.2), '#8FE3FF', T(-10.6, 2, 2, 0, 0.3, 0.2, 0.7, 1.6, 0.7)), part(oct(1.6), '#B48CFF', T(10.8, 3, -2, 0, 0.6, -0.3, 0.7, 1.6, 0.7)), part(cone(0.6, 2.4, 5), '#C9F1FF', T(-5, 14.2, 1, Math.PI, 0, 0)), part(cone(0.5, 1.8, 5), '#C9F1FF', T(4, 14.5, -2, Math.PI, 0, 0)), part(cone(0.7, 2.8, 5), '#C9F1FF', T(8, 14.0, 3, Math.PI, 0, 0))]); } function mHouse() { const roof = new THREE.CylinderGeometry(0.01, 6.2, 3.6, 4, 1); roof.rotateY(Math.PI / 4); return merge([part(box(8, 6, 7), '#C96B4A', T(0, 3, 0)), part(roof, '#5B3A2E', T(0, 7.8, 0, 0, 0, 0, 1.0, 1, 0.9)), part(box(8.6, 0.6, 7.6), SNOW, T(0, 6.2, 0)), part(cone(4.3, 1.5, 4), SNOW, T(0, 9.0, 0, 0, Math.PI / 4, 0, 1, 1, 0.9)), part(box(1.2, 2.2, 0.15), '#5B3A2E', T(-2, 1.1, 3.55)), part(box(1.0, 3.0, 1.0), '#7A5446', T(2.5, 9.3, 1.5))]); } function mWindows() { return merge([part(box(1.4, 1.3, 0.1), '#FFD27A', T(1.6, 3.6, 3.56)), part(box(1.4, 1.3, 0.1), '#FFD27A', T(-1.6, 3.6, 3.56)), part(box(0.1, 1.3, 1.4), '#FFD27A', T(4.01, 3.6, 0))]); } function mBulb() { return merge([part(sph(0.45, 6, 4), '#FFE6A0', T(1.3, 7.55, 0))]); } function mPine(s) { return mTree(s); } function mHill() { return merge([part(cone(30, 26, 6), '#E6EEFA', T(0, 13, 0)), part(cone(14, 10, 6), '#FFFFFF', T(0, 21, 0)), part(cone(22, 16, 6), '#D5E2F4', T(18, 8, 10))]); } function mSign() { return merge([part(box(0.25, 4.2, 0.25), '#5C6478', T(0, 2.1, 0)), part(box(2.6, 2.6, 0.2), '#FFC928', T(0, 4.6, 0, 0, 0, Math.PI / 4)), part(box(2.2, 2.2, 0.22), '#1E1E24', T(0, 4.6, 0.01, 0, 0, Math.PI / 4, 0.8, 0.8, 1)), part(box(1.6, 1.6, 0.24), '#FFC928', T(0, 4.6, 0.02, 0, 0, Math.PI / 4, 0.8, 0.8, 1))]); } function mBeast() { const fur = '#EEF3FF', fur2 = '#C9D5F2'; return merge([part(ico(3.4, 1), fur, T(0, 5.2, 0, 0, 0, 0, 1.15, 1.1, 0.95)), part(ico(2.3, 1), fur2, T(0, 4.6, -1.8, 0, 0, 0, 1.0, 0.9, 0.6)), part(ico(2.2, 1), fur, T(0, 8.6, -0.6)), part(cone(0.5, 3.2, 6), '#7FD6FF', T(-1.6, 10.6, -0.4, 0.3, 0, 0.7)), part(cone(0.5, 3.2, 6), '#7FD6FF', T(1.6, 10.6, -0.4, 0.3, 0, -0.7)), part(box(2.2, 0.7, 0.3), '#2B2F45', T(0, 7.9, -2.55)), part(cone(0.18, 0.5, 4), '#FFFFFF', T(-0.6, 7.65, -2.6, Math.PI, 0, 0)), part(cone(0.18, 0.5, 4), '#FFFFFF', T(0.6, 7.65, -2.6, Math.PI, 0, 0)), part(cyl(0.9, 1.2, 4.6, 7), fur2, T(-3.6, 5.2, -1.2, 0.9, 0, 0.5)), part(cyl(0.9, 1.2, 4.6, 7), fur2, T(3.6, 5.2, -1.2, 0.9, 0, -0.5)), part(cyl(1.2, 1.3, 2.6, 7), fur2, T(-1.6, 1.3, 0.2)), part(cyl(1.2, 1.3, 2.6, 7), fur2, T(1.6, 1.3, 0.2))]); } function mEyes() { return merge([part(sph(0.42, 6, 4), '#7DF9FF', T(-0.85, 8.9, -2.35)), part(sph(0.42, 6, 4), '#7DF9FF', T(0.85, 8.9, -2.35))]); } function mWings() { return merge([part(box(4.2, 0.12, 1.6), '#FFFFFF', T(-2.8, 1.6, 0.4, 0, 0, 0.15)), part(box(4.2, 0.12, 1.6), '#FFFFFF', T(2.8, 1.6, 0.4, 0, 0, -0.15)), part(box(1.4, 0.14, 1.65), '#FF6A3D', T(-4.3, 1.82, 0.4, 0, 0, 0.15)), part(box(1.4, 0.14, 1.65), '#FF6A3D', T(4.3, 1.82, 0.4, 0, 0, -0.15))]); } // ------------------------------------------------------------------ textures drawn on a canvas function canvasTex(w, h, draw) { const cv = document.createElement('canvas'); cv.width = w; cv.height = h; draw(cv.getContext('2d'), w, h); const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t; } function dotTex() { return canvasTex(64, 64, (g) => { const r = g.createRadialGradient(32, 32, 0, 32, 32, 32); r.addColorStop(0, 'rgba(255,255,255,1)'); r.addColorStop(0.4, 'rgba(255,255,255,.8)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, 64, 64); }); } const POWER_COL = { magnet: '#FF4D6D', shield: '#4DA6FF', double: '#FFC928', glider: '#3FD18A', gift: '#B26BFF' }; function powerTex(p) { return canvasTex(128, 128, (g) => { const c = POWER_COL[p]; const rg = g.createRadialGradient(64, 64, 10, 64, 64, 62); rg.addColorStop(0, '#ffffff'); rg.addColorStop(0.55, c); rg.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = rg; g.beginPath(); g.arc(64, 64, 62, 0, 7); g.fill(); g.strokeStyle = '#fff'; g.lineWidth = 6; g.beginPath(); g.arc(64, 64, 46, 0, 7); g.stroke(); g.fillStyle = '#1E1E2E'; g.strokeStyle = '#1E1E2E'; g.lineWidth = 11; g.lineCap = 'round'; if (p === 'magnet') { g.beginPath(); g.arc(64, 58, 20, Math.PI, 0, true); g.stroke(); g.fillStyle = '#fff'; g.fillRect(36, 70, 16, 12); g.fillRect(76, 70, 16, 12); } else if (p === 'shield') { g.beginPath(); g.moveTo(64, 34); g.lineTo(88, 44); g.lineTo(86, 70); g.lineTo(64, 92); g.lineTo(42, 70); g.lineTo(40, 44); g.closePath(); g.fill(); } else if (p === 'double') { g.font = '900 46px system-ui, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('×2', 64, 66); } else if (p === 'glider') { g.beginPath(); g.moveTo(26, 70); g.lineTo(64, 44); g.lineTo(102, 70); g.lineTo(64, 62); g.closePath(); g.fill(); } else { g.fillRect(42, 54, 44, 34); g.fillStyle = '#fff'; g.fillRect(60, 54, 8, 34); g.fillStyle = '#1E1E2E'; g.fillRect(38, 46, 52, 12); g.beginPath(); g.arc(56, 42, 7, 0, 7); g.arc(72, 42, 7, 0, 7); g.fill(); } }); } function mountainTex() { return canvasTex(2048, 256, (g, w, h) => { g.clearRect(0, 0, w, h); const ridge = (base, amp, seed, col) => { g.fillStyle = col; g.beginPath(); g.moveTo(0, h); let y = base; for (let x = 0; x <= w; x += 16) { const n = Math.sin(x * 0.004 + seed) * 0.5 + Math.sin(x * 0.011 + seed * 2.1) * 0.3 + Math.sin(x * 0.027 + seed * 3.7) * 0.2; y = base - n * amp - amp * 0.6; g.lineTo(x, y); } g.lineTo(w, h); g.closePath(); g.fill(); }; ridge(150, 90, 1.3, 'rgba(255,255,255,0.55)'); ridge(190, 70, 4.1, 'rgba(255,255,255,0.85)'); ridge(230, 40, 7.7, 'rgba(255,255,255,1)'); }); } // ------------------------------------------------------------------ sky const SKY = { pine: { top: '#5FA8FF', mid: '#BFE3FF', bot: '#FFF2E0', sun: '#FFF4D6', light: 1.15, hemi: 1.0, fog: '#DCEBFA' }, village: { top: '#3E8CF0', mid: '#A9D5FF', bot: '#F2F8FF', sun: '#FFFFFF', light: 1.25, hemi: 1.05, fog: '#E3EEF9' }, caves: { top: '#5B4BA8', mid: '#E58A7A', bot: '#FFC48A', sun: '#FFB46B', light: 0.8, hemi: 0.75, fog: '#B9A7C9' }, aurora: { top: '#06112A', mid: '#142C55', bot: '#2B4A7A', sun: '#BFD9FF', light: 0.55, hemi: 0.55, fog: '#1A2F55' }, }; function skyMaterial() { return new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, fog: false, uniforms: { top: { value: C('#5FA8FF') }, mid: { value: C('#BFE3FF') }, bot: { value: C('#FFF2E0') }, night: { value: 0 }, time: { value: 0 }, sunDir: { value: new THREE.Vector3(0.3, 0.35, -1).normalize() } }, vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }', fragmentShader: `uniform vec3 top; uniform vec3 mid; uniform vec3 bot; uniform float night; uniform float time; uniform vec3 sunDir; varying vec3 vDir; float h(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453); } void main(){ float y = vDir.y; vec3 c = y > 0.12 ? mix(mid, top, smoothstep(0.12, 0.65, y)) : mix(bot, mid, smoothstep(-0.05, 0.12, y)); float s = max(0.0, dot(normalize(vDir), normalize(sunDir))); c += vec3(1.0,0.92,0.75) * (pow(s, 400.0) * 1.2 + pow(s, 12.0) * 0.18) * (1.0 - night * 0.7); if (night > 0.01) { vec2 g = floor(vDir.xz / (vDir.y + 0.35) * 160.0); float st = step(0.996, h(g)) * smoothstep(0.05, 0.4, y); c += vec3(st) * night * (0.6 + 0.4 * sin(time * 3.0 + h(g) * 40.0)); float band = sin(vDir.x * 6.0 + time * 0.25 + sin(vDir.z * 3.0 + time * 0.1) * 2.0); float a = smoothstep(0.15, 0.55, y) * smoothstep(0.95, 0.5, y) * (0.5 + 0.5 * band); float wave = 0.5 + 0.5 * sin(vDir.z * 9.0 + time * 0.4 + band * 2.0); c += mix(vec3(0.15, 1.0, 0.6), vec3(0.65, 0.35, 1.0), wave) * a * night * 0.55; } gl_FragColor = vec4(c, 1.0); }`, }); } // ------------------------------------------------------------------ pools class Pool { constructor(scene, geo, mat, cap, colored = false) { this.mesh = new THREE.InstancedMesh(geo, mat, cap); this.mesh.frustumCulled = false; this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); if (colored) this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(cap * 3), 3); this.cap = cap; this.n = 0; scene.add(this.mesh); } begin() { this.n = 0; } put(x, y, z, ry = 0, sx = 1, sy = 1, sz = 1, rx = 0, rz = 0, color) { if (this.n >= this.cap) return; _e.set(rx, ry, rz); _q.setFromEuler(_e); _p.set(x, y, z); _s.set(sx, sy, sz); _m.compose(_p, _q, _s); this.mesh.setMatrixAt(this.n, _m); if (color && this.mesh.instanceColor) this.mesh.setColorAt(this.n, color); this.n++; } end() { this.mesh.count = this.n; this.mesh.instanceMatrix.needsUpdate = true; if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true; } } const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler(), _p = new THREE.Vector3(), _s = new THREE.Vector3(); const hash = (n) => { let x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }; // ------------------------------------------------------------------ particles class Particles { constructor(scene, cap, size, color, tex, mat) { this.cap = cap; this.pos = new Float32Array(cap * 3); this.vel = new Float32Array(cap * 3); this.life = new Float32Array(cap); this.i = 0; const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3)); this.geo = g; this.mat = mat || bendify(new THREE.PointsMaterial({ size, map: tex, color, transparent: true, depthWrite: false, opacity: 0.9 })); this.pts = new THREE.Points(g, this.mat); this.pts.frustumCulled = false; scene.add(this.pts); for (let i = 0; i < cap; i++) this.pos[i * 3 + 1] = -999; } emit(x, y, z, vx, vy, vz, life) { const i = this.i; this.i = (this.i + 1) % this.cap; this.pos[i * 3] = x; this.pos[i * 3 + 1] = y; this.pos[i * 3 + 2] = z; this.vel[i * 3] = vx; this.vel[i * 3 + 1] = vy; this.vel[i * 3 + 2] = vz; this.life[i] = life; } update(dt, grav = -14) { for (let i = 0; i < this.cap; i++) { if (this.life[i] <= 0) continue; this.life[i] -= dt; if (this.life[i] <= 0) { this.pos[i * 3 + 1] = -999; continue; } this.vel[i * 3 + 1] += grav * dt; this.pos[i * 3] += this.vel[i * 3] * dt; this.pos[i * 3 + 1] += this.vel[i * 3 + 1] * dt; this.pos[i * 3 + 2] += this.vel[i * 3 + 2] * dt; if (this.pos[i * 3 + 1] < 0.05) { this.pos[i * 3 + 1] = 0.05; this.vel[i * 3 + 1] *= -0.2; } } this.geo.attributes.position.needsUpdate = true; } } // ------------------------------------------------------------------ the scene export class World { constructor(canvas, { low = false } = {}) { this.low = low; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: !low, powerPreference: 'high-performance', preserveDrawingBuffer: false }); this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.pixelRatio = Math.min(window.devicePixelRatio || 1, low ? 1.25 : 2); this.renderer.setPixelRatio(this.pixelRatio); this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(62, 1, 0.5, 1400); this.scene.fog = new THREE.Fog('#DCEBFA', 70, AHEAD + 30); this.hemi = new THREE.HemisphereLight('#FFFFFF', '#9FB6D6', 1.0); this.scene.add(this.hemi); this.sun = new THREE.DirectionalLight('#FFF4D6', 1.15); this.sun.position.set(-40, 80, 30); this.scene.add(this.sun); this.scene.add(this.sun.target); this.mat = bendify(new THREE.MeshLambertMaterial({ vertexColors: true })); this.glow = bendify(new THREE.MeshLambertMaterial({ vertexColors: true, emissive: '#ffffff', emissiveIntensity: 0.0 })); this.coinMat = bendify(new THREE.MeshLambertMaterial({ vertexColors: true, emissive: '#FFB020', emissiveIntensity: 0.45 })); this.frostMat = bendify(new THREE.MeshLambertMaterial({ vertexColors: true, emissive: '#44D9FF', emissiveIntensity: 0.6, transparent: true, opacity: 0.95 })); this.lightMat = bendify(new THREE.MeshBasicMaterial({ vertexColors: true })); this.tileMat = bendify(new THREE.MeshLambertMaterial({ vertexColors: true, emissive: '#B7C6DD', emissiveIntensity: 0.55 })); const S = this.scene, m = this.mat; this.pools = { tree: new Pool(S, mTree(), m, 40), rock: new Pool(S, mRock(), m, 24), log: new Pool(S, mLog(), m, 24), branch: new Pool(S, mBranch(), m, 24), bush: new Pool(S, mBush(), m, 16), fence: new Pool(S, mFence(), m, 30), snowman: new Pool(S, mSnowman(), m, 24), sledder: new Pool(S, mSledder(), m, 16), post: new Pool(S, mPost(), m, 30), banner: new Pool(S, mBanner(), m, 20), drift: new Pool(S, mDrift(), m, 16), pillar: new Pool(S, mPillar(), m, 24), crystal: new Pool(S, mCrystal(), m, 24), icicles: new Pool(S, mIcicles(), m, 24), icelog: new Pool(S, mIcelog(), m, 20), boulder: new Pool(S, mBoulder(), m, 20), ball: new Pool(S, mBall(), m, 20), pylon: new Pool(S, mPylon(), m, 20), gap: new Pool(S, mGap(), m, 20), hole: new Pool(S, mHole(), m, 20), ramp: new Pool(S, mRamp(), m, 8), rail: new Pool(S, mRail(), m, 10), railPost: new Pool(S, mRailPost(), m, 40), coin: new Pool(S, mStar(), this.coinMat, 140), frost: new Pool(S, mFrost(), this.frostMat, 12), tile: new Pool(S, mTile(), this.tileMat, 96, true), plank: new Pool(S, mPlank(), m, 96), bank: new Pool(S, mBank(), this.tileMat, 64, true), bridgeRail: new Pool(S, mBridgeRail(), m, 64), arch: new Pool(S, mArch(), m, 40), dTree: new Pool(S, mPine(1.5), m, 90), house: new Pool(S, mHouse(), m, 30), windows: new Pool(S, mWindows(), this.lightMat, 30), bulb: new Pool(S, mBulb(), this.lightMat, 40), hill: new Pool(S, mHill(), m, 24), sign: new Pool(S, mSign(), m, 6), cable: new Pool(S, merge([part(box(0.12, 0.12, 1), '#2B2F45')]), m, 20), chair: new Pool(S, merge([part(box(2.4, 0.3, 1.2), '#E8413C', T(0, -3.2, 0)), part(box(0.1, 3.2, 0.1), '#2B2F45', T(0, -1.6, 0)), part(box(2.4, 1.2, 0.2), '#E8413C', T(0, -2.6, 0.55))]), m, 40), }; // power-up orbs (sprites; few on screen) this.powerTex = {}; for (const p of ['magnet', 'shield', 'double', 'glider', 'gift']) this.powerTex[p] = powerTex(p); this.sprites = []; for (let i = 0; i < 6; i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.powerTex.gift, depthWrite: false, fog: true })); s.scale.set(3.4, 3.4, 1); s.visible = false; S.add(s); this.sprites.push(s); } // sky, mountains, snowfall this.skyMat = skyMaterial(); this.sky = new THREE.Mesh(new THREE.SphereGeometry(1000, 24, 12), this.skyMat); this.sky.renderOrder = -2; S.add(this.sky); this.mtnMat = new THREE.MeshBasicMaterial({ map: mountainTex(), transparent: true, depthWrite: false, fog: false, side: THREE.BackSide, color: '#E6EEFA' }); this.mtn = new THREE.Mesh(new THREE.CylinderGeometry(900, 900, 260, 48, 1, true), this.mtnMat); this.mtn.renderOrder = -1; S.add(this.mtn); const dot = dotTex(); this.spray = new Particles(S, low ? 160 : 320, 0.9, '#FFFFFF', dot); this.burst = new Particles(S, 160, 1.3, '#FFFFFF', dot); this.sparks = new Particles(S, 90, 0.7, '#FFE36B', dot); this.snow = { n: low ? 300 : 700, pos: null }; { const n = this.snow.n; const pos = new Float32Array(n * 3); for (let i = 0; i < n; i++) { pos[i * 3] = (hash(i) - 0.5) * 120; pos[i * 3 + 1] = hash(i + 99) * 50; pos[i * 3 + 2] = -hash(i + 7) * 140 + 20; } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); this.snow.pos = pos; this.snow.geo = g; this.snow.pts = new THREE.Points(g, new THREE.PointsMaterial({ size: 0.55, map: dot, transparent: true, depthWrite: false, opacity: 0.85, color: '#FFFFFF' })); this.snow.pts.frustumCulled = false; S.add(this.snow.pts); } // trail ribbon this.trailN = 40; this.trailHist = []; { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.trailN * 2 * 3), 3)); g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(this.trailN * 2 * 4), 4)); const idx = []; for (let i = 0; i < this.trailN - 1; i++) { const a = i * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); } g.setIndex(idx); this.trail = new THREE.Mesh(g, bendify(new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false, side: THREE.DoubleSide }))); this.trail.frustumCulled = false; S.add(this.trail); } // player, ghost, beast this.player = this.makeRig(false); this.ghost = this.makeRig(true); this.ghost.group.visible = false; this.beast = new THREE.Group(); this.beast.add(new THREE.Mesh(mBeast(), this.mat)); this.beast.add(new THREE.Mesh(mEyes(), this.lightMat)); this.beast.visible = false; S.add(this.beast); this.shield = new THREE.Mesh(new THREE.IcosahedronGeometry(3.2, 2), new THREE.MeshBasicMaterial({ color: '#7FD6FF', transparent: true, opacity: 0.22, depthWrite: false })); this.player.group.add(this.shield); this.wings = new THREE.Mesh(mWings(), this.mat); this.player.group.add(this.wings); this.boostGlow = new THREE.Mesh(new THREE.ConeGeometry(1.2, 6, 8, 1, true), new THREE.MeshBasicMaterial({ color: '#4DF2FF', transparent: true, opacity: 0.5, depthWrite: false })); this.boostGlow.rotation.x = -Math.PI / 2; this.boostGlow.position.set(0, 0.9, 4.6); this.player.group.add(this.boostGlow); this.lines = new Pool(S, merge([part(box(0.05, 0.05, 6), '#FFFFFF')]), new THREE.MeshBasicMaterial({ color: '#FFFFFF', transparent: true, opacity: 0.55 }), 30); this.debris = []; this.flying = []; this.win = new Map(); this.smashed = new Set(); this.back = 12.5; this.shake = 0; this.fov = 62; this.t = 0; this.camX = 0; this.camY = 7; this.cosm = null; this.lastBiome = ''; this.resize(); } makeRig(ghost) { const group = new THREE.Group(); const sled = new THREE.Mesh(new THREE.BufferGeometry(), ghost ? new THREE.MeshBasicMaterial({ color: '#7DF9FF', transparent: true, opacity: 0.35, depthWrite: false }) : this.mat); const rider = new THREE.Mesh(new THREE.BufferGeometry(), sled.material); group.add(sled); group.add(rider); this.scene.add(group); return { group, sled, rider, lean: 0 }; } dress(rig, sled, rider) { rig.sled.geometry.dispose(); rig.rider.geometry.dispose(); rig.sled.geometry = merge(mSled(sled.color, sled.accent)); rig.rider.geometry = merge(mRider(rider)); } /** Equip the player's look: { sled, rider, trail } objects from core.js. */ setLook(look, ghostLook) { this.cosm = look; this.dress(this.player, look.sled, look.rider); this.dress(this.ghost, (ghostLook || look).sled, (ghostLook || look).rider); this.trailColor = C(look.trail.color); } resize() { const c = this.renderer.domElement; const w = c.clientWidth || window.innerWidth, h = c.clientHeight || window.innerHeight; this.renderer.setSize(w, h, false); this.camera.aspect = w / h; // portrait phones: pull the camera back a little so all 3 lanes stay on screen this.portrait = h > w; this.camera.updateProjectionMatrix(); } reset() { this.trailHist.length = 0; this.debris.length = 0; this.flying.length = 0; this.shake = 0; this.smashed.clear(); for (const P of [this.spray, this.burst, this.sparks]) { P.life.fill(0); for (let i = 0; i < P.cap; i++) P.pos[i * 3 + 1] = -999; } } setQuality(scale) { this.renderer.setPixelRatio(Math.max(0.6, this.pixelRatio * scale)); this.resize(); } /** Draw one frame. run = core.js run; view = { alpha, prev: {d,x,y}, ghost, events, dt, mode, caught } */ frame(r, view) { const dt = Math.min(0.05, view.dt || 0.016); this.t += dt; const a = view.alpha ?? 1, pv = view.prev || r; const d = pv.d + (r.d - pv.d) * a, x = pv.x + (r.x - pv.x) * a, y = pv.y + (r.y - pv.y) * a; const pz = -d; // biome look (blend across the boundary) const biome = biomeAt(d + 40); const f = ((d + 40) % BIOME_LEN) / BIOME_LEN; const nextB = biomeAt(d + 40 + BIOME_LEN * (1 - f) + 1); const k = f > 0.88 ? (f - 0.88) / 0.12 : 0; const A = SKY[biome], B = SKY[nextB]; const mix = (p, q) => C(A[p]).lerp(C(B[p]), k); const u = this.skyMat.uniforms; u.top.value.copy(mix('top')); u.mid.value.copy(mix('mid')); u.bot.value.copy(mix('bot')); u.time.value = this.t; const night = (biome === 'aurora' ? 1 - k : 0) + (nextB === 'aurora' ? k : 0); u.night.value = night; this.scene.fog.color.copy(mix('fog')); this.hemi.intensity = A.hemi + (B.hemi - A.hemi) * k; this.sun.intensity = A.light + (B.light - A.light) * k; this.sun.color.copy(mix('sun')); this.mtnMat.color.copy(night > 0.5 ? C('#3A5A8A') : biome === 'caves' ? C('#E7C2C8') : C('#E6EEFA')); this.glowNight = night; // camera const boosting = r.boost > 0; const targetFov = (this.portrait ? 70 : 62) + (boosting ? 12 : 0) + Math.min(6, (r.v - 40) * 0.12); this.fov += (targetFov - this.fov) * Math.min(1, dt * 4); this.camera.fov = this.fov; this.camera.updateProjectionMatrix(); this.camX += (x * 0.72 - this.camX) * Math.min(1, dt * 9); const gy = r.pw.glider > 0 ? 6 : 0; const baseY = this.portrait ? 9.6 : 7.4; this.camY += (baseY + y * 0.85 + gy - this.camY) * Math.min(1, dt * 6); this.shake = Math.max(0, this.shake - dt * 2.5); const sh = this.shake * this.shake; const back = this.portrait ? 13.5 : 12.5; this.back = back; this.camera.position.set(this.camX + (Math.random() - 0.5) * sh * 1.6, this.camY + (Math.random() - 0.5) * sh * 1.2, pz + back); if (this.camOverride) { const o = this.camOverride; this.camera.position.set(x + o.x, o.y, pz + o.z); this.camera.lookAt(x + o.lx, o.ly, pz + o.lz); } else this.camera.lookAt(this.camX * 0.9 + x * 0.1, (this.portrait ? -0.5 : 2.0) + y * 0.8 + gy * 0.4, pz - (this.portrait ? 26 : 22)); BEND.value.y = Math.sin(d / 650) * 0.00035; this.sky.position.copy(this.camera.position); this.mtn.position.set(this.camera.position.x, 40, this.camera.position.z); this.sun.position.set(this.camera.position.x - 40, 80, pz + 30); this.sun.target.position.set(this.camera.position.x, 0, pz - 20); // world for (const key in this.pools) this.pools[key].begin(); this.drawGround(r.course, d, night); this.drawItems(r, d, view); this.drawDecor(d, night); for (const key in this.pools) this.pools[key].end(); this.coinMat.emissiveIntensity = 0.35 + night * 0.4; // player rig this.pose(this.player, r, x, y, pz, dt, view); if (view.ghost && view.ghost.alive !== undefined) { const g = view.ghost; this.ghost.group.visible = true; this.ghost.group.position.set(g.x, g.y + 0.02, -g.d); this.ghost.group.rotation.set(0, 0, 0); this.ghost.rider.scale.y = g.duck > 0 && g.y < 0.5 ? 0.6 : 1; } else this.ghost.group.visible = false; // friends (store renders / attract): more riders on the course, each with their own look this.friends = this.friends || []; const fl = view.friends || []; for (let i = 0; i < Math.max(fl.length, this.friends.length); i++) { const f = fl[i]; if (f && !this.friends[i]) { this.friends[i] = this.makeRig(false); } const rigF = this.friends[i]; if (!rigF) continue; if (!f) { rigF.group.visible = false; continue; } if (rigF.lookKey !== f.key) { this.dress(rigF, f.sled, f.rider); rigF.lookKey = f.key; } rigF.group.visible = true; rigF.group.position.set(f.x, f.y || 0, -f.d); rigF.group.rotation.set(f.y > 0.5 ? -0.2 : 0, 0, f.lean || 0); for (const side of [-1, 1]) if ((f.y || 0) < 0.3) this.spray.emit(f.x + side * 1.1, 0.3, -f.d + 2.4, side * 3, 3, r.v * 0.15, 0.4); } // beast if ((r.beast > 0 && r.alive) || view.caught) { this.beast.visible = true; const t = view.caught ? Math.min(1, view.caughtT || 0) : Math.max(0, Math.min(1, (420 - r.beast) / 30)); // the chase camera sits ~13 units back at ~7 high, so the beast runs a little to the side (away from the edge // the sled is on) and smaller than the camera's height: it looms at the edge of the screen without hiding the run const dz = view.caught ? 9 - t * 9 : 24 - t * 16 + Math.sin(this.t * 7) * 0.6; const side = view.caught ? 0 : (x > 0.5 ? -1 : 1) * 4.2 * t; this.beast.position.set(x * 0.8 + side, Math.abs(Math.sin(this.t * 7)) * 1.2 + (view.caught ? t * 2 : 0), pz + dz); this.beast.rotation.set(view.caught ? -0.3 * t : 0.08, Math.sin(this.t * 3.5) * 0.12, Math.sin(this.t * 7) * 0.06); const s = (view.caught || this.camOverride ? 0.9 : 0.6) + Math.sin(this.t * 14) * 0.02; this.beast.scale.set(s, s, s); } else this.beast.visible = false; // particles if (r.alive) { const spd = r.v; if (y < 0.3 && r.pw.glider <= 0) { const n = this.low ? 1 : 2; for (let i = 0; i < n; i++) for (const side of [-1, 1]) this.spray.emit(x + side * 1.1, 0.3, pz + 2.4, side * (2 + Math.random() * 3), 2 + Math.random() * 3, spd * 0.15 + Math.random() * 3, 0.45); } if (r.grind) for (let i = 0; i < 2; i++) this.sparks.emit(x + (Math.random() - 0.5), RAIL_Y, pz + 1.5, (Math.random() - 0.5) * 8, 3 + Math.random() * 4, spd * 0.1, 0.35); } for (const e of view.events || []) this.onEvent(e, x, y, pz, r); this.spray.update(dt); this.burst.update(dt); this.sparks.update(dt, -20); // snowfall around the camera, heavier and lighter with distance { const P = this.snow.pos, n = this.snow.n, cz = this.camera.position.z, cx = this.camera.position.x; const heavy = 0.55 + 0.45 * Math.sin(d / 900); this.snow.pts.material.opacity = 0.35 + heavy * 0.55; for (let i = 0; i < n; i++) { P[i * 3 + 1] -= dt * (5 + (i % 5)); P[i * 3] += dt * Math.sin(this.t + i) * 0.8; if (P[i * 3 + 1] < 0 || P[i * 3 + 2] > cz + 10 || P[i * 3 + 2] < cz - 150) { P[i * 3] = cx + (hash(i + this.t) - 0.5) * 110; P[i * 3 + 1] = 10 + hash(i * 3 + this.t) * 40; P[i * 3 + 2] = cz - hash(i * 7 + this.t) * 150; } } this.snow.geo.attributes.position.needsUpdate = true; } this.drawTrail(r, x, y, pz); // speed lines while boosting this.lines.begin(); if (boosting) for (let i = 0; i < 24; i++) { const ang = hash(i) * Math.PI * 2; const rad = 7 + hash(i + 3) * 6; const zz = ((this.t * 160 + hash(i + 9) * 60) % 60); this.lines.put(x + Math.cos(ang) * rad, 4 + Math.sin(ang) * rad * 0.6, pz - 30 + zz); } this.lines.end(); this.renderer.render(this.scene, this.camera); } pose(rig, r, x, y, pz, dt, view) { const g = rig.group; const dx = r.lane * LANE_W - x; rig.lean += ((dx * -0.06) - rig.lean) * Math.min(1, dt * 12); g.position.set(x, y + (r.alive ? Math.sin(this.t * 22) * 0.04 : 0), pz); let pitch = 0; if (r.air === 'ramp' || r.air === 'jump') pitch = Math.max(-0.35, Math.min(0.35, -r.vy * 0.5)); if (!r.alive && !view.caught) { pitch = 0.9; g.position.y = 0.4; } g.rotation.set(pitch, rig.lean * 0.6, rig.lean); rig.rider.scale.set(1, r.duck > 0 && r.y < 0.5 ? 0.62 : 1, 1); rig.rider.position.z = r.duck > 0 ? 0.3 : 0; this.shield.visible = r.pw.shield > 0 && r.alive; this.shield.rotation.y += dt * 1.5; this.shield.material.opacity = r.pw.shield > 0 && r.pw.shield < 120 ? (Math.sin(this.t * 20) > 0 ? 0.3 : 0.08) : 0.22; this.wings.visible = r.pw.glider > 0; this.boostGlow.visible = r.boost > 0; this.boostGlow.scale.set(1, 1 + Math.sin(this.t * 40) * 0.15, 1); rig.sled.material.transparent = r.grace > 0; rig.sled.material.opacity = r.grace > 0 && Math.sin(this.t * 30) > 0 ? 0.4 : 1; } onEvent(e, x, y, pz, r) { const T2 = e.type; if (T2 === 'land' || T2 === 'stumble' || T2 === 'bump') { for (let i = 0; i < 18; i++) this.burst.emit(x + (Math.random() - 0.5) * 3, 0.4, pz + (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 10, 3 + Math.random() * 5, (Math.random() - 0.5) * 6, 0.6); this.shake = Math.max(this.shake, T2 === 'land' ? (e.from === 'ramp' ? 0.6 : 0.3) : 0.8); } else if (T2 === 'crash' || T2 === 'caught') { for (let i = 0; i < 50; i++) this.burst.emit(x + (Math.random() - 0.5) * 3, 1 + Math.random() * 2, pz - 1, (Math.random() - 0.5) * 16, 4 + Math.random() * 9, -Math.random() * 12, 1.0); this.shake = 1.2; } else if (T2 === 'smashed' || T2 === 'shieldbreak') { for (let i = 0; i < 30; i++) this.burst.emit(x + (Math.random() - 0.5) * 4, 1 + Math.random() * 3, pz - 3, (Math.random() - 0.5) * 18, 5 + Math.random() * 8, -6 - Math.random() * 10, 0.8); const it = this.win.get(e.id); this.smashed.add(e.id); if (it) this.debris.push({ k: it.k, x: it.l * LANE_W, y: 0, z: -it.z, vx: (Math.random() - 0.5) * 20, vy: 14, vz: -30, r: 0, t: 0 }); this.shake = Math.max(this.shake, 0.7); } else if (T2 === 'coin') { const it = this.win.get(e.id) || (r.pw.glider > 0 || r.y > 8 ? r.course.sky.find((q) => q.id === e.id && Math.abs(q.z - r.d) < 6) : null); if (it) this.flying.push({ x: it.l * LANE_W, y: it.y || 1.2, z: -it.z, t: 0 }); } else if (T2 === 'boost' || T2 === 'power' || T2 === 'ramp') { for (let i = 0; i < 24; i++) this.sparks.emit(x, y + 1.5, pz, (Math.random() - 0.5) * 14, Math.random() * 10, (Math.random() - 0.5) * 14, 0.6); if (T2 === 'boost') this.shake = Math.max(this.shake, 0.5); } } drawGround(c, d, night) { const P = this.pools; const zones = []; for (const L of c.longs) if (L.k === 'zone' && L.e > d - BEHIND && L.z < d + AHEAD) zones.push(L); const zoneAt = (z) => { for (const L of zones) if (z >= L.z && z <= L.e) return L.t; return ''; }; const z0 = Math.floor((d - BEHIND) / 10) * 10; const snow = C('#FFFFFF'), ice = C('#BCEBFF'), cave = C('#D7E6FA'), field = C(night > 0.5 ? '#C9D8F0' : '#FFFFFF'); for (let z = z0; z < d + AHEAD; z += 10) { const biome = biomeAt(z); const zt = zoneAt(z + 5); const zz = -(z + 5); for (let l = -1; l <= 1; l++) { if (zt === 'bridge') P.plank.put(l * LANE_W, 0, zz); else P.tile.put(l * LANE_W, 0, zz, 0, 1, 1, 1, 0, 0, zt === 'lake' ? ice : biome === 'caves' ? cave : snow); } if (zt === 'bridge') { P.bridgeRail.put(-9.1, 0, zz); P.bridgeRail.put(9.1, 0, zz); } else { const bc = zt === 'lake' ? ice : biome === 'caves' ? cave : field; P.bank.put(9, 0, zz, 0, 1, 1, 1, 0, 0, bc); P.bank.put(-9, 0, zz, Math.PI, 1, 1, 1, 0, 0, bc); } if (biome === 'caves' && Math.floor(z / 10) % 2 === 0) P.arch.put(0, 0, zz, 0, 1, zt === 'tunnel' ? 0.85 : 1, 1); } } drawItems(r, d, view) { const P = this.pools, c = r.course; // first item in range (binary search on z) const items = c.items; let lo = 0, hi = items.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (items[mid].e < d - BEHIND) lo = mid + 1; else hi = mid; } let si = 0; const pylons = []; this.win.clear(); for (let i = Math.max(0, lo - 4); i < items.length; i++) { const it = items[i]; if (it.z > d + AHEAD) break; if (it.e < d - BEHIND) continue; const k = it.k; if (r.done[it.id] && (k === 'coin' || k === 'frost' || k === 'pow')) continue; if (this.smashed.has(it.id)) continue; this.win.set(it.id, it); const x = itemX(it, d), z = -itemZ(it, d); if (k === 'coin') { if (it.y > SKY_Y - 2 && r.pw.glider <= 0) continue; P.coin.put(x, it.y || 1.2, z, this.t * 3 + it.z * 0.1, 0.9, 0.9, 0.9); continue; } if (k === 'frost') { P.frost.put(x, 1.4 + Math.sin(this.t * 3 + it.z) * 0.25, z, this.t * 2); continue; } if (k === 'pow') { if (si < this.sprites.length) { const s = this.sprites[si++]; s.visible = true; s.material.map = this.powerTex[it.p]; const dz = Math.max(0, it.z - d + this.back - 10); s.position.set(x, 2.1 + Math.sin(this.t * 4) * 0.3 - dz * dz * BEND.value.x, z); } continue; } if (k === 'ball') { P.ball.put(x, 0, z, 0, 1, 1, 1, (d - it.z) * 0.2, 0); continue; } if (k === 'sledder') { P.sledder.put(x, 0, z, (itemX(it, d + 1) - x) * -0.8); continue; } if (k === 'pylon') pylons.push([x, z]); const pool = P[k]; if (pool) pool.put(x, 0, z, k === 'tree' ? it.z : k === 'rock' || k === 'boulder' || k === 'crystal' ? it.z * 0.7 : 0); } for (; si < this.sprites.length; si++) this.sprites[si].visible = false; // sky coins only while gliding if (r.pw.glider > 0 || r.y > 8) for (const it of c.sky) { if (it.z < d - BEHIND || it.z > d + AHEAD || r.done[it.id]) continue; P.coin.put(it.l * LANE_W, it.y, -it.z, this.t * 3 + it.z * 0.1); } // ski-lift cables between pylons, with chairs for (let i = 0; i + 1 < pylons.length; i++) { const [x1, z1] = pylons[i], [x2, z2] = pylons[i + 1]; for (const off of [-3.2, 3.2]) { const ax = x1 + off, bx = x2 + off, len = Math.hypot(bx - ax, z2 - z1); P.cable.put((ax + bx) / 2, 12.4, (z1 + z2) / 2, Math.atan2(bx - ax, z2 - z1), 1, 1, len); const t = ((this.t * 0.15 + i * 0.37) % 1); P.chair.put(ax + (bx - ax) * t, 12.4, z1 + (z2 - z1) * t, Math.atan2(bx - ax, z2 - z1)); } } // rails for (const L of c.longs) { if (L.k !== 'rail' || L.e < d - BEHIND || L.z > d + AHEAD) continue; const len = L.e - L.z; P.rail.put(L.l * LANE_W, 0, -(L.z + len / 2), 0, 1, 1, len); for (let z = L.z + 1; z < L.e; z += 8) P.railPost.put(L.l * LANE_W, 0, -z); } // warning signs before special sections for (const s of c.decor) if (s.k === 'sign' && s.z > d - BEHIND && s.z < d + AHEAD) { P.sign.put(9.6, 0, -s.z, -0.3); P.sign.put(-9.6, 0, -s.z, 0.3); } // flying coins (magnet / pickup), smashed debris for (let i = this.flying.length - 1; i >= 0; i--) { const f = this.flying[i]; f.t += 0.06; if (f.t >= 1) { this.flying.splice(i, 1); continue; } const px = this.player.group.position; P.coin.put(f.x + (px.x - f.x) * f.t, f.y + (px.y + 2 - f.y) * f.t + Math.sin(f.t * 3) * 2, f.z + (px.z - f.z) * f.t, this.t * 8, 1 - f.t * 0.6, 1 - f.t * 0.6, 1 - f.t * 0.6); } for (let i = this.debris.length - 1; i >= 0; i--) { const b = this.debris[i]; b.t += 0.016; b.vy -= 30 * 0.016; b.x += b.vx * 0.016; b.y += b.vy * 0.016; b.z += b.vz * 0.016; b.r += 0.2; if (b.t > 1.2 || !P[b.k]) { this.debris.splice(i, 1); continue; } P[b.k].put(b.x, b.y, b.z, b.r, 1 - b.t * 0.5, 1 - b.t * 0.5, 1 - b.t * 0.5, b.r * 0.7, b.r * 0.4); } } drawDecor(d, night) { const P = this.pools; const n0 = Math.floor((d - BEHIND) / 14), n1 = Math.floor((d + AHEAD) / 14); for (let n = n0; n <= n1; n++) { const z = n * 14, zz = -z, biome = biomeAt(z); for (const side of [-1, 1]) { const h1 = hash(n * 2 + (side > 0 ? 1 : 0)), h2 = hash(n * 5 + side * 3.3), h3 = hash(n * 11 + side); const ox = side * (13 + h1 * 18); if (biome === 'pine' || biome === 'aurora') { if (h2 < 0.85) P.dTree.put(ox, 1.4, zz + h3 * 6, h1 * 6, 0.8 + h3 * 0.7, 0.8 + h3 * 0.7, 0.8 + h3 * 0.7); if (h3 > 0.55) P.dTree.put(side * (34 + h2 * 20), 1.4, zz + 5, h2 * 6, 1.2, 1.4, 1.2); } else if (biome === 'village') { if (n % 3 === 0) { P.house.put(side * 19, 1.6, zz, side > 0 ? -Math.PI / 2 : Math.PI / 2, 1, 1, 1); P.windows.put(side * 19, 1.6, zz, side > 0 ? -Math.PI / 2 : Math.PI / 2); } else if (n % 3 === 1) { P.post.put(side * 10.5, 0.6, zz, side > 0 ? Math.PI : 0); P.bulb.put(side * 10.5, 0.6, zz, side > 0 ? Math.PI : 0); } else if (h2 < 0.6) P.dTree.put(side * (26 + h1 * 10), 1.4, zz, h1 * 6, 0.9, 0.9, 0.9); } else if (biome === 'caves') { if (h2 < 0.5) P.crystal.put(side * (10.2 + h1 * 1.2), 0, zz + h3 * 8, h1 * 3, 0.8 + h3 * 0.6, 0.8 + h3, 0.8 + h3 * 0.6); } if (n % 4 === 0 && biome !== 'caves') P.hill.put(side * (90 + h1 * 60), 0, zz - 40, h2 * 6, 1 + h3, 0.8 + h2 * 0.8, 1 + h3); } } this.lightMat.color.set(night > 0.3 || biomeAt(d) === 'caves' ? '#FFFFFF' : '#E8D9B0'); } drawTrail(r, x, y, pz) { const H = this.trailHist; H.unshift([x, Math.max(0.15, y + 0.15), pz + 2.6]); if (H.length > this.trailN) H.length = this.trailN; const pos = this.trail.geometry.attributes.position.array, col = this.trail.geometry.attributes.color.array; const c = this.trailColor || C('#FFFFFF'); for (let i = 0; i < this.trailN; i++) { const p = H[Math.min(i, H.length - 1)] || [x, 0.15, pz]; const w = 0.42 * (1 - i / this.trailN); // clamp how far back a point may sit (4 units in the air, 40 on the snow) so sparse history (capture mode, slow // frames) can't stretch one segment back over the camera const zc = Math.min(p[2], pz + 2.6 + (y > 1 ? 4 : 40)); pos.set([p[0] - w, p[1], zc, p[0] + w, p[1], zc], i * 6); // fade points far above or below the sled: a glider descent otherwise draws a tall sheet from the sky to the // rider that the chase camera sees as a beam of light // in the air, also keep it short: the chase camera sits ~13 units back, and a long ribbon at rider height passes // right over the lens const behind = zc - (pz + 2.6); const al = (1 - i / this.trailN) * (1 - i / this.trailN) * 0.5 * Math.max(0, 1 - Math.abs(p[1] - y - 0.15) / 2.5) * (y > 1 ? (this.camOverride ? 0 : Math.max(0, 1 - behind / 4)) : 1); // store shots look up from below: no air trail col.set([c.r, c.g, c.b, al, c.r, c.g, c.b, al], i * 8); } this.trail.geometry.attributes.position.needsUpdate = true; this.trail.geometry.attributes.color.needsUpdate = true; } /** PNG data URL of the current frame (call right after frame()). */ capture(type = 'image/jpeg', q = 0.9) { this.renderer.render(this.scene, this.camera); return this.renderer.domElement.toDataURL(type, q); } }