Download script.js from ZENLLC/Glider: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/ZENLLC/Glider/resolve/main/script.js
- Command line
-
hf download hf://spaces/ZENLLC/Glider/script.js
-
curl -L -o script.js https://huggingface.co/spaces/ZENLLC/Glider/resolve/main/script.js
14.5 kB
| /* ZEN Glider — Canyon Run | |
| No audio, single-file Three.js gameplay. | |
| Author: ZEN AI Co. | |
| */ | |
| // ---------- Scene & Renderer ---------- | |
| const scene = new THREE.Scene(); | |
| // Gradient sky (procedural) | |
| const skyCanvas = document.createElement('canvas'); | |
| skyCanvas.width = 1; skyCanvas.height = 256; | |
| const skyCtx = skyCanvas.getContext('2d'); | |
| const grad = skyCtx.createLinearGradient(0,256,0,0); | |
| grad.addColorStop(0.00, '#0b1220'); // deep blue | |
| grad.addColorStop(0.45, '#0f2240'); // indigo | |
| grad.addColorStop(1.00, '#0a0a0f'); // near-black | |
| skyCtx.fillStyle = grad; skyCtx.fillRect(0,0,1,256); | |
| const skyTex = new THREE.CanvasTexture(skyCanvas); | |
| scene.background = skyTex; | |
| // Subtle stars | |
| { | |
| const N = 900; | |
| const pos = new Float32Array(N*3); | |
| for (let i=0;i<N;i++){ | |
| const r = 600, th = Math.random()*Math.PI*2, ph = Math.random()*Math.PI*0.5; | |
| pos[3*i+0] = r*Math.sin(ph)*Math.cos(th); | |
| pos[3*i+1] = r*Math.cos(ph)+80; | |
| pos[3*i+2] = r*Math.sin(ph)*Math.sin(th); | |
| } | |
| const g = new THREE.BufferGeometry(); | |
| g.setAttribute('position', new THREE.BufferAttribute(pos,3)); | |
| const m = new THREE.PointsMaterial({size:1, sizeAttenuation:false, color:0xffffff, opacity:0.8, transparent:true}); | |
| const stars = new THREE.Points(g,m); | |
| scene.add(stars); | |
| } | |
| const camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 2000); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(innerWidth, innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| document.body.appendChild(renderer.domElement); | |
| // Lights | |
| const hemi = new THREE.HemisphereLight(0x6ba7ff, 0x0a0a0a, 0.6); | |
| scene.add(hemi); | |
| const sun = new THREE.DirectionalLight(0xfff2cc, 1.15); | |
| sun.position.set(-120,180,-60); | |
| sun.castShadow = true; | |
| sun.shadow.mapSize.set(1024,1024); | |
| scene.add(sun); | |
| // ---------- Canyon Terrain (procedural) ---------- | |
| const terrain = new THREE.Mesh( | |
| new THREE.PlaneGeometry(2000, 2000, 400, 400), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0x1b2433, | |
| roughness: 0.95, | |
| metalness: 0.0 | |
| }) | |
| ); | |
| terrain.rotation.x = -Math.PI/2; | |
| terrain.receiveShadow = true; | |
| scene.add(terrain); | |
| // Height function for canyon: valley width varies with z; walls rise smoothly outside valley | |
| function canyonHalfWidth(z){ | |
| return 8 + 4*Math.sin(0.02*z) + 2*Math.sin(0.004*z+1.2); | |
| } | |
| function wallFactor(x, z, w){ | |
| const d = Math.max(0, Math.abs(x) - w); | |
| return 1 - Math.exp(-0.75*d); | |
| } | |
| function baseNoise(x,z){ | |
| return -1.0 + 0.45*Math.sin(0.04*z) + 0.25*Math.sin(0.17*x + 0.31*z); | |
| } | |
| function heightAt(x,z){ | |
| const w = canyonHalfWidth(z); | |
| const walls = wallFactor(x,z,w) * (6 + 2*Math.sin(0.05*z)); | |
| const ripples = 0.25*Math.sin(0.9*Math.sin(0.02*z)*x); | |
| return baseNoise(x,z) + walls + ripples; | |
| } | |
| // Deform terrain geometry | |
| { | |
| const pos = terrain.geometry.attributes.position; | |
| for (let i=0;i<pos.count;i++){ | |
| const x = pos.getX(i); | |
| const z = pos.getZ(i); | |
| const h = heightAt(x,z); | |
| pos.setY(i, h); | |
| } | |
| terrain.geometry.computeVertexNormals(); | |
| } | |
| // ---------- Rings & Thermals ---------- | |
| const rings = []; | |
| const thermals = []; | |
| function makeRing(x,y,z){ | |
| const t = new THREE.Mesh( | |
| new THREE.TorusGeometry(1.6, 0.12, 8, 24), | |
| new THREE.MeshBasicMaterial({ color: 0x00e5ff, transparent:true, opacity:0.9 }) | |
| ); | |
| t.position.set(x,y,z); | |
| scene.add(t); | |
| rings.push(t); | |
| return t; | |
| } | |
| function makeThermal(x,y,z,height=6){ | |
| const cyl = new THREE.Mesh( | |
| new THREE.CylinderGeometry(1.2, 1.2, height, 24, 1, true), | |
| new THREE.MeshBasicMaterial({ color: 0x22d39a, transparent:true, opacity:0.18, side:THREE.DoubleSide }) | |
| ); | |
| cyl.position.set(x, y + height/2, z); | |
| scene.add(cyl); | |
| thermals.push({mesh:cyl, top: y+height}); | |
| return cyl; | |
| } | |
| // Populate forward corridor (z from 20 to 1500) | |
| function populateEnvironment(){ | |
| // clear previous if any | |
| rings.splice(0, rings.length); | |
| thermals.splice(0, thermals.length); | |
| // place every ~30–40 units | |
| for (let z=40; z<=1500; ){ | |
| const w = canyonHalfWidth(z)*0.7; | |
| const x = (Math.random()*2-1) * w; | |
| const ground = heightAt(x, z); | |
| const y = ground + 3.2 + Math.random()*2.5; | |
| makeRing(x, y, z); | |
| // sometimes pair a thermal after ring | |
| if (Math.random() < 0.45){ | |
| const tz = z + 6 + Math.random()*8; | |
| const tw = canyonHalfWidth(tz)*0.6; | |
| const tx = THREE.MathUtils.clamp(x + (Math.random()*2-1)*2.5, -tw, tw); | |
| const ty = heightAt(tx, tz) + 1.0; | |
| makeThermal(tx, ty, tz, 7+Math.random()*3); | |
| } | |
| z += 30 + Math.floor(Math.random()*14); | |
| } | |
| } | |
| populateEnvironment(); | |
| // ---------- Paper Glider ---------- | |
| function makeGlider(){ | |
| const group = new THREE.Group(); | |
| const paper = new THREE.MeshLambertMaterial({ color: 0xf4f6f8, side: THREE.DoubleSide }); | |
| // Center body (slender wedge) | |
| const bodyShape = new THREE.Shape(); | |
| bodyShape.moveTo(0, 0); | |
| bodyShape.lineTo(-0.22, 0.55); | |
| bodyShape.lineTo(-0.42, 1.05); | |
| bodyShape.lineTo(0.42, 1.05); | |
| bodyShape.lineTo(0.22, 0.55); | |
| bodyShape.lineTo(0, 0); | |
| const body = new THREE.Mesh(new THREE.ExtrudeGeometry(bodyShape, { depth: 0.03, bevelEnabled:false }), paper); | |
| body.castShadow = true; body.receiveShadow = true; | |
| // Wings | |
| const wingShape = (dir)=> { | |
| const s = new THREE.Shape(); | |
| s.moveTo(0,0.2); s.lineTo(dir*0.32, 0.85); s.lineTo(dir*0.9, 0.5); s.lineTo(0,0.2); | |
| return s; | |
| }; | |
| const leftWing = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape(-1), {depth:0.02, bevelEnabled:false}), paper); | |
| const rightWing= new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape( 1), {depth:0.02, bevelEnabled:false}), paper); | |
| leftWing.position.y = 0.01; rightWing.position.y = 0.015; | |
| leftWing.rotation.x = 0.18; rightWing.rotation.x = 0.18; | |
| leftWing.castShadow = rightWing.castShadow = true; | |
| // Center crease | |
| const creaseGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0.03,0), new THREE.Vector3(0,0.03,1.05)]); | |
| const crease = new THREE.Line(creaseGeo, new THREE.LineBasicMaterial({ color: 0xdedede })); | |
| group.add(body, leftWing, rightWing, crease); | |
| group.rotation.order = "ZXY"; | |
| group.rotation.x = -Math.PI/2; | |
| return group; | |
| } | |
| const glider = makeGlider(); | |
| scene.add(glider); | |
| // ---------- Game State & Physics ---------- | |
| let game = "aim"; // "aim" | "fly" | "end" | |
| let power = 0, maxPower = 14, charging = false; | |
| let stamina = 100, maxStamina = 100; | |
| let combo = 1, comboTimer = 0, comboWindow = 2.5; // seconds to chain rings | |
| let distance = 0, score = 0; | |
| const vel = new THREE.Vector3(0,0,0); | |
| const forwardSpeed = { base: 14, current: 0 }; // m/s along +z | |
| const gravity = 5.0; | |
| const drag = 0.015; // fractional per second | |
| let pitch = 0; // radians, positive pitches nose up (we'll invert to rotation) | |
| let bank = 0; // radians for left/right roll visual | |
| // Controls | |
| let key = { left:false, right:false, up:false, down:false, burst:false }; | |
| let touchLeftActive=false, touchRightActive=false; | |
| function reset(){ | |
| game = "aim"; | |
| power = 0; charging = false; | |
| stamina = maxStamina; | |
| combo = 1; comboTimer = 0; | |
| distance = 0; score = 0; | |
| forwardSpeed.current = 0; | |
| vel.set(0,0,0); | |
| pitch = 0; bank = 0; | |
| glider.position.set(0, heightAt(0,0)+2.5, 0); | |
| glider.rotation.set(-Math.PI/2, 0, 0); | |
| camera.position.set(0, glider.position.y+4, glider.position.z-10); | |
| populateEnvironment(); | |
| document.getElementById('centerMsg').style.display = 'none'; | |
| } | |
| reset(); | |
| function launch(){ | |
| forwardSpeed.current = THREE.MathUtils.mapLinear(power, 0, maxPower, 8, 26); | |
| vel.y = Math.sin(Math.PI/4) * (power*0.65); // initial toss angle ~45° | |
| game = "fly"; power = 0; charging = false; | |
| } | |
| // ---------- UI helpers ---------- | |
| const elDistance = document.getElementById('distance'); | |
| const elScore = document.getElementById('score'); | |
| const elPower = document.getElementById('powerBar'); | |
| const elStamina = document.getElementById('staminaBar'); | |
| const elCenter = document.getElementById('centerMsg'); | |
| const elResult = document.getElementById('resultLine'); | |
| const elDetail = document.getElementById('resultDetail'); | |
| document.getElementById('btnRestart').addEventListener('click', reset); | |
| function setBar(el, v){ el.style.width = `${THREE.MathUtils.clamp(v,0,100)}%`; } | |
| // ---------- Input ---------- | |
| addEventListener('keydown', (e)=>{ | |
| if (e.code === 'Space'){ if (game==='aim'){ charging = true; e.preventDefault(); } else if (game==='end'){ reset(); } } | |
| if (e.code === 'ArrowLeft') key.left = true; | |
| if (e.code === 'ArrowRight') key.right = true; | |
| if (e.code === 'ArrowUp') key.up = true; | |
| if (e.code === 'ArrowDown') key.down = true; | |
| if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') key.burst = true; | |
| if (e.code === 'Escape') reset(); | |
| }); | |
| addEventListener('keyup', (e)=>{ | |
| if (e.code === 'Space' && game==='aim'){ launch(); e.preventDefault(); } | |
| if (e.code === 'ArrowLeft') key.left = false; | |
| if (e.code === 'ArrowRight') key.right = false; | |
| if (e.code === 'ArrowUp') key.up = false; | |
| if (e.code === 'ArrowDown') key.down = false; | |
| if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') key.burst = false; | |
| }); | |
| const touchLeft = document.getElementById('touchLeft'); | |
| const touchRight = document.getElementById('touchRight'); | |
| touchLeft.addEventListener('touchstart', ()=>{ touchLeftActive=true; }, {passive:true}); | |
| touchLeft.addEventListener('touchend', ()=>{ touchLeftActive=false; }, {passive:true}); | |
| touchRight.addEventListener('touchstart',()=>{ touchRightActive=true; }, {passive:true}); | |
| touchRight.addEventListener('touchend', ()=>{ touchRightActive=false; }, {passive:true}); | |
| addEventListener('mousedown', ()=>{ if (game==='aim'){ charging = true; } }); | |
| addEventListener('mouseup', ()=>{ if (game==='aim' && charging){ launch(); } }); | |
| // ---------- Helpers ---------- | |
| const clock = new THREE.Clock(); | |
| function sampleGroundY(x,z){ | |
| return heightAt(x,z); | |
| } | |
| function endRun(reason){ | |
| game = 'end'; | |
| elResult.textContent = reason || 'Crash!'; | |
| elDetail.textContent = `Distance: ${distance.toFixed(1)} m • Score: ${Math.floor(score)} • Best combo ×${combo}`; | |
| elCenter.style.display = 'grid'; | |
| } | |
| function ringCollected(t){ | |
| // remove visually | |
| scene.remove(t); | |
| rings.splice(rings.indexOf(t),1); | |
| // reward | |
| stamina = Math.min(maxStamina, stamina + 35); | |
| combo = Math.min(10, combo + 1); | |
| comboTimer = comboWindow; | |
| score += 150 * combo; | |
| } | |
| function inThermalBoost(p){ | |
| // check if inside any thermal cylinder footprint | |
| for (const th of thermals){ | |
| const dx = p.x - th.mesh.position.x; | |
| const dz = p.z - th.mesh.position.z; | |
| const r = 1.2; | |
| const insideXZ = (dx*dx + dz*dz) <= r*r; | |
| const y = p.y; | |
| if (insideXZ && y >= th.mesh.position.y && y <= th.top){ | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // ---------- Main Loop ---------- | |
| function animate(){ | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(0.033, clock.getDelta()); | |
| // background gentle shift with distance | |
| const ratio = Math.min(1, distance / 1500); | |
| skyTex.rotation = ratio * Math.PI * 0.4; skyTex.center.set(0.5,0.5); skyTex.needsUpdate = true; | |
| // UI update | |
| setBar(elPower, game==='aim' ? (power/maxPower)*100 : 0); | |
| setBar(elStamina, (stamina/maxStamina)*100); | |
| elDistance.textContent = `Distance: ${distance.toFixed(1)} m`; | |
| elScore.textContent = `Score: ${Math.floor(score)} | Combo ×${combo}`; | |
| if (game==='aim'){ | |
| if (charging){ | |
| power += 24*dt; // charge rate | |
| power = Math.min(power, maxPower); | |
| } | |
| } | |
| if (game==='fly'){ | |
| // Controls -> bank/pitch targets | |
| const steerLeft = key.left || touchLeftActive; | |
| const steerRight = key.right || touchRightActive; | |
| const bankTarget = (steerLeft?-1:0) + (steerRight?1:0); // -1 left, +1 right | |
| bank = THREE.MathUtils.lerp(bank, bankTarget * Math.PI/5, 0.1); | |
| const pitchTarget = (key.up?0.35:0) + (key.down?-0.35:0); | |
| pitch = THREE.MathUtils.lerp(pitch, pitchTarget, 0.12); | |
| // Forward speed, drag & burst | |
| const baseAcc = 0.0; | |
| forwardSpeed.current += baseAcc*dt; | |
| forwardSpeed.current *= (1 - drag); // simple drag | |
| if ((key.burst || key.down) && stamina>0){ | |
| forwardSpeed.current += 8*dt; | |
| vel.y += 5.5*dt; // upward impulse during burst | |
| stamina -= 18*dt; | |
| } else { | |
| stamina += 4*dt; // slow regen | |
| } | |
| stamina = THREE.MathUtils.clamp(stamina, 0, maxStamina); | |
| // Lift from pitch and forward motion | |
| const lift = (Math.sin(pitch) * (forwardSpeed.current+6)) * 0.9; | |
| vel.y += (lift - gravity) * dt; | |
| // Gentle random gusts | |
| const gust = (Math.random()-0.5) * 0.3; | |
| const lateralControl = (steerRight?-1:0) + (steerLeft?1:0); | |
| glider.position.x += (lateralControl*3.8 + gust) * dt; | |
| // Move forward | |
| glider.position.z += (forwardSpeed.current + 10) * dt; | |
| // Vertical | |
| glider.position.y += vel.y * dt; | |
| // Visual orientation (x = pitch, z = bank) | |
| glider.rotation.x = -Math.PI/2 + ( -pitch * 0.6 ); | |
| glider.rotation.z = bank * 0.8; | |
| // Thermals | |
| if (inThermalBoost(glider.position)){ | |
| vel.y += 6.5*dt; | |
| score += 5 * combo; // tiny drip while in column | |
| } | |
| // Check rings | |
| for (let i=rings.length-1;i>=0;i--){ | |
| const t = rings[i]; | |
| const d = glider.position.distanceTo(t.position); | |
| if (d < 1.6){ | |
| ringCollected(t); | |
| } else { | |
| // animate ring pulse | |
| t.rotation.x += 0.6*dt; | |
| t.rotation.z += 0.4*dt; | |
| const s = 1 + 0.08*Math.sin(performance.now()/260 + i); | |
| t.scale.set(s,s,s); | |
| } | |
| } | |
| // Combo decay | |
| comboTimer -= dt; | |
| if (comboTimer <= 0 && combo>1){ | |
| combo = Math.max(1, combo-1); | |
| comboTimer = 0.35; // prevent rapid drop | |
| } | |
| // Distance & score | |
| const dz = (forwardSpeed.current + 10) * dt; | |
| distance += dz; | |
| score += dz * (0.8*combo); | |
| // Collision with ground/ walls | |
| const groundY = sampleGroundY(glider.position.x, glider.position.z); | |
| if (glider.position.y <= groundY + 0.05){ | |
| endRun('You skimmed the canyon floor!'); | |
| } | |
| // Fail-safe: out of bounds (too high or too far sideways) | |
| if (glider.position.y > 40 || Math.abs(glider.position.x) > 50){ | |
| endRun('You drifted off course!'); | |
| } | |
| } | |
| // Camera chase rig | |
| const camTarget = new THREE.Vector3(glider.position.x, glider.position.y + 3.0, glider.position.z - 10); | |
| camera.position.lerp(camTarget, 0.12); | |
| camera.lookAt(glider.position); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| // ---------- Resize ---------- | |
| addEventListener('resize', ()=>{ | |
| camera.aspect = innerWidth/innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| }); | |