Spaces:
Running
Running
Download sledvolt/render.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 53.1 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/sledvolt/render.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/sledvolt/render.js
-
curl -L -o render.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/sledvolt/render.js
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); } | |
| } | |