cybermax / sledvolt /render.js
CyberMax-tools's picture
Deploy sledvolt to the hub
4c8e9de verified
Raw History Blame Contribute Delete
53.1 kB
// 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 <project_vertex>',
'#include <project_vertex>\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); }
}