|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width,initial-scale=1"> |
|
<title>NEON ARENA</title> |
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%2300e5ff'/%3E%3C/svg%3E"> |
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@500;600;700&display=swap" rel="stylesheet"> |
|
<style> |
|
:root{ --cyan:#00e5ff; --mag:#ff2d78; --bg:#05060f; } |
|
*{ margin:0; padding:0; box-sizing:border-box; } |
|
html,body{ width:100%; height:100%; overflow:hidden; background:var(--bg); } |
|
body{ font-family:'Rajdhani',system-ui,sans-serif; color:#cfefff; user-select:none; -webkit-user-select:none; } |
|
canvas{ display:block; } |
|
|
|
/* ---------- HUD ---------- */ |
|
#hud{ position:fixed; inset:0; pointer-events:none; z-index:5; } |
|
#scoreBox{ position:absolute; top:26px; left:30px; } |
|
.label{ font-weight:600; font-size:13px; letter-spacing:.42em; color:#5f8fb8; opacity:.9; } |
|
#score{ |
|
font-family:'Orbitron',monospace; font-weight:700; font-size:46px; line-height:1.05; |
|
color:#eaffff; text-shadow:0 0 18px rgba(0,229,255,.75), 0 0 48px rgba(0,229,255,.3); |
|
} |
|
#score.pop{ animation:scorePop .25s ease-out; } |
|
@keyframes scorePop{ 0%{transform:scale(1)} 35%{transform:scale(1.18)} 100%{transform:scale(1)} } |
|
#comboWrap{ margin-top:10px; opacity:0; transition:opacity .3s; } |
|
#comboX{ font-family:'Orbitron',monospace; font-weight:700; font-size:20px; color:var(--mag); text-shadow:0 0 14px rgba(255,45,120,.85); } |
|
#comboBarOuter{ width:130px; height:3px; background:rgba(0,229,255,.15); margin-top:5px; overflow:hidden; } |
|
#comboBar{ height:100%; width:100%; background:linear-gradient(90deg,var(--cyan),var(--mag)); box-shadow:0 0 10px rgba(0,229,255,.8); } |
|
#levelChip{ |
|
position:absolute; top:30px; left:50%; transform:translateX(-50%); |
|
font-family:'Orbitron',monospace; font-weight:700; font-size:15px; letter-spacing:.3em; padding:8px 16px 8px 20px; |
|
color:#b48cff; border:1px solid rgba(150,100,255,.4); background:rgba(30,16,60,.35); |
|
text-shadow:0 0 12px rgba(150,100,255,.8); backdrop-filter:blur(4px); |
|
} |
|
#levelChip.bump{ animation:chipBump .5s ease-out; } |
|
@keyframes chipBump{ |
|
0%{ transform:translateX(-50%) scale(1); } |
|
30%{ transform:translateX(-50%) scale(1.25); border-color:#b48cff; box-shadow:0 0 24px rgba(150,100,255,.5); } |
|
100%{ transform:translateX(-50%) scale(1); } |
|
} |
|
#lives{ position:absolute; top:30px; right:30px; display:flex; gap:10px; } |
|
#lives svg{ width:30px; height:30px; fill:var(--cyan); filter:drop-shadow(0 0 8px rgba(0,229,255,.8)); transition:all .3s; } |
|
#lives svg.lost{ fill:#22303f; filter:none; transform:scale(.82) rotate(-18deg); animation:heartBreak .5s ease-out; } |
|
@keyframes heartBreak{ 0%{ transform:scale(1.45); } 100%{ transform:scale(.82) rotate(-18deg); } } |
|
|
|
/* ---------- overlays / fx ---------- */ |
|
#vignette{ position:fixed; inset:0; pointer-events:none; z-index:4; |
|
background:radial-gradient(ellipse at center, transparent 52%, rgba(2,3,10,.62) 100%); } |
|
#scanlines{ position:fixed; inset:0; pointer-events:none; z-index:4; opacity:.05; mix-blend-mode:overlay; |
|
background:repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.5) 2px 3px); } |
|
#flash{ position:fixed; inset:0; pointer-events:none; z-index:6; opacity:0; } |
|
#msg{ |
|
position:fixed; top:22%; left:0; right:0; text-align:center; opacity:0; pointer-events:none; z-index:7; |
|
font-family:'Orbitron',monospace; font-weight:900; font-size:44px; letter-spacing:.25em; |
|
} |
|
#msg.show{ animation:msgIn 1.5s ease-out forwards; } |
|
@keyframes msgIn{ |
|
0%{ opacity:0; transform:scale(.6); } |
|
18%{ opacity:1; transform:scale(1.06); } |
|
30%{ transform:scale(1); } |
|
75%{ opacity:1; } |
|
100%{ opacity:0; transform:translateY(-14px) scale(1); } |
|
} |
|
#hint{ |
|
position:fixed; bottom:26px; left:0; right:0; text-align:center; opacity:0; pointer-events:none; z-index:5; |
|
font-weight:600; font-size:15px; letter-spacing:.4em; color:#6fa8c9; |
|
} |
|
|
|
/* ---------- screens ---------- */ |
|
.overlay{ |
|
position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; |
|
background:rgba(4,6,15,.66); backdrop-filter:blur(7px); z-index:10; |
|
opacity:0; pointer-events:none; transition:opacity .45s; |
|
} |
|
.overlay.show{ opacity:1; pointer-events:auto; } |
|
#scrTitle{ font-family:'Orbitron',monospace; font-weight:900; font-size:clamp(44px,8vw,88px); letter-spacing:.18em; padding-left:.18em; } |
|
.overlay.show #scrTitle{ animation:titleGlow 3s ease-in-out infinite; } |
|
@keyframes titleGlow{ 0%,100%{ filter:brightness(1); } 50%{ filter:brightness(1.3); } } |
|
#scrSub{ font-size:17px; letter-spacing:.5em; padding-left:.5em; text-transform:uppercase; color:#7fb6d8; } |
|
#scrStats{ display:flex; gap:34px; margin-top:8px; } |
|
.stat{ text-align:center; } |
|
.stat span{ display:block; font-size:12px; letter-spacing:.34em; color:#5f8fb8; margin-bottom:4px; } |
|
.stat b{ font-family:'Orbitron',monospace; font-size:26px; color:#eaffff; text-shadow:0 0 14px rgba(0,229,255,.6); } |
|
#scrPrompt{ |
|
margin-top:26px; font-family:'Orbitron',monospace; font-weight:700; font-size:18px; letter-spacing:.3em; |
|
color:#9fefff; animation:pulse 1.6s ease-in-out infinite; |
|
} |
|
#scrPrompt.danger{ color:#ff9dbb; } |
|
@keyframes pulse{ 0%,100%{ opacity:.35; } 50%{ opacity:1; } } |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<div id="hud"> |
|
<div id="scoreBox"> |
|
<div class="label">SCORE</div> |
|
<div id="score">0</div> |
|
<div id="comboWrap"> |
|
<div id="comboX">x2</div> |
|
<div id="comboBarOuter"><div id="comboBar"></div></div> |
|
</div> |
|
</div> |
|
<div id="levelChip">LVL 1</div> |
|
<div id="lives"></div> |
|
</div> |
|
|
|
<div id="vignette"></div> |
|
<div id="scanlines"></div> |
|
<div id="flash"></div> |
|
<div id="msg"></div> |
|
<div id="hint">W A S D · MOVE</div> |
|
|
|
<div id="screen" class="overlay"> |
|
<div id="scrTitle">NEON ARENA</div> |
|
<div id="scrSub">collect the light · survive the swarm</div> |
|
<div id="scrStats"></div> |
|
<div id="scrPrompt">PRESS ANY KEY TO START</div> |
|
</div> |
|
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.152.2/build/three.min.js"></script> |
|
<script> |
|
(() => { |
|
'use strict'; |
|
|
|
/* ============================ helpers ============================ */ |
|
const TAU = Math.PI * 2; |
|
const rand = (a, b) => a + Math.random() * (b - a); |
|
const randInt = (a, b) => Math.floor(rand(a, b + 1)); |
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); |
|
const lerp = (a, b, t) => a + (b - a) * t; |
|
const damp = (a, b, l, dt) => lerp(a, b, 1 - Math.exp(-l * dt)); |
|
const smooth = (a, b, x) => { x = clamp((x - a) / (b - a), 0, 1); return x * x * (3 - 2 * x); }; |
|
const easeOutBack = t => { const c = 1.70158; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); }; |
|
const dampAngle = (a, b, l, dt) => { |
|
let d = (b - a) % TAU; |
|
if (d > Math.PI) d -= TAU; |
|
if (d < -Math.PI) d += TAU; |
|
return a + d * (1 - Math.exp(-l * dt)); |
|
}; |
|
|
|
/* ============================ config ============================ */ |
|
const ARENA = 22; // arena half-size |
|
const PLAYER_ACC = 62; // accel units/s^2 |
|
const PLAYER_DRAG = 4.2; // exponential drag |
|
const PLAYER_MAX_SPD = 13.5; |
|
const START_LIVES = 3; |
|
const UP = new THREE.Vector3(0, 1, 0); |
|
|
|
/* ============================ renderer / scene ============================ */ |
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); |
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
renderer.shadowMap.enabled = true; |
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
renderer.toneMapping = THREE.ACESFilmicToneMapping; |
|
renderer.toneMappingExposure = 1.15; |
|
document.body.appendChild(renderer.domElement); |
|
|
|
const scene = new THREE.Scene(); |
|
scene.background = new THREE.Color(0x05060f); |
|
scene.fog = new THREE.Fog(0x070a16, 26, 95); |
|
|
|
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 700); |
|
camera.position.set(0, 7, 14); |
|
|
|
/* ============================ lights ============================ */ |
|
scene.add(new THREE.HemisphereLight(0x4466aa, 0x0a0a18, 0.55)); |
|
|
|
const sun = new THREE.DirectionalLight(0x99bbff, 1.15); |
|
sun.position.set(14, 22, 10); |
|
sun.castShadow = true; |
|
sun.shadow.mapSize.set(2048, 2048); |
|
sun.shadow.camera.left = -30; sun.shadow.camera.right = 30; |
|
sun.shadow.camera.top = 30; sun.shadow.camera.bottom = -30; |
|
sun.shadow.camera.near = 2; sun.shadow.camera.far = 70; |
|
sun.shadow.bias = -0.0004; |
|
sun.shadow.normalBias = 0.02; |
|
scene.add(sun); |
|
|
|
const rim = new THREE.PointLight(0xff2d78, 0.5, 60, 2); |
|
rim.position.set(-16, 8, -14); |
|
scene.add(rim); |
|
|
|
/* ============================ canvas textures ============================ */ |
|
function makeCanvas(w, h, fn) { |
|
const c = document.createElement('canvas'); |
|
c.width = w; c.height = h; |
|
fn(c.getContext('2d'), w, h); |
|
return c; |
|
} |
|
|
|
const glowTex = new THREE.CanvasTexture(makeCanvas(128, 128, (x) => { |
|
const g = x.createRadialGradient(64, 64, 0, 64, 64, 64); |
|
g.addColorStop(0, 'rgba(255,255,255,1)'); |
|
g.addColorStop(0.3, 'rgba(255,255,255,0.5)'); |
|
g.addColorStop(1, 'rgba(255,255,255,0)'); |
|
x.fillStyle = g; x.fillRect(0, 0, 128, 128); |
|
})); |
|
|
|
const ringTex = new THREE.CanvasTexture(makeCanvas(256, 256, (x) => { |
|
x.strokeStyle = 'rgba(255,255,255,0.9)'; |
|
x.lineWidth = 10; |
|
x.shadowColor = 'rgba(255,255,255,1)'; |
|
x.shadowBlur = 22; |
|
x.beginPath(); x.arc(128, 128, 96, 0, TAU); x.stroke(); |
|
x.shadowBlur = 0; |
|
x.lineWidth = 3; |
|
x.beginPath(); x.arc(128, 128, 96, 0, TAU); x.stroke(); |
|
})); |
|
|
|
const groundTex = new THREE.CanvasTexture(makeCanvas(1024, 1024, (x, w, h) => { |
|
x.fillStyle = '#0a0e1e'; x.fillRect(0, 0, w, h); |
|
let g = x.createRadialGradient(512, 512, 0, 512, 512, 620); |
|
g.addColorStop(0, 'rgba(35,50,95,0.5)'); |
|
g.addColorStop(1, 'rgba(8,10,24,0)'); |
|
x.fillStyle = g; x.fillRect(0, 0, w, h); |
|
x.strokeStyle = 'rgba(0,220,255,0.055)'; x.lineWidth = 2; |
|
for (let i = 0; i <= w; i += 32) { x.beginPath(); x.moveTo(i, 0); x.lineTo(i, h); x.moveTo(0, i); x.lineTo(w, i); x.stroke(); } |
|
x.strokeStyle = 'rgba(0,220,255,0.13)'; x.lineWidth = 3; |
|
for (let i = 0; i <= w; i += 128) { x.beginPath(); x.moveTo(i, 0); x.lineTo(i, h); x.moveTo(0, i); x.lineTo(w, i); x.stroke(); } |
|
x.strokeStyle = 'rgba(0,229,255,0.28)'; x.lineWidth = 4; |
|
x.beginPath(); x.arc(512, 512, 86, 0, TAU); x.stroke(); |
|
x.setLineDash([20, 16]); x.lineWidth = 3; x.strokeStyle = 'rgba(0,229,255,0.22)'; |
|
x.beginPath(); x.arc(512, 512, 132, 0, TAU); x.stroke(); |
|
x.setLineDash([]); |
|
x.strokeStyle = 'rgba(0,229,255,0.3)'; x.lineWidth = 4; |
|
for (let a = 0; a < 4; a++) { |
|
const ang = a * Math.PI / 2, c1 = Math.cos(ang), s1 = Math.sin(ang); |
|
x.beginPath(); x.moveTo(512 + c1 * 140, 512 + s1 * 140); x.lineTo(512 + c1 * 180, 512 + s1 * 180); x.stroke(); |
|
} |
|
g = x.createRadialGradient(512, 512, 300, 512, 512, 724); |
|
g.addColorStop(0, 'rgba(2,3,10,0)'); |
|
g.addColorStop(1, 'rgba(2,3,10,0.9)'); |
|
x.fillStyle = g; x.fillRect(0, 0, w, h); |
|
for (let i = 0; i < 500; i++) { |
|
x.fillStyle = 'rgba(170,210,255,' + (Math.random() * 0.07).toFixed(3) + ')'; |
|
x.fillRect(Math.random() * w, Math.random() * h, rand(1, 2.4), rand(1, 2.4)); |
|
} |
|
})); |
|
groundTex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()); |
|
groundTex.colorSpace = THREE.SRGBColorSpace; |
|
|
|
/* ============================ sky / stars / moon ============================ */ |
|
scene.add(new THREE.Mesh( |
|
new THREE.SphereGeometry(300, 32, 16), |
|
new THREE.ShaderMaterial({ |
|
side: THREE.BackSide, depthWrite: false, fog: false, |
|
uniforms: { |
|
cTop: { value: new THREE.Color(0x03040c) }, |
|
cMid: { value: new THREE.Color(0x1b1440) }, |
|
cBot: { value: new THREE.Color(0x05060f) } |
|
}, |
|
vertexShader: ` |
|
varying vec3 vP; |
|
void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, |
|
fragmentShader: ` |
|
uniform vec3 cTop; uniform vec3 cMid; uniform vec3 cBot; |
|
varying vec3 vP; |
|
void main(){ |
|
float h = normalize(vP).y; |
|
vec3 c = mix(cBot, cMid, smoothstep(-0.3, 0.12, h)); |
|
c = mix(c, cTop, smoothstep(0.12, 0.8, h)); |
|
c += cMid * 0.5 * exp(-abs(h - 0.02) * 10.0); |
|
gl_FragColor = vec4(c, 1.0); |
|
}` |
|
}) |
|
)); |
|
|
|
const starMat = new THREE.PointsMaterial({ |
|
size: 1.8, sizeAttenuation: false, vertexColors: true, |
|
transparent: true, opacity: 0.9, fog: false, depthWrite: false |
|
}); |
|
{ |
|
const n = 380, pos = new Float32Array(n * 3), col = new Float32Array(n * 3); |
|
const c = new THREE.Color(); |
|
for (let i = 0; i < n; i++) { |
|
const th = rand(0, TAU), ph = Math.acos(rand(0.05, 1)), r = rand(240, 290); |
|
pos[i * 3] = r * Math.sin(ph) * Math.cos(th); |
|
pos[i * 3 + 1] = r * Math.cos(ph); |
|
pos[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th); |
|
c.setHSL(rand(0.55, 0.75), rand(0.2, 0.7), rand(0.5, 0.95)); |
|
col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; |
|
} |
|
const g = new THREE.BufferGeometry(); |
|
g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); |
|
g.setAttribute('color', new THREE.BufferAttribute(col, 3)); |
|
const stars = new THREE.Points(g, starMat); |
|
stars.frustumCulled = false; |
|
scene.add(stars); |
|
} |
|
|
|
function glowSprite(hex, opacity, scale, pos) { |
|
const s = new THREE.Sprite(new THREE.SpriteMaterial({ |
|
map: glowTex, color: hex, transparent: true, opacity: opacity, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
s.scale.setScalar(scale); |
|
s.position.copy(pos); |
|
scene.add(s); |
|
return s; |
|
} |
|
const moonPos = new THREE.Vector3(-120, 95, -180); |
|
glowSprite(0x8899ff, 0.55, 70, moonPos); |
|
glowSprite(0xdde6ff, 0.9, 22, moonPos); |
|
|
|
/* ============================ arena floor & boundary ============================ */ |
|
const ground = new THREE.Mesh( |
|
new THREE.PlaneGeometry(ARENA * 2, ARENA * 2), |
|
new THREE.MeshStandardMaterial({ map: groundTex, roughness: 0.85, metalness: 0.25 }) |
|
); |
|
ground.rotation.x = -Math.PI / 2; |
|
ground.receiveShadow = true; |
|
scene.add(ground); |
|
|
|
const voidPlane = new THREE.Mesh( |
|
new THREE.PlaneGeometry(600, 600), |
|
new THREE.MeshBasicMaterial({ color: 0x04050c }) |
|
); |
|
voidPlane.rotation.x = -Math.PI / 2; |
|
voidPlane.position.y = -0.05; |
|
scene.add(voidPlane); |
|
|
|
const walls = []; |
|
{ |
|
const wallGeo = new THREE.PlaneGeometry(ARENA * 2, 2.4); |
|
[[0, -ARENA, 0], [0, ARENA, Math.PI], [-ARENA, 0, Math.PI / 2], [ARENA, 0, -Math.PI / 2]].forEach(([wx, wz, ry]) => { |
|
const m = new THREE.Mesh(wallGeo, new THREE.MeshBasicMaterial({ |
|
color: 0x00e5ff, transparent: true, opacity: 0.08, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false, side: THREE.DoubleSide |
|
})); |
|
m.position.set(wx, 1.2, wz); |
|
m.rotation.y = ry; |
|
scene.add(m); |
|
walls.push(m); |
|
}); |
|
} |
|
const edgeMat = new THREE.MeshBasicMaterial({ |
|
color: 0x00e5ff, transparent: true, opacity: 0.5, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
}); |
|
function edgeBar(len, x, z, ry) { |
|
const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.14, 0.3), edgeMat); |
|
m.position.set(x, 0.07, z); m.rotation.y = ry; |
|
scene.add(m); |
|
} |
|
edgeBar(ARENA * 2 + 0.6, 0, -ARENA, 0); |
|
edgeBar(ARENA * 2 + 0.6, 0, ARENA, 0); |
|
edgeBar(ARENA * 2 + 0.6, -ARENA, 0, Math.PI / 2); |
|
edgeBar(ARENA * 2 + 0.6, ARENA, 0, Math.PI / 2); |
|
|
|
const pillarMat = new THREE.MeshBasicMaterial({ |
|
color: 0xff2d78, transparent: true, opacity: 0.4, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
}); |
|
{ |
|
const pillarGeo = new THREE.BoxGeometry(0.5, 5, 0.5); |
|
[[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([sx, sz]) => { |
|
const p = new THREE.Mesh(pillarGeo, pillarMat); |
|
p.position.set(sx * ARENA, 2.5, sz * ARENA); |
|
scene.add(p); |
|
const s = new THREE.Sprite(new THREE.SpriteMaterial({ |
|
map: glowTex, color: 0xff2d78, transparent: true, opacity: 0.8, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
s.position.set(sx * ARENA, 5.2, sz * ARENA); |
|
s.scale.setScalar(2.5); |
|
scene.add(s); |
|
}); |
|
} |
|
|
|
/* ============================ player ============================ */ |
|
const player = new THREE.Group(); |
|
const playerBody = new THREE.Group(); |
|
player.add(playerBody); |
|
|
|
const playerMat = new THREE.MeshStandardMaterial({ |
|
color: 0x0a2a38, emissive: 0x00d9ff, emissiveIntensity: 1.1, |
|
metalness: 0.7, roughness: 0.25, flatShading: true |
|
}); |
|
const playerCore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1), playerMat); |
|
playerCore.castShadow = true; |
|
playerBody.add(playerCore); |
|
|
|
{ |
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xeaffff, fog: false }); |
|
const eyeGeo = new THREE.SphereGeometry(0.07, 8, 8); |
|
const eL = new THREE.Mesh(eyeGeo, eyeMat); eL.position.set(-0.16, 0.08, 0.42); |
|
const eR = new THREE.Mesh(eyeGeo, eyeMat); eR.position.set( 0.16, 0.08, 0.42); |
|
playerBody.add(eL, eR); |
|
} |
|
|
|
const ring1 = new THREE.Mesh(new THREE.TorusGeometry(0.8, 0.045, 10, 36), |
|
new THREE.MeshBasicMaterial({ color: 0x9df8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, fog: false })); |
|
const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.98, 0.03, 10, 36), |
|
new THREE.MeshBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false, fog: false })); |
|
playerBody.add(ring1, ring2); |
|
|
|
const playerGlow = new THREE.Sprite(new THREE.SpriteMaterial({ |
|
map: glowTex, color: 0x00e5ff, transparent: true, opacity: 0.5, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
playerGlow.scale.setScalar(2.4); |
|
playerBody.add(playerGlow); |
|
|
|
const playerLight = new THREE.PointLight(0x00e5ff, 1.6, 18, 2); |
|
playerLight.position.y = 0.4; |
|
playerBody.add(playerLight); |
|
|
|
const pool = new THREE.Mesh(new THREE.CircleGeometry(1.3, 32), |
|
new THREE.MeshBasicMaterial({ color: 0x00b7d9, transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending, depthWrite: false, fog: false })); |
|
pool.rotation.x = -Math.PI / 2; |
|
pool.position.y = 0.05; |
|
player.add(pool); |
|
|
|
const shield = new THREE.Mesh(new THREE.SphereGeometry(1.05, 24, 16), new THREE.ShaderMaterial({ |
|
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, |
|
uniforms: { uOp: { value: 0 }, uTime: { value: 0 } }, |
|
vertexShader: ` |
|
varying vec3 vN; varying vec3 vV; |
|
void main(){ |
|
vN = normalize(normalMatrix * normal); |
|
vec4 mv = modelViewMatrix * vec4(position, 1.0); |
|
vV = normalize(-mv.xyz); |
|
gl_Position = projectionMatrix * mv; |
|
}`, |
|
fragmentShader: ` |
|
uniform float uOp; uniform float uTime; |
|
varying vec3 vN; varying vec3 vV; |
|
void main(){ |
|
float f = pow(1.0 - clamp(dot(vN, vV), 0.0, 1.0), 2.4); |
|
float pulse = 0.75 + 0.25 * sin(uTime * 6.0); |
|
vec3 col = mix(vec3(0.0, 0.85, 1.0), vec3(1.0), f * 0.7); |
|
gl_FragColor = vec4(col, f * 0.85 * uOp * pulse + 0.06 * uOp); |
|
}` |
|
})); |
|
shield.visible = false; |
|
player.add(shield); |
|
|
|
scene.add(player); |
|
|
|
const pv = new THREE.Vector3(); // player velocity |
|
let playerYaw = 0, playerPop = 0, invuln = 0, shieldTime = 0, bank = 0; |
|
|
|
/* ============================ orb ============================ */ |
|
const orb = new THREE.Group(); |
|
const orbMat = new THREE.MeshStandardMaterial({ |
|
color: 0x03222e, emissive: 0x00e5ff, emissiveIntensity: 2.0, metalness: 0.3, roughness: 0.2 |
|
}); |
|
const orbMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.38, 2), orbMat); |
|
orb.add(orbMesh); |
|
const orbRing = new THREE.Mesh(new THREE.TorusGeometry(0.62, 0.03, 8, 32), |
|
new THREE.MeshBasicMaterial({ color: 0xaefcff, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending, depthWrite: false, fog: false })); |
|
orb.add(orbRing); |
|
const orbGlow = new THREE.Sprite(new THREE.SpriteMaterial({ |
|
map: glowTex, color: 0x00e5ff, transparent: true, opacity: 0.85, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
orbGlow.scale.setScalar(3.2); |
|
orb.add(orbGlow); |
|
const orbLight = new THREE.PointLight(0x00e5ff, 0.9, 10, 2); |
|
orb.add(orbLight); |
|
scene.add(orb); |
|
let orbGolden = false, orbSpawnT = 0; |
|
|
|
function spawnOrb() { |
|
let x = 0, z = 0; |
|
for (let i = 0; i < 32; i++) { |
|
const a = rand(0, TAU), r = rand(2.5, ARENA - 2.5); |
|
x = Math.cos(a) * r; z = Math.sin(a) * r; |
|
if (Math.hypot(x - player.position.x, z - player.position.z) > 7) break; |
|
} |
|
orb.position.set(x, 0, z); |
|
orbGolden = Math.random() < 0.12; |
|
const col = orbGolden ? 0xffc84d : 0x00e5ff; |
|
orbMat.emissive.setHex(col); |
|
orbMat.color.setHex(orbGolden ? 0x2a1a03 : 0x03222e); |
|
orbGlow.material.color.setHex(col); |
|
orbLight.color.setHex(col); |
|
orbRing.material.color.setHex(orbGolden ? 0xffe6a8 : 0xaefcff); |
|
orb.scale.setScalar(0.01); |
|
orbSpawnT = gameTime; |
|
} |
|
|
|
function animateOrb(dt) { |
|
const st = (gameTime - orbSpawnT) / 0.35; |
|
orb.scale.setScalar(st < 1 ? Math.max(0.01, easeOutBack(clamp(st, 0, 1))) : 1); |
|
orb.position.y = 1.05 + Math.sin(gameTime * 2.4) * 0.18; |
|
orbRing.rotation.x += dt * 1.4; |
|
orbRing.rotation.y += dt * 2.1; |
|
orbMesh.rotation.y += dt * 1.2; |
|
const p = 0.75 + 0.2 * Math.sin(gameTime * 4.5); |
|
orbGlow.material.opacity = orbGolden ? 0.85 : p * 0.8; |
|
orbLight.intensity = (orbGolden ? 1.4 : 0.9) * (0.8 + 0.4 * Math.sin(gameTime * 4.5)); |
|
} |
|
|
|
/* ============================ enemies ============================ */ |
|
const ENEMY_MAX = 14; |
|
const enemies = []; |
|
{ |
|
const eBodyGeo = new THREE.IcosahedronGeometry(0.55, 0); |
|
const eCoreGeo = new THREE.IcosahedronGeometry(0.3, 1); |
|
const eEyeGeo = new THREE.SphereGeometry(0.075, 8, 8); |
|
const eRingGeo = new THREE.TorusGeometry(0.78, 0.035, 8, 28); |
|
const eEyeMat = new THREE.MeshBasicMaterial({ color: 0xfff5f5, fog: false }); |
|
for (let i = 0; i < ENEMY_MAX; i++) { |
|
const g = new THREE.Group(); |
|
const body = new THREE.Mesh(eBodyGeo, new THREE.MeshStandardMaterial({ |
|
color: 0x1c050c, emissive: 0xff2d4d, emissiveIntensity: 0.85, |
|
flatShading: true, metalness: 0.4, roughness: 0.35 |
|
})); |
|
body.castShadow = true; |
|
const core = new THREE.Mesh(eCoreGeo, new THREE.MeshBasicMaterial({ |
|
color: 0xff7788, transparent: true, opacity: 0.9, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ |
|
map: glowTex, color: 0xff2d55, transparent: true, opacity: 0.5, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
glow.scale.setScalar(2.8); |
|
const eyeL = new THREE.Mesh(eEyeGeo, eEyeMat); eyeL.position.set(-0.17, 0.12, 0.4); |
|
const eyeR = new THREE.Mesh(eEyeGeo, eEyeMat); eyeR.position.set( 0.17, 0.12, 0.4); |
|
const ring = new THREE.Mesh(eRingGeo, new THREE.MeshBasicMaterial({ |
|
color: 0xff4466, transparent: true, opacity: 0.5, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false |
|
})); |
|
ring.rotation.x = Math.PI / 2.3; |
|
g.add(body, core, glow, eyeL, eyeR, ring); |
|
g.visible = false; |
|
g.position.y = 0.75; |
|
scene.add(g); |
|
enemies.push({ |
|
g, body, core, ring, glow, |
|
vel: new THREE.Vector3(), |
|
active: false, phase: rand(0, TAU), spawnT: 0, speed: 4 |
|
}); |
|
} |
|
} |
|
|
|
function spawnEnemy() { |
|
const e = enemies.find(en => !en.active); |
|
if (!e) return; |
|
const side = randInt(0, 3); |
|
const t = rand(-ARENA + 1.5, ARENA - 1.5); |
|
const off = ARENA - 0.9; |
|
const p = e.g.position; |
|
if (side === 0) { p.x = t; p.z = -off; } |
|
else if (side === 1) { p.x = t; p.z = off; } |
|
else if (side === 2) { p.x = -off; p.z = t; } |
|
else { p.x = off; p.z = t; } |
|
p.y = 0.75; |
|
e.vel.set(0, 0, 0); |
|
e.active = true; |
|
e.spawnT = gameTime; |
|
e.speed = Math.min(10.5, 3.3 + 0.5 * (level - 1) + rand(-0.3, 0.6)); |
|
e.g.visible = true; |
|
e.g.scale.setScalar(0.01); |
|
shockwave(p, 0xff2d55, 7); |
|
burst(p, 0xff3355, 12, { spMin: 1, spMax: 4, size: 0.5 }); |
|
} |
|
|
|
/* ============================ particles ============================ */ |
|
const P_MAX = 900; |
|
const pPos = new Float32Array(P_MAX * 3); |
|
const pVel = new Float32Array(P_MAX * 3); |
|
const pCol = new Float32Array(P_MAX * 3); |
|
const pSize = new Float32Array(P_MAX); |
|
const pLife = new Float32Array(P_MAX); |
|
const pMaxLife = new Float32Array(P_MAX).fill(1); |
|
const pGrav = new Float32Array(P_MAX); |
|
const pLifeA = new Float32Array(P_MAX); |
|
let pCursor = 0; |
|
|
|
const pGeo = new THREE.BufferGeometry(); |
|
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage)); |
|
pGeo.setAttribute('aColor', new THREE.BufferAttribute(pCol, 3).setUsage(THREE.DynamicDrawUsage)); |
|
pGeo.setAttribute('aSize', new THREE.BufferAttribute(pSize, 1).setUsage(THREE.DynamicDrawUsage)); |
|
const pLifeAttr = new THREE.BufferAttribute(pLifeA, 1).setUsage(THREE.DynamicDrawUsage); |
|
pGeo.setAttribute('aLife', pLifeAttr); |
|
|
|
const pMat = new THREE.ShaderMaterial({ |
|
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, |
|
vertexShader: ` |
|
attribute vec3 aColor; attribute float aSize; attribute float aLife; |
|
varying vec3 vC; varying float vL; |
|
void main(){ |
|
vC = aColor; vL = aLife; |
|
vec4 mv = modelViewMatrix * vec4(position, 1.0); |
|
gl_PointSize = aSize * (0.35 + 0.65 * aLife) * (240.0 / -mv.z); |
|
gl_Position = projectionMatrix * mv; |
|
}`, |
|
fragmentShader: ` |
|
varying vec3 vC; varying float vL; |
|
void main(){ |
|
float d = length(gl_PointCoord - 0.5); |
|
float a = smoothstep(0.5, 0.05, d) * vL; |
|
if (a < 0.02) discard; |
|
gl_FragColor = vec4(vC, a); |
|
}` |
|
}); |
|
const points = new THREE.Points(pGeo, pMat); |
|
points.frustumCulled = false; |
|
scene.add(points); |
|
|
|
const _burstColor = new THREE.Color(); |
|
function burst(pos, hex, n, o) { |
|
o = o || {}; |
|
_burstColor.setHex(hex); |
|
for (let i = 0; i < n; i++) { |
|
const idx = (pCursor++) % P_MAX; |
|
const th = rand(0, TAU); |
|
const ph = Math.acos(clamp(rand(0.15, 1), -1, 1)); |
|
const sx = Math.sin(ph) * Math.cos(th), sy = Math.cos(ph), sz = Math.sin(ph) * Math.sin(th); |
|
const sp = rand(o.spMin !== undefined ? o.spMin : 2, o.spMax !== undefined ? o.spMax : 6.5); |
|
pPos[idx * 3] = pos.x + rand(-0.15, 0.15); |
|
pPos[idx * 3 + 1] = pos.y + rand(-0.15, 0.15); |
|
pPos[idx * 3 + 2] = pos.z + rand(-0.15, 0.15); |
|
pVel[idx * 3] = sx * sp + (o.vx || 0); |
|
pVel[idx * 3 + 1] = sy * sp + (o.vy || 0); |
|
pVel[idx * 3 + 2] = sz * sp + (o.vz || 0); |
|
const life = rand(o.lifeMin !== undefined ? o.lifeMin : 0.45, o.lifeMax !== undefined ? o.lifeMax : 0.9); |
|
pLife[idx] = life; |
|
pMaxLife[idx] = life; |
|
pGrav[idx] = o.g !== undefined ? o.g : 7; |
|
pSize[idx] = rand(0.3, 0.62) * (o.size !== undefined ? o.size : 1); |
|
const v = rand(0.8, 1.2); |
|
pCol[idx * 3] = clamp(_burstColor.r * v, 0, 1); |
|
pCol[idx * 3 + 1] = clamp(_burstColor.g * v, 0, 1); |
|
pCol[idx * 3 + 2] = clamp(_burstColor.b * v, 0, 1); |
|
} |
|
} |
|
|
|
function clearParticles() { pLife.fill(0); pLifeA.fill(0); } |
|
|
|
function updateParticles(dt) { |
|
const drag = Math.exp(-2.0 * dt); |
|
for (let i = 0; i < P_MAX; i++) { |
|
if (pLife[i] <= 0) { pLifeA[i] = 0; continue; } |
|
pLife[i] -= dt; |
|
if (pLife[i] <= 0) { pLifeA[i] = 0; continue; } |
|
pVel[i * 3 + 1] -= pGrav[i] * dt; |
|
pVel[i * 3] *= drag; |
|
pVel[i * 3 + 1] *= drag; |
|
pVel[i * 3 + 2] *= drag; |
|
let x = pPos[i * 3] + pVel[i * 3] * dt; |
|
let y = pPos[i * 3 + 1] + pVel[i * 3 + 1] * dt; |
|
let z = pPos[i * 3 + 2] + pVel[i * 3 + 2] * dt; |
|
if (y < 0.03 && pVel[i * 3 + 1] < 0) { |
|
y = 0.03; |
|
pVel[i * 3 + 1] *= -0.45; |
|
pVel[i * 3] *= 0.7; |
|
pVel[i * 3 + 2] *= 0.7; |
|
} |
|
pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z; |
|
pLifeA[i] = pLife[i] / pMaxLife[i]; |
|
} |
|
pGeo.attributes.position.needsUpdate = true; |
|
pGeo.attributes.aColor.needsUpdate = true; |
|
pGeo.attributes.aSize.needsUpdate = true; |
|
pLifeAttr.needsUpdate = true; |
|
} |
|
|
|
/* ============================ shockwaves ============================ */ |
|
const waves = []; |
|
for (let i = 0; i < 6; i++) { |
|
const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ |
|
map: ringTex, transparent: true, opacity: 0, |
|
blending: THREE.AdditiveBlending, depthWrite: false, fog: false, side: THREE.DoubleSide |
|
})); |
|
m.rotation.x = -Math.PI / 2; |
|
m.visible = false; |
|
m.position.y = 0.15; |
|
scene.add(m); |
|
waves.push({ m, t: 1, max: 8 }); |
|
} |
|
let wCursor = 0; |
|
function shockwave(pos, hex, maxScale) { |
|
const w = waves[(wCursor++) % waves.length]; |
|
w.m.position.set(pos.x, 0.15, pos.z); |
|
w.m.material.color.setHex(hex); |
|
w.m.visible = true; |
|
w.t = 0; |
|
w.max = maxScale || 8; |
|
} |
|
function updateWaves(dt) { |
|
for (const w of waves) { |
|
if (!w.m.visible) continue; |
|
w.t += dt * 2.6; |
|
if (w.t >= 1) { w.m.visible = false; continue; } |
|
const s = 0.6 + w.t * w.max; |
|
w.m.scale.set(s, s, 1); |
|
w.m.material.opacity = (1 - w.t) * 0.85; |
|
} |
|
} |
|
|
|
/* ============================ score popups ============================ */ |
|
const popups = []; |
|
for (let i = 0; i < 6; i++) { |
|
const cv = document.createElement('canvas'); |
|
cv.width = 256; cv.height = 96; |
|
const tex = new THREE.CanvasTexture(cv); |
|
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0, depthWrite: false, fog: false })); |
|
sp.visible = false; |
|
sp.scale.set(3.4, 1.28, 1); |
|
scene.add(sp); |
|
popups.push({ cv, ctx: cv.getContext('2d'), tex, sp, t: 1 }); |
|
} |
|
let popCursor = 0; |
|
const _popupColor = new THREE.Color(); |
|
function popup(pos, text, hex) { |
|
const p = popups[(popCursor++) % popups.length]; |
|
const x = p.ctx; |
|
x.clearRect(0, 0, 256, 96); |
|
x.font = '700 40px Orbitron, sans-serif'; |
|
x.textAlign = 'center'; |
|
x.textBaseline = 'middle'; |
|
_popupColor.setHex(hex); |
|
x.shadowColor = '#' + _popupColor.getHexString(); |
|
x.shadowBlur = 18; |
|
x.fillStyle = '#ffffff'; |
|
x.fillText(text, 128, 50); |
|
p.tex.needsUpdate = true; |
|
p.sp.position.set(pos.x, pos.y + 1.3, pos.z); |
|
p.sp.visible = true; |
|
p.sp.material.opacity = 1; |
|
p.t = 0; |
|
} |
|
function updatePopups(dt) { |
|
for (const p of popups) { |
|
if (!p.sp.visible) continue; |
|
p.t += dt * 1.15; |
|
if (p.t >= 1) { p.sp.visible = false; continue; } |
|
p.sp.position.y += dt * 2.2; |
|
p.sp.material.opacity = 1 - p.t * p.t; |
|
const s = 1 + 0.25 * Math.sin(p.t * Math.PI); |
|
p.sp.scale.set(3.4 * s, 1.28 * s, 1); |
|
} |
|
} |
|
|
|
/* ============================ ambient dust ============================ */ |
|
const DUST = 130; |
|
const dustPos = new Float32Array(DUST * 3); |
|
const dustVel = []; |
|
for (let i = 0; i < DUST; i++) { |
|
dustPos[i * 3] = rand(-ARENA, ARENA); |
|
dustPos[i * 3 + 1] = rand(0.2, 9); |
|
dustPos[i * 3 + 2] = rand(-ARENA, ARENA); |
|
dustVel.push(new THREE.Vector3(rand(-0.2, 0.2), rand(-0.25, -0.05), rand(-0.2, 0.2))); |
|
} |
|
const dustGeo = new THREE.BufferGeometry(); |
|
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3).setUsage(THREE.DynamicDrawUsage)); |
|
const dust = new THREE.Points(dustGeo, new THREE.PointsMaterial({ |
|
map: glowTex, size: 0.14, transparent: true, opacity: 0.3, |
|
blending: THREE.AdditiveBlending, depthWrite: false, color: 0x5f8cff, fog: false |
|
})); |
|
dust.frustumCulled = false; |
|
scene.add(dust); |
|
function updateDust(dt) { |
|
for (let i = 0; i < DUST; i++) { |
|
dustPos[i * 3] += dustVel[i].x * dt; |
|
dustPos[i * 3 + 1] += dustVel[i].y * dt; |
|
dustPos[i * 3 + 2] += dustVel[i].z * dt; |
|
if (dustPos[i * 3 + 1] < 0.1) dustPos[i * 3 + 1] = 9; |
|
if (Math.abs(dustPos[i * 3]) > ARENA + 2) dustVel[i].x *= -1; |
|
if (Math.abs(dustPos[i * 3 + 2]) > ARENA + 2) dustVel[i].z *= -1; |
|
} |
|
dustGeo.attributes.position.needsUpdate = true; |
|
} |
|
|
|
/* ============================ audio (synth) ============================ */ |
|
const Sfx = { |
|
ctx: null, master: null, noiseBuf: null, |
|
init() { |
|
if (this.ctx) return; |
|
const AC = window.AudioContext || window.webkitAudioContext; |
|
if (!AC) return; |
|
this.ctx = new AC(); |
|
const comp = this.ctx.createDynamicsCompressor(); |
|
comp.threshold.value = -18; comp.knee.value = 20; comp.ratio.value = 8; |
|
this.master = this.ctx.createGain(); |
|
this.master.gain.value = 0.55; |
|
this.master.connect(comp); |
|
comp.connect(this.ctx.destination); |
|
const len = this.ctx.sampleRate | 0; |
|
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); |
|
const d = this.noiseBuf.getChannelData(0); |
|
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; |
|
// ambient pad |
|
const g = this.ctx.createGain(); |
|
g.gain.value = 0; |
|
g.gain.linearRampToValueAtTime(0.05, this.ctx.currentTime + 3); |
|
const filt = this.ctx.createBiquadFilter(); |
|
filt.type = 'lowpass'; filt.frequency.value = 220; filt.Q.value = 0.8; |
|
[55, 82.41].forEach((f, i) => { |
|
const o = this.ctx.createOscillator(); |
|
o.type = i ? 'triangle' : 'sine'; |
|
o.frequency.value = f; |
|
o.detune.value = i ? 5 : 0; |
|
const og = this.ctx.createGain(); |
|
og.gain.value = i ? 0.4 : 1; |
|
o.connect(og); og.connect(filt); |
|
o.start(); |
|
}); |
|
const lfo = this.ctx.createOscillator(); |
|
lfo.frequency.value = 0.07; |
|
const lg = this.ctx.createGain(); |
|
lg.gain.value = 90; |
|
lfo.connect(lg); lg.connect(filt.frequency); |
|
lfo.start(); |
|
filt.connect(g); g.connect(this.master); |
|
}, |
|
resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); }, |
|
tone(o) { |
|
if (!this.ctx) return; |
|
const t0 = this.ctx.currentTime + (o.at || 0); |
|
const dur = o.dur || 0.2; |
|
const osc = this.ctx.createOscillator(); |
|
osc.type = o.type || 'sine'; |
|
osc.frequency.setValueAtTime(o.f0, t0); |
|
if (o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(o.f1, 1), t0 + dur); |
|
const g = this.ctx.createGain(); |
|
g.gain.setValueAtTime(0.0001, t0); |
|
g.gain.exponentialRampToValueAtTime(o.peak || 0.2, t0 + 0.008); |
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); |
|
osc.connect(g); g.connect(this.master); |
|
osc.start(t0); osc.stop(t0 + dur + 0.05); |
|
}, |
|
noise(o) { |
|
if (!this.ctx) return; |
|
const t0 = this.ctx.currentTime + (o.at || 0); |
|
const dur = o.dur || 0.3; |
|
const src = this.ctx.createBufferSource(); |
|
src.buffer = this.noiseBuf; src.loop = true; |
|
const f = this.ctx.createBiquadFilter(); |
|
f.type = o.type || 'bandpass'; |
|
f.frequency.setValueAtTime(o.f0 || 600, t0); |
|
if (o.f1) f.frequency.exponentialRampToValueAtTime(Math.max(o.f1, 1), t0 + dur); |
|
f.Q.value = o.q || 0.8; |
|
const g = this.ctx.createGain(); |
|
g.gain.setValueAtTime(0.0001, t0); |
|
g.gain.exponentialRampToValueAtTime(o.peak || 0.2, t0 + 0.01); |
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); |
|
src.connect(f); f.connect(g); g.connect(this.master); |
|
src.start(t0); src.stop(t0 + dur + 0.05); |
|
}, |
|
pickup(combo) { |
|
const scale = [0, 2, 4, 7, 9, 12, 14, 16]; |
|
const semi = scale[clamp(combo - 1, 0, scale.length - 1)]; |
|
const f = 523.25 * Math.pow(2, semi / 12); |
|
this.tone({ f0: f, f1: f, dur: 0.1, peak: 0.22, type: 'triangle' }); |
|
this.tone({ f0: f * 1.5, dur: 0.14, peak: 0.14, type: 'sine', at: 0.05 }); |
|
this.tone({ f0: f * 2, dur: 0.2, peak: 0.06, type: 'sine', at: 0.09 }); |
|
}, |
|
golden() { |
|
[0, 4, 7, 12, 16].forEach((s, i) => this.tone({ f0: 659.25 * Math.pow(2, s / 12), dur: 0.16, peak: 0.16, type: 'triangle', at: i * 0.07 })); |
|
this.tone({ f0: 1318.5, dur: 0.5, peak: 0.08, type: 'sine', at: 0.35 }); |
|
}, |
|
hurt() { |
|
this.tone({ f0: 160, f1: 50, dur: 0.3, peak: 0.5, type: 'sawtooth' }); |
|
this.noise({ f0: 500, f1: 120, dur: 0.25, peak: 0.35 }); |
|
}, |
|
boom() { |
|
this.noise({ f0: 900, f1: 90, dur: 0.4, peak: 0.4, q: 1.2 }); |
|
this.tone({ f0: 110, f1: 32, dur: 0.35, peak: 0.45, type: 'sine' }); |
|
}, |
|
level() { |
|
[392, 523.25, 659.25].forEach((f, i) => this.tone({ f0: f, dur: 0.1, peak: 0.12, type: 'square', at: i * 0.07 })); |
|
}, |
|
over() { |
|
this.tone({ f0: 220, f1: 55, dur: 1.1, peak: 0.4, type: 'sawtooth' }); |
|
this.tone({ f0: 110, f1: 40, dur: 1.3, peak: 0.3, type: 'sine', at: 0.1 }); |
|
this.noise({ f0: 300, f1: 60, dur: 0.9, peak: 0.2, at: 0.05 }); |
|
}, |
|
start() { |
|
this.tone({ f0: 523.25, dur: 0.09, peak: 0.18, type: 'triangle' }); |
|
this.tone({ f0: 784, dur: 0.12, peak: 0.18, type: 'triangle', at: 0.08 }); |
|
this.tone({ f0: 1046.5, dur: 0.2, peak: 0.12, type: 'sine', at: 0.16 }); |
|
} |
|
}; |
|
|
|
/* ============================ HUD ============================ */ |
|
const $ = id => document.getElementById(id); |
|
const elScore = $('score'), elLives = $('lives'), elLevel = $('levelChip'); |
|
const elCombo = $('comboWrap'), elComboX = $('comboX'), elComboBar = $('comboBar'); |
|
const elFlash = $('flash'), elMsg = $('msg'), elHint = $('hint'); |
|
const scr = $('screen'), scrTitle = $('scrTitle'), scrSub = $('scrSub'); |
|
const scrStats = $('scrStats'), scrPrompt = $('scrPrompt'); |
|
|
|
let best = 0; |
|
try { best = +(localStorage.getItem('neon_arena_best') || 0); } catch (e) {} |
|
|
|
function makeHeart() { |
|
const ns = 'http://www.w3.org/2000/svg'; |
|
const s = document.createElementNS(ns, 'svg'); |
|
s.setAttribute('viewBox', '0 0 24 24'); |
|
const p = document.createElementNS(ns, 'path'); |
|
p.setAttribute('d', 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'); |
|
s.appendChild(p); |
|
return s; |
|
} |
|
function buildLives(n) { |
|
elLives.innerHTML = ''; |
|
for (let i = 0; i < START_LIVES; i++) { |
|
const h = makeHeart(); |
|
if (i >= n) h.classList.add('lost'); |
|
elLives.appendChild(h); |
|
} |
|
} |
|
function loseLifeUI() { |
|
const kids = [...elLives.children]; |
|
for (let i = kids.length - 1; i >= 0; i--) { |
|
if (!kids[i].classList.contains('lost')) { kids[i].classList.add('lost'); return; } |
|
} |
|
} |
|
function setScore(v) { |
|
elScore.textContent = v; |
|
elScore.classList.remove('pop'); |
|
void elScore.offsetWidth; |
|
elScore.classList.add('pop'); |
|
} |
|
function showMsg(text, color) { |
|
elMsg.textContent = text; |
|
elMsg.style.color = color; |
|
elMsg.style.textShadow = '0 0 24px ' + color + ', 0 0 60px ' + color; |
|
elMsg.classList.remove('show'); |
|
void elMsg.offsetWidth; |
|
elMsg.classList.add('show'); |
|
} |
|
function flash(color, opacity) { |
|
elFlash.style.background = 'radial-gradient(ellipse at center, transparent 40%, ' + color + ' 100%)'; |
|
elFlash.style.transition = 'none'; |
|
elFlash.style.opacity = opacity; |
|
requestAnimationFrame(() => { |
|
elFlash.style.transition = 'opacity 0.5s ease-out'; |
|
elFlash.style.opacity = 0; |
|
}); |
|
} |
|
function fmtTime(t) { |
|
const m = Math.floor(t / 60), s = Math.floor(t % 60); |
|
return m + ':' + String(s).padStart(2, '0'); |
|
} |
|
function showScreen(mode) { |
|
scr.classList.add('show'); |
|
if (mode === 'menu') { |
|
scrTitle.textContent = 'NEON ARENA'; |
|
scrTitle.style.color = '#7ff4ff'; |
|
scrTitle.style.textShadow = '0 0 30px rgba(0,229,255,.8), 0 0 90px rgba(0,229,255,.35)'; |
|
scrSub.textContent = 'collect the light \u00b7 survive the swarm'; |
|
scrStats.innerHTML = best > 0 ? '<div class="stat"><span>BEST</span><b>' + best + '</b></div>' : ''; |
|
scrPrompt.textContent = 'PRESS ANY KEY TO START'; |
|
scrPrompt.classList.remove('danger'); |
|
} else { |
|
scrTitle.textContent = 'GAME OVER'; |
|
scrTitle.style.color = '#ff5c8a'; |
|
scrTitle.style.textShadow = '0 0 30px rgba(255,45,120,.8), 0 0 90px rgba(255,45,120,.35)'; |
|
scrSub.textContent = 'the swarm got you'; |
|
scrStats.innerHTML = |
|
'<div class="stat"><span>FINAL SCORE</span><b>' + score + '</b></div>' + |
|
'<div class="stat"><span>BEST</span><b>' + best + (score >= best && score > 0 ? ' \u2605' : '') + '</b></div>' + |
|
'<div class="stat"><span>LEVEL</span><b>' + level + '</b></div>' + |
|
'<div class="stat"><span>TIME</span><b>' + fmtTime(elapsed) + '</b></div>'; |
|
scrPrompt.textContent = 'PRESS ANY KEY TO RESTART'; |
|
scrPrompt.classList.add('danger'); |
|
} |
|
} |
|
function hideScreen() { scr.classList.remove('show'); } |
|
|
|
/* ============================ game state ============================ */ |
|
let state = 'menu'; // menu | playing | dying | over |
|
let score = 0, lives = START_LIVES, combo = 0, comboTimer = 0; |
|
let elapsed = 0, level = 1, spawnTimer = 1.6; |
|
let timeScale = 1, hitstopT = 0, hitstopS = 0.2, camShake = 0; |
|
let gameTime = 0, dieTimer = 0, hintTimer = 0; |
|
|
|
function hitstop(t, s) { hitstopT = Math.max(hitstopT, t); hitstopS = s; } |
|
|
|
function resetGame() { |
|
score = 0; lives = START_LIVES; combo = 0; comboTimer = 0; |
|
elapsed = 0; level = 1; spawnTimer = 1.6; |
|
pv.set(0, 0, 0); |
|
player.position.set(0, 0.55, 0); |
|
player.visible = true; |
|
playerYaw = 0; player.rotation.y = 0; |
|
playerBody.rotation.set(0, 0, 0); |
|
playerBody.scale.setScalar(1); |
|
invuln = 0; shieldTime = 0; playerPop = 0; bank = 0; |
|
timeScale = 1; hitstopT = 0; camShake = 0; |
|
shield.visible = false; |
|
enemies.forEach(e => { e.active = false; e.g.visible = false; }); |
|
clearParticles(); |
|
popups.forEach(p => { p.sp.visible = false; }); |
|
waves.forEach(w => { w.m.visible = false; }); |
|
spawnOrb(); |
|
elScore.textContent = '0'; |
|
buildLives(lives); |
|
elLevel.textContent = 'LVL 1'; |
|
elCombo.style.opacity = 0; |
|
playerMat.emissiveIntensity = 1.1; |
|
} |
|
|
|
function startGame() { |
|
resetGame(); |
|
state = 'playing'; |
|
hideScreen(); |
|
Sfx.start(); |
|
hintTimer = 6; |
|
} |
|
|
|
function damagePlayer(fromPos) { |
|
if (invuln > 0 || state !== 'playing') return; |
|
lives--; |
|
loseLifeUI(); |
|
combo = 0; comboTimer = 0; |
|
elCombo.style.opacity = 0; |
|
invuln = 1.6; |
|
const k = new THREE.Vector3().subVectors(player.position, fromPos).setY(0).normalize().multiplyScalar(15); |
|
pv.add(k); |
|
camShake = Math.max(camShake, 0.55); |
|
hitstop(0.28, 0.12); |
|
flash('rgba(255,45,90,0.55)', 0.55); |
|
Sfx.hurt(); |
|
burst(player.position, 0xff3355, 26, { spMin: 3, spMax: 9 }); |
|
shockwave(player.position, 0xff3355, 6); |
|
if (lives <= 0) die(); |
|
} |
|
|
|
function die() { |
|
state = 'dying'; |
|
dieTimer = 0; |
|
Sfx.over(); |
|
camShake = 1.1; |
|
hitstop(1.0, 0.18); |
|
burst(player.position, 0x00e5ff, 60, { spMin: 2, spMax: 11, lifeMin: 0.6, lifeMax: 1.4 }); |
|
burst(player.position, 0xffffff, 20, { spMin: 1, spMax: 5 }); |
|
shockwave(player.position, 0x00e5ff, 12); |
|
player.visible = false; |
|
if (score > best) { |
|
best = score; |
|
try { localStorage.setItem('neon_arena_best', String(best)); } catch (e) {} |
|
} |
|
} |
|
|
|
function pickupOrb() { |
|
const base = orbGolden ? 50 : 10; |
|
combo++; |
|
comboTimer = 3.5; |
|
const mult = clamp(combo, 1, 5); |
|
const pts = base * mult; |
|
score += pts; |
|
setScore(score); |
|
const pos = orb.position.clone(); |
|
const col = orbGolden ? 0xffc84d : 0x00e5ff; |
|
popup(pos, orbGolden ? '+' + pts + ' SHIELD' : (mult > 1 ? '+' + pts + ' x' + mult : '+' + pts), col); |
|
burst(pos, col, orbGolden ? 34 : 22, { spMin: 2, spMax: 8 }); |
|
shockwave(pos, col, orbGolden ? 10 : 6); |
|
playerPop = 1; |
|
camShake = Math.max(camShake, 0.08); |
|
hitstop(0.1, 0.3); |
|
if (orbGolden) { |
|
shieldTime = 3.5; |
|
showMsg('SHIELD', '#ffe6a8'); |
|
flash('rgba(255,200,77,0.35)', 0.3); |
|
Sfx.golden(); |
|
} else { |
|
Sfx.pickup(combo); |
|
} |
|
if (combo >= 2) { |
|
elCombo.style.opacity = 1; |
|
elComboX.textContent = 'x' + mult; |
|
} |
|
spawnOrb(); |
|
} |
|
|
|
function updateLevel() { |
|
const nl = 1 + Math.floor(elapsed / 18); |
|
if (nl > level) { |
|
level = nl; |
|
elLevel.textContent = 'LVL ' + level; |
|
elLevel.classList.remove('bump'); |
|
void elLevel.offsetWidth; |
|
elLevel.classList.add('bump'); |
|
showMsg('LEVEL ' + level, '#c886ff'); |
|
Sfx.level(); |
|
shockwave(player.position, 0x9a5cff, 14); |
|
} |
|
} |
|
|
|
/* ============================ updates ============================ */ |
|
function updateWalls() { |
|
const pp = player.position; |
|
const d = [pp.z + ARENA, ARENA - pp.z, pp.x + ARENA, ARENA - pp.x]; |
|
walls.forEach((w, i) => { |
|
w.material.opacity = 0.06 + 0.03 * Math.sin(gameTime * 2 + i * 1.7) + smooth(7, 1.5, d[i]) * 0.5; |
|
}); |
|
edgeMat.opacity = 0.4 + 0.15 * Math.sin(gameTime * 3); |
|
pillarMat.opacity = 0.35 + 0.2 * Math.sin(gameTime * 2.4); |
|
} |
|
|
|
function updateMenu(dt) { |
|
const t = gameTime; |
|
camera.position.x = damp(camera.position.x, Math.sin(t * 0.12) * 13, 2.5, dt); |
|
camera.position.y = damp(camera.position.y, 5.5 + Math.sin(t * 0.23) * 0.8, 2.5, dt); |
|
camera.position.z = damp(camera.position.z, Math.cos(t * 0.12) * 13, 2.5, dt); |
|
camera.fov = damp(camera.fov, 62, 2, dt); |
|
camera.updateProjectionMatrix(); |
|
camera.lookAt(0, 1, 0); |
|
player.position.y = 0.55 + Math.sin(t * 2) * 0.06; |
|
playerBody.position.y = Math.sin(t * 3) * 0.05; |
|
ring1.rotation.x += dt * 1.2; |
|
ring2.rotation.z += dt * 0.9; |
|
playerGlow.material.opacity = 0.45 + 0.15 * Math.sin(t * 5); |
|
animateOrb(dt); |
|
} |
|
|
|
function updatePlaying(dt) { |
|
elapsed += dt; |
|
updateLevel(); |
|
const pp = player.position; |
|
|
|
/* ---- camera-relative movement with momentum ---- */ |
|
const fwd = new THREE.Vector3().subVectors(pp, camera.position); |
|
fwd.y = 0; |
|
if (fwd.lengthSq() < 1e-6) fwd.set(0, 0, -1); |
|
fwd.normalize(); |
|
const right = new THREE.Vector3().crossVectors(fwd, UP).normalize(); |
|
const ix = ((keys.KeyD || keys.ArrowRight) ? 1 : 0) - ((keys.KeyA || keys.ArrowLeft) ? 1 : 0); |
|
const iz = ((keys.KeyW || keys.ArrowUp) ? 1 : 0) - ((keys.KeyS || keys.ArrowDown) ? 1 : 0); |
|
const mv = new THREE.Vector3().addScaledVector(fwd, iz).addScaledVector(right, ix); |
|
if (mv.lengthSq() > 0) mv.normalize(); |
|
pv.addScaledVector(mv, PLAYER_ACC * dt); |
|
pv.multiplyScalar(Math.exp(-PLAYER_DRAG * dt)); |
|
const maxSpd = Math.min(15, PLAYER_MAX_SPD + level * 0.15); |
|
const spd = pv.length(); |
|
if (spd > maxSpd) pv.multiplyScalar(maxSpd / spd); |
|
pp.addScaledVector(pv, dt); |
|
|
|
/* ---- arena bounds ---- */ |
|
const B = ARENA - 0.9; |
|
if (Math.abs(pp.x) > B) { |
|
pp.x = clamp(pp.x, -B, B); |
|
if (Math.abs(pv.x) > 5) { |
|
burst(pp, 0x00e5ff, 6, { spMin: 1, spMax: 3, size: 0.5 }); |
|
Sfx.noise({ f0: 1200, f1: 400, dur: 0.06, peak: 0.05 }); |
|
} |
|
pv.x *= -0.3; |
|
} |
|
if (Math.abs(pp.z) > B) { |
|
pp.z = clamp(pp.z, -B, B); |
|
if (Math.abs(pv.z) > 5) { |
|
burst(pp, 0x00e5ff, 6, { spMin: 1, spMax: 3, size: 0.5 }); |
|
Sfx.noise({ f0: 1200, f1: 400, dur: 0.06, peak: 0.05 }); |
|
} |
|
pv.z *= -0.3; |
|
} |
|
pp.y = 0.55 + Math.sin(gameTime * 7.3) * 0.02; |
|
|
|
/* ---- player visuals ---- */ |
|
if (spd > 0.4) playerYaw = dampAngle(playerYaw, Math.atan2(pv.x, pv.z), 10, dt); |
|
player.rotation.y = playerYaw; |
|
const lat = right.dot(pv); |
|
playerBody.rotation.z = damp(playerBody.rotation.z, clamp(-lat * 0.03, -0.35, 0.35), 8, dt); |
|
playerBody.rotation.x = damp(playerBody.rotation.x, -(spd / maxSpd) * 0.15, 6, dt); |
|
playerBody.position.y = Math.sin(gameTime * (6 + spd * 0.6)) * 0.04 * (0.4 + spd / maxSpd); |
|
ring1.rotation.x += dt * 2.2; |
|
ring1.rotation.y += dt * 1.1; |
|
ring2.rotation.z += dt * 1.6; |
|
playerGlow.material.opacity = 0.45 + 0.15 * Math.sin(gameTime * 5) + (spd / maxSpd) * 0.25; |
|
playerLight.intensity = 1.4 + (spd / maxSpd) * 1.2; |
|
playerPop = Math.max(0, playerPop - dt * 2.6); |
|
playerBody.scale.setScalar(1 + 0.3 * playerPop * playerPop); |
|
if (invuln > 0) { |
|
invuln -= dt; |
|
playerMat.emissiveIntensity = 1.1 + 2.2 * Math.abs(Math.sin(gameTime * 18)); |
|
} else { |
|
playerMat.emissiveIntensity = 1.1; |
|
} |
|
if (shieldTime > 0) { |
|
shieldTime -= dt; |
|
shield.visible = true; |
|
shield.material.uniforms.uOp.value = clamp(shieldTime / 0.4, 0, 1); |
|
shield.material.uniforms.uTime.value = gameTime; |
|
shield.scale.setScalar(1 + 0.04 * Math.sin(gameTime * 9)); |
|
} else { |
|
shield.visible = false; |
|
} |
|
|
|
/* ---- speed trail ---- */ |
|
if (spd > 7 && Math.random() < dt * 30) { |
|
burst(new THREE.Vector3(pp.x + rand(-0.2, 0.2), 0.5, pp.z + rand(-0.2, 0.2)), 0x00c8ff, 1, |
|
{ spMin: 0.2, spMax: 0.8, size: 0.6, lifeMin: 0.2, lifeMax: 0.45, g: 0 }); |
|
} |
|
|
|
/* ---- combo timer ---- */ |
|
if (comboTimer > 0) { |
|
comboTimer -= dt; |
|
elComboBar.style.width = (comboTimer / 3.5 * 100) + '%'; |
|
if (comboTimer <= 0) { |
|
combo = 0; |
|
elCombo.style.opacity = 0; |
|
} |
|
} |
|
|
|
/* ---- orb ---- */ |
|
animateOrb(dt); |
|
{ |
|
const op = orb.position; |
|
const d = new THREE.Vector3().subVectors(pp, op); |
|
d.y = 0; |
|
const dist = d.length(); |
|
if (dist < 5 && dist > 0.01) { |
|
d.normalize(); |
|
op.addScaledVector(d, dt * 9); // magnet pull |
|
} |
|
if (Math.random() < dt * 6) { |
|
burst(op, orbGolden ? 0xffc84d : 0x66f0ff, 1, { spMin: 0.3, spMax: 1.2, size: 0.5, lifeMin: 0.3, lifeMax: 0.6, g: 1 }); |
|
} |
|
const st = (gameTime - orbSpawnT) / 0.35; |
|
if (dist < 1.35 && st > 0.4) pickupOrb(); |
|
} |
|
|
|
/* ---- enemy spawning (difficulty ramp) ---- */ |
|
spawnTimer -= dt; |
|
const interval = Math.max(0.5, 2.3 - 0.18 * (level - 1)); |
|
const cap = Math.min(4 + level, 12); |
|
if (spawnTimer <= 0) { |
|
spawnTimer = interval * rand(0.8, 1.2); |
|
const activeCount = enemies.reduce((n, e) => n + (e.active ? 1 : 0), 0); |
|
if (activeCount < cap) spawnEnemy(); |
|
} |
|
|
|
/* ---- enemies ---- */ |
|
for (const e of enemies) { |
|
if (!e.active) continue; |
|
const ep = e.g.position; |
|
const to = new THREE.Vector3().subVectors(pp, ep); |
|
to.y = 0; |
|
const dist = to.length(); |
|
if (dist > 0.01) to.normalize(); |
|
const wob = Math.sin(gameTime * 2.2 + e.phase) * 0.35; |
|
const wPerp = new THREE.Vector3(-to.z, 0, to.x).multiplyScalar(wob); |
|
const dir = new THREE.Vector3().addVectors(to, wPerp).normalize(); |
|
e.vel.addScaledVector(dir, 26 * dt); |
|
const s = e.vel.length(); |
|
if (s > e.speed) e.vel.multiplyScalar(e.speed / s); |
|
ep.addScaledVector(e.vel, dt); |
|
ep.x = clamp(ep.x, -ARENA + 0.3, ARENA - 0.3); |
|
ep.z = clamp(ep.z, -ARENA + 0.3, ARENA - 0.3); |
|
ep.y = 0.75 + Math.sin(gameTime * 5 + e.phase) * 0.06; |
|
if (s > 0.3) e.g.rotation.y = dampAngle(e.g.rotation.y, Math.atan2(e.vel.x, e.vel.z), 8, dt); |
|
const est = (gameTime - e.spawnT) / 0.3; |
|
e.g.scale.setScalar(est < 1 |
|
? Math.max(0.01, easeOutBack(clamp(est, 0, 1))) |
|
: 1 + 0.05 * Math.sin(gameTime * 6 + e.phase)); |
|
e.ring.rotation.z += dt * 3; |
|
e.core.scale.setScalar(1 + 0.25 * Math.sin(gameTime * 7 + e.phase)); |
|
if (invuln <= 0) { |
|
const cdist = new THREE.Vector3().subVectors(pp, ep).length(); |
|
if (cdist < 1.15) { |
|
const pos = ep.clone(); |
|
e.active = false; |
|
e.g.visible = false; |
|
burst(pos, 0xff3355, 24, { spMin: 2, spMax: 8 }); |
|
shockwave(pos, 0xff5577, 7); |
|
Sfx.boom(); |
|
damagePlayer(pos); |
|
} |
|
} |
|
} |
|
|
|
/* ---- enemy separation ---- */ |
|
const act = enemies.filter(e => e.active); |
|
for (let i = 0; i < act.length; i++) { |
|
for (let j = i + 1; j < act.length; j++) { |
|
const a = act[i].g.position, b = act[j].g.position; |
|
const dx = b.x - a.x, dz = b.z - a.z; |
|
const d2 = dx * dx + dz * dz; |
|
if (d2 < 2.25 && d2 > 0.0001) { |
|
const d = Math.sqrt(d2), push = (1.5 - d) * 0.5; |
|
const nx = dx / d, nz = dz / d; |
|
a.x -= nx * push; a.z -= nz * push; |
|
b.x += nx * push; b.z += nz * push; |
|
} |
|
} |
|
} |
|
|
|
/* ---- camera follow + juice ---- */ |
|
camera.position.x = damp(camera.position.x, pp.x, 4.5, dt); |
|
camera.position.y = damp(camera.position.y, 5.6, 4.5, dt); |
|
camera.position.z = damp(camera.position.z, pp.z + 9.6, 4.5, dt); |
|
const look = new THREE.Vector3( |
|
pp.x + clamp(pv.x * 0.3, -2.5, 2.5), |
|
0.8 + pp.y * 0.4, |
|
pp.z + clamp(pv.z * 0.3, -2.5, 2.5) |
|
); |
|
camera.lookAt(look); |
|
bank = damp(bank, clamp(-lat * 0.014, -0.055, 0.055), 5, dt); |
|
camera.rotateZ(bank); |
|
if (camShake > 0) { |
|
camShake = Math.max(0, camShake - dt * 2.2); |
|
camera.position.x += rand(-1, 1) * camShake * 0.35; |
|
camera.position.y += rand(-1, 1) * camShake * 0.3; |
|
camera.position.z += rand(-1, 1) * camShake * 0.35; |
|
} |
|
const f = camera.fov; |
|
camera.fov = damp(camera.fov, 58 + (spd / maxSpd) * 9, 4, dt); |
|
if (Math.abs(camera.fov - f) > 0.001) camera.updateProjectionMatrix(); |
|
} |
|
|
|
function updateDying(dt, rawDt) { |
|
dieTimer += rawDt; |
|
const a = gameTime * 0.5; |
|
camera.position.x = damp(camera.position.x, player.position.x + Math.sin(a) * 10, 2, dt); |
|
camera.position.y = damp(camera.position.y, 5, 2, dt); |
|
camera.position.z = damp(camera.position.z, player.position.z + Math.cos(a) * 10, 2, dt); |
|
camera.lookAt(player.position.x, 1, player.position.z); |
|
if (dieTimer > 1.5) { |
|
state = 'over'; |
|
showScreen('over'); |
|
} |
|
} |
|
|
|
function updateOver(dt) { |
|
const a = gameTime * 0.15; |
|
camera.position.x = damp(camera.position.x, player.position.x + Math.sin(a) * 11, 1.5, dt); |
|
camera.position.y = damp(camera.position.y, 5.5, 1.5, dt); |
|
camera.position.z = damp(camera.position.z, player.position.z + Math.cos(a) * 11, 1.5, dt); |
|
camera.lookAt(player.position.x, 1, player.position.z); |
|
} |
|
|
|
/* ============================ input ============================ */ |
|
const keys = {}; |
|
const PREVENT = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']; |
|
window.addEventListener('keydown', e => { |
|
if (PREVENT.includes(e.code)) e.preventDefault(); |
|
Sfx.init(); |
|
Sfx.resume(); |
|
if (e.repeat) return; |
|
if (state === 'menu' || state === 'over') startGame(); |
|
keys[e.code] = true; |
|
}); |
|
window.addEventListener('keyup', e => { keys[e.code] = false; }); |
|
window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; }); |
|
window.addEventListener('resize', () => { |
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
}); |
|
|
|
/* ============================ main loop ============================ */ |
|
const clock = new THREE.Clock(); |
|
function loop() { |
|
requestAnimationFrame(loop); |
|
const rawDt = Math.min(clock.getDelta(), 0.05); |
|
if (hitstopT > 0) { |
|
hitstopT -= rawDt; |
|
timeScale = hitstopS; |
|
} else { |
|
timeScale = damp(timeScale, 1, 10, rawDt); |
|
} |
|
const dt = rawDt * timeScale; |
|
gameTime += dt; |
|
|
|
if (state === 'menu') updateMenu(dt); |
|
else if (state === 'playing') updatePlaying(dt); |
|
else if (state === 'dying') updateDying(dt, rawDt); |
|
else updateOver(dt); |
|
|
|
updateParticles(dt); |
|
updateWaves(dt); |
|
updatePopups(dt); |
|
updateDust(dt); |
|
updateWalls(); |
|
|
|
starMat.opacity = 0.75 + 0.2 * Math.sin(gameTime * 0.8); |
|
|
|
const ho = state === 'playing' && hintTimer > 0 |
|
? clamp(Math.min(1, (6 - hintTimer) * 2) * clamp(hintTimer * 0.8, 0, 1), 0, 1) |
|
: 0; |
|
if (state === 'playing' && hintTimer > 0) hintTimer -= rawDt; |
|
elHint.style.opacity = ho; |
|
|
|
renderer.render(scene, camera); |
|
} |
|
|
|
/* ============================ boot ============================ */ |
|
resetGame(); |
|
state = 'menu'; |
|
showScreen('menu'); |
|
loop(); |
|
|
|
})(); |
|
</script> |
|
</body> |
|
</html> |