Glider / script.js
ZENLLC's picture
Rename scropt.js to script.js
48702dc verified
/* 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);
});