/* 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 { 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); });