|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const scene = new THREE.Scene(); |
|
|
|
|
|
|
|
|
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'); |
|
|
grad.addColorStop(0.45, '#0f2240'); |
|
|
grad.addColorStop(1.00, '#0a0a0f'); |
|
|
skyCtx.fillStyle = grad; skyCtx.fillRect(0,0,1,256); |
|
|
const skyTex = new THREE.CanvasTexture(skyCanvas); |
|
|
scene.background = skyTex; |
|
|
|
|
|
|
|
|
{ |
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
{ |
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
function populateEnvironment(){ |
|
|
|
|
|
rings.splice(0, rings.length); |
|
|
thermals.splice(0, thermals.length); |
|
|
|
|
|
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); |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
function makeGlider(){ |
|
|
const group = new THREE.Group(); |
|
|
const paper = new THREE.MeshLambertMaterial({ color: 0xf4f6f8, side: THREE.DoubleSide }); |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
let game = "aim"; |
|
|
let power = 0, maxPower = 14, charging = false; |
|
|
let stamina = 100, maxStamina = 100; |
|
|
let combo = 1, comboTimer = 0, comboWindow = 2.5; |
|
|
let distance = 0, score = 0; |
|
|
|
|
|
const vel = new THREE.Vector3(0,0,0); |
|
|
const forwardSpeed = { base: 14, current: 0 }; |
|
|
const gravity = 5.0; |
|
|
const drag = 0.015; |
|
|
let pitch = 0; |
|
|
let bank = 0; |
|
|
|
|
|
|
|
|
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); |
|
|
game = "fly"; power = 0; charging = false; |
|
|
} |
|
|
|
|
|
|
|
|
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)}%`; } |
|
|
|
|
|
|
|
|
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(); } }); |
|
|
|
|
|
|
|
|
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){ |
|
|
|
|
|
scene.remove(t); |
|
|
rings.splice(rings.indexOf(t),1); |
|
|
|
|
|
stamina = Math.min(maxStamina, stamina + 35); |
|
|
combo = Math.min(10, combo + 1); |
|
|
comboTimer = comboWindow; |
|
|
score += 150 * combo; |
|
|
} |
|
|
|
|
|
function inThermalBoost(p){ |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
function animate(){ |
|
|
requestAnimationFrame(animate); |
|
|
const dt = Math.min(0.033, clock.getDelta()); |
|
|
|
|
|
|
|
|
const ratio = Math.min(1, distance / 1500); |
|
|
skyTex.rotation = ratio * Math.PI * 0.4; skyTex.center.set(0.5,0.5); skyTex.needsUpdate = true; |
|
|
|
|
|
|
|
|
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; |
|
|
power = Math.min(power, maxPower); |
|
|
} |
|
|
} |
|
|
|
|
|
if (game==='fly'){ |
|
|
|
|
|
const steerLeft = key.left || touchLeftActive; |
|
|
const steerRight = key.right || touchRightActive; |
|
|
|
|
|
const bankTarget = (steerLeft?-1:0) + (steerRight?1:0); |
|
|
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); |
|
|
|
|
|
|
|
|
const baseAcc = 0.0; |
|
|
forwardSpeed.current += baseAcc*dt; |
|
|
forwardSpeed.current *= (1 - drag); |
|
|
|
|
|
if ((key.burst || key.down) && stamina>0){ |
|
|
forwardSpeed.current += 8*dt; |
|
|
vel.y += 5.5*dt; |
|
|
stamina -= 18*dt; |
|
|
} else { |
|
|
stamina += 4*dt; |
|
|
} |
|
|
stamina = THREE.MathUtils.clamp(stamina, 0, maxStamina); |
|
|
|
|
|
|
|
|
const lift = (Math.sin(pitch) * (forwardSpeed.current+6)) * 0.9; |
|
|
vel.y += (lift - gravity) * dt; |
|
|
|
|
|
|
|
|
const gust = (Math.random()-0.5) * 0.3; |
|
|
const lateralControl = (steerRight?-1:0) + (steerLeft?1:0); |
|
|
glider.position.x += (lateralControl*3.8 + gust) * dt; |
|
|
|
|
|
|
|
|
glider.position.z += (forwardSpeed.current + 10) * dt; |
|
|
|
|
|
glider.position.y += vel.y * dt; |
|
|
|
|
|
|
|
|
glider.rotation.x = -Math.PI/2 + ( -pitch * 0.6 ); |
|
|
glider.rotation.z = bank * 0.8; |
|
|
|
|
|
|
|
|
if (inThermalBoost(glider.position)){ |
|
|
vel.y += 6.5*dt; |
|
|
score += 5 * combo; |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
comboTimer -= dt; |
|
|
if (comboTimer <= 0 && combo>1){ |
|
|
combo = Math.max(1, combo-1); |
|
|
comboTimer = 0.35; |
|
|
} |
|
|
|
|
|
|
|
|
const dz = (forwardSpeed.current + 10) * dt; |
|
|
distance += dz; |
|
|
score += dz * (0.8*combo); |
|
|
|
|
|
|
|
|
const groundY = sampleGroundY(glider.position.x, glider.position.z); |
|
|
if (glider.position.y <= groundY + 0.05){ |
|
|
endRun('You skimmed the canyon floor!'); |
|
|
} |
|
|
|
|
|
|
|
|
if (glider.position.y > 40 || Math.abs(glider.position.x) > 50){ |
|
|
endRun('You drifted off course!'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
addEventListener('resize', ()=>{ |
|
|
camera.aspect = innerWidth/innerHeight; |
|
|
camera.updateProjectionMatrix(); |
|
|
renderer.setSize(innerWidth, innerHeight); |
|
|
}); |
|
|
|