
https://officialstupid.com/parkour/fps.html
testing base on – https://threejs.org/examples/
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js - misc - octree collisions (bot waves)</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/three@0.180.0/examples/main.css">
<style>
body { margin: 0; overflow: hidden; background: #88ccee; }
#info {
position: absolute; top: 0; width: 100%; padding: 10px;
box-sizing: border-box; text-align: center;
font-family: monospace; color: #fff;
text-shadow: 0 0 4px #000; pointer-events: none; z-index: 1;
}
#hud {
position: absolute; bottom: 16px; left: 16px;
font-family: monospace; color: #fff;
text-shadow: 0 0 4px #000; pointer-events: none; z-index: 2;
}
#healthBar {
width: 240px; height: 18px;
border: 2px solid rgba(255,255,255,0.85);
border-radius: 10px; overflow: hidden;
background: rgba(0,0,0,0.35);
box-shadow: 0 0 8px rgba(0,0,0,0.4);
}
#healthFill {
height: 100%; width: 100%;
background: linear-gradient(90deg, #28d17c, #7ef0b0);
transition: width 0.12s linear, background 0.2s linear;
}
#hudText {
margin-top: 6px; font-size: 14px; letter-spacing: 0.5px;
}
#damageFlash {
position: absolute; inset: 0; z-index: 3; pointer-events: none;
background: radial-gradient(circle at center, rgba(255,0,0,0) 35%, rgba(255,0,0,0.75) 100%);
opacity: 0;
}
#waveMsg {
position: absolute; top: 28%; left: 0; width: 100%;
text-align: center; font-family: monospace;
color: #fff; text-shadow: 0 0 12px #000, 0 0 4px #000;
font-size: 42px; letter-spacing: 3px; font-weight: bold;
opacity: 0; transition: opacity 0.4s ease;
pointer-events: none; z-index: 4;
}
#gameOver {
position: absolute; inset: 0; z-index: 5;
display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.65);
font-family: monospace; color: #fff; text-align: center;
}
#gameOver h1 {
font-size: 52px; margin: 0 0 12px 0; letter-spacing: 4px;
color: #ff5a4a; text-shadow: 0 0 18px rgba(255,80,60,0.8);
}
#gameOver p {
font-size: 22px; margin: 6px 0; letter-spacing: 2px;
}
#gameOver .restart {
margin-top: 22px; font-size: 16px; opacity: 0.8;
}
#botTracker {
position: absolute; top: 90px; right: 16px;
font-family: monospace; color: #fff;
text-shadow: 0 0 4px #000; font-size: 13px;
text-align: right; pointer-events: none; z-index: 2;
line-height: 1.5em;
}
#botTracker .arrow {
display: inline-block; color: #ff5a4a; font-weight: bold;
}
#botTracker .near {
color: #ffd24a; font-weight: bold;
}
</style>
</head>
<body>
<div id="info">
Octree threejs demo - basic collisions with static triangle mesh<br />
MOUSE to look around and to throw balls | WASD to move | SPACE to jump<br/>
<span style="color:#ff9c9c">Red bots track you down — hit them for 25% HP, they hit you for 10% HP<br/>
Bots learn your jumps after 20 seconds!</span>
</div>
<div id="hud">
<div id="healthBar"><div id="healthFill"></div></div>
<div id="hudText">Wave 0 | Bots: 0 | Kills: 0 | Health: 100%</div>
</div>
<div id="botTracker"></div>
<div id="damageFlash"></div>
<div id="waveMsg"></div>
<div id="gameOver">
<div>
<h1>GAME OVER</h1>
<p id="finalKills">Total Kills: 0</p>
<p class="restart">Press R to restart</p>
</div>
</div>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import Stats from 'three/addons/libs/stats.module.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Octree } from 'three/addons/math/Octree.js';
import { OctreeHelper } from 'three/addons/helpers/OctreeHelper.js';
import { Capsule } from 'three/addons/math/Capsule.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const MODEL_URL = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r180/examples/models/gltf/collision-world.glb';
const timer = new THREE.Timer();
timer.connect( document );
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x88ccee );
scene.fog = new THREE.Fog( 0x88ccee, 0, 50 );
const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.rotation.order = 'YXZ';
const fillLight1 = new THREE.HemisphereLight( 0x8dc1de, 0x00668d, 1.5 );
fillLight1.position.set( 2, 1, 1 );
scene.add( fillLight1 );
const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
directionalLight.position.set( - 5, 25, - 1 );
directionalLight.castShadow = true;
directionalLight.shadow.camera.near = 0.01;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.left = - 30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = - 30;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.radius = 4;
directionalLight.shadow.bias = - 0.00006;
scene.add( directionalLight );
const container = document.getElementById( 'container' );
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild( renderer.domElement );
const stats = new Stats();
stats.domElement.style.position = 'absolute';
stats.domElement.style.top = '0px';
container.appendChild( stats.domElement );
const GRAVITY = 30;
const NUM_SPHERES = 40;
const SPHERE_RADIUS = 0.2;
const STEPS_PER_FRAME = 5;
const PLAYER_BALL_LIFE = 1.5;
const ENEMY_BALL_LIFE = 1.5;
const sphereGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 5 );
const sphereMaterial = new THREE.MeshLambertMaterial( { color: 0xdede8d } );
const spheres = [];
let sphereIdx = 0;
for ( let i = 0; i < NUM_SPHERES; i ++ ) {
const sphere = new THREE.Mesh( sphereGeometry, sphereMaterial );
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add( sphere );
spheres.push( {
mesh: sphere,
collider: new THREE.Sphere( new THREE.Vector3( 0, - 100, 0 ), SPHERE_RADIUS ),
velocity: new THREE.Vector3(),
life: 0
} );
}
function recyclePlayerBall( sphere ) {
sphere.collider.center.set( 0, - 1000, 0 );
sphere.velocity.set( 0, 0, 0 );
sphere.mesh.position.copy( sphere.collider.center );
sphere.life = 0;
}
const worldOctree = new Octree();
const playerCollider = new Capsule( new THREE.Vector3( 0, 0.35, 0 ), new THREE.Vector3( 0, 1, 0 ), 0.35 );
const playerVelocity = new THREE.Vector3();
const playerDirection = new THREE.Vector3();
let playerOnFloor = false;
let mouseTime = 0;
const keyStates = {};
const vector1 = new THREE.Vector3();
const vector2 = new THREE.Vector3();
const vector3 = new THREE.Vector3();
/* ---------------- GAME STATE ---------------- */
const PLAYER_MAX_HEALTH = 100;
const PLAYER_HIT_DAMAGE = 10;
const BOT_HIT_DAMAGE = 25;
const BOT_MAX_HEALTH = 100;
const BOT_RADIUS = 0.35;
const BOT_HEIGHT = 0.65;
// Bots spawn a bit away so it's not instant chaos, but still close.
const BOT_SPAWN_MIN_DIST = 7;
const BOT_SPAWN_MAX_DIST = 11;
const JUMP_LEARN_DELAY = 20;
const PLAYER_JUMP_MEMORY = 0.9;
let playerHealth = PLAYER_MAX_HEALTH;
let playerInvuln = 0;
let totalKills = 0;
let wave = 0;
let gameOver = false;
let matchTime = 0;
let lastPlayerJumpTime = - 999;
const bots = [];
const healthFill = document.getElementById( 'healthFill' );
const hudText = document.getElementById( 'hudText' );
const damageFlashEl = document.getElementById( 'damageFlash' );
const waveMsgEl = document.getElementById( 'waveMsg' );
const gameOverEl = document.getElementById( 'gameOver' );
const finalKillsEl = document.getElementById( 'finalKills' );
const botTrackerEl = document.getElementById( 'botTracker' );
let flashTimeout = null;
let waveMsgTimeout = null;
function updateHUD() {
const pct = Math.max( 0, playerHealth ) / PLAYER_MAX_HEALTH * 100;
healthFill.style.width = pct + '%';
healthFill.style.background = pct > 60
? 'linear-gradient(90deg,#28d17c,#7ef0b0)'
: pct > 30
? 'linear-gradient(90deg,#e8c341,#f7e07a)'
: 'linear-gradient(90deg,#d13a2e,#ff7b6b)';
hudText.textContent =
`Wave ${ wave } | Bots: ${ bots.length } | Kills: ${ totalKills } | Health: ${ Math.round( pct ) }%`;
}
function flashDamage() {
damageFlashEl.style.transition = 'none';
damageFlashEl.style.opacity = '0.9';
if ( flashTimeout ) clearTimeout( flashTimeout );
flashTimeout = setTimeout( () => {
damageFlashEl.style.transition = 'opacity 0.45s ease-out';
damageFlashEl.style.opacity = '0';
}, 40 );
}
function showWaveMessage( text ) {
waveMsgEl.textContent = text;
waveMsgEl.style.transition = 'none';
waveMsgEl.style.opacity = '1';
if ( waveMsgTimeout ) clearTimeout( waveMsgTimeout );
waveMsgTimeout = setTimeout( () => {
waveMsgEl.style.transition = 'opacity 0.8s ease';
waveMsgEl.style.opacity = '0';
}, 1200 );
}
/* ---------------- ENEMY BALLS ---------------- */
const ENEMY_BALL_COUNT = 40;
const enemyBallGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 3 );
const enemyBallMaterial = new THREE.MeshLambertMaterial( { color: 0xff2a1f, emissive: 0x330000 } );
const enemyBalls = [];
let enemyBallIdx = 0;
for ( let i = 0; i < ENEMY_BALL_COUNT; i ++ ) {
const mesh = new THREE.Mesh( enemyBallGeometry, enemyBallMaterial );
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.visible = false;
scene.add( mesh );
enemyBalls.push( {
mesh: mesh,
collider: new THREE.Sphere( new THREE.Vector3( 0, - 1000, 0 ), SPHERE_RADIUS ),
velocity: new THREE.Vector3(),
active: false,
life: 0
} );
}
function getFreeEnemyBall() {
for ( let i = 0; i < enemyBalls.length; i ++ ) {
const ball = enemyBalls[ ( enemyBallIdx + i ) % enemyBalls.length ];
if ( ! ball.active ) {
enemyBallIdx = ( enemyBallIdx + i + 1 ) % enemyBalls.length;
return ball;
}
}
return null;
}
function deactivateEnemyBall( ball ) {
ball.active = false;
ball.mesh.visible = false;
ball.velocity.set( 0, 0, 0 );
ball.collider.center.set( 0, - 1000, 0 );
ball.life = 0;
}
/* ---------------- BOT ---------------- */
const botGeometry = new THREE.CapsuleGeometry( BOT_RADIUS, BOT_HEIGHT, 4, 10 );
const eyeGeometry = new THREE.SphereGeometry( 0.07, 8, 6 );
const eyeMaterial = new THREE.MeshBasicMaterial( { color: 0x1a0000 } );
const ringGeometry = new THREE.RingGeometry( 0.55, 0.75, 24 );
const ringMaterial = new THREE.MeshBasicMaterial( {
color: 0xff3b30, side: THREE.DoubleSide, transparent: true, opacity: 0.85
} );
const _bc = new THREE.Vector3();
const _pc = new THREE.Vector3();
const _toP = new THREE.Vector3();
const _fwd = new THREE.Vector3();
const _rgt = new THREE.Vector3();
const _mv = new THREE.Vector3();
const _dl = new THREE.Vector3();
const _org = new THREE.Vector3();
const _aim = new THREE.Vector3();
const _hitBotCenter = new THREE.Vector3();
const _hitNormal = new THREE.Vector3();
const _sepA = new THREE.Vector3();
const _sepB = new THREE.Vector3();
const _sepPush = new THREE.Vector3();
class Bot {
constructor( position ) {
this.collider = new Capsule(
new THREE.Vector3( position.x, position.y, position.z ),
new THREE.Vector3( position.x, position.y + BOT_HEIGHT, position.z ),
BOT_RADIUS
);
this.velocity = new THREE.Vector3();
this.onFloor = false;
this.alive = true;
this.health = BOT_MAX_HEALTH;
this.hitCooldown = 0;
this.flash = 0;
this.strafeDir = Math.random() < 0.5 ? - 1 : 1;
this.strafeTimer = 0.5 + Math.random() * 1.5;
this.jumpTimer = 1.0 + Math.random() * 2.5;
this.throwTimer = 0.8 + Math.random() * 1.2;
// Balanced speed — they close distance but you can outrun them.
this.speed = 4.5 + Math.random() * 2;
// They like to hang around this range.
this.preferredRange = 4 + Math.random() * 4;
this.jumpMimicSkill = 0.4 + Math.random() * 0.55;
this.material = new THREE.MeshLambertMaterial( { color: 0xff3b30, emissive: 0x000000 } );
this.group = new THREE.Group();
const body = new THREE.Mesh( botGeometry, this.material );
body.castShadow = true;
body.receiveShadow = true;
this.group.add( body );
const eyeL = new THREE.Mesh( eyeGeometry, eyeMaterial );
eyeL.position.set( - 0.13, 0.45, 0.28 );
const eyeR = new THREE.Mesh( eyeGeometry, eyeMaterial );
eyeR.position.set( 0.13, 0.45, 0.28 );
this.group.add( eyeL, eyeR );
this.ring = new THREE.Mesh( ringGeometry, ringMaterial );
this.ring.rotation.x = - Math.PI / 2;
this.ring.position.y = - BOT_HEIGHT / 2 - BOT_RADIUS + 0.05;
this.group.add( this.ring );
this.syncMesh();
scene.add( this.group );
}
center( target ) {
return ( target || new THREE.Vector3() ).addVectors( this.collider.start, this.collider.end ).multiplyScalar( 0.5 );
}
syncMesh() {
this.group.position.addVectors( this.collider.start, this.collider.end ).multiplyScalar( 0.5 );
}
hit( amount ) {
if ( ! this.alive ) return;
this.health -= amount;
this.flash = 1;
if ( this.health <= 0 ) {
this.alive = false;
this.group.visible = false;
}
}
throwBallAt( targetCenter ) {
const ball = getFreeEnemyBall();
if ( ! ball ) return;
_org.addVectors( this.collider.start, this.collider.end ).multiplyScalar( 0.5 );
_org.y += 0.25;
_aim.copy( targetCenter );
const dir = _aim.sub( _org ).normalize();
// a bit more spread so shots miss more often
const spread = 0.12;
dir.x += ( Math.random() - 0.5 ) * spread;
dir.y += ( Math.random() - 0.5 ) * spread;
dir.z += ( Math.random() - 0.5 ) * spread;
dir.normalize();
const speed = 14 + Math.random() * 4;
ball.active = true;
ball.life = ENEMY_BALL_LIFE;
ball.collider.center.copy( _org ).addScaledVector( dir, 0.55 );
ball.velocity.copy( dir ).multiplyScalar( speed );
ball.mesh.visible = true;
ball.mesh.position.copy( ball.collider.center );
}
update( dt ) {
if ( ! this.alive ) return;
this.hitCooldown = Math.max( 0, this.hitCooldown - dt );
this.strafeTimer -= dt;
this.jumpTimer -= dt;
this.throwTimer -= dt;
if ( this.flash > 0 ) {
this.flash = Math.max( 0, this.flash - dt * 4 );
this.material.emissive.setScalar( this.flash * 0.9 );
}
this.center( _bc );
_pc.addVectors( playerCollider.start, playerCollider.end ).multiplyScalar( 0.5 );
_toP.subVectors( _pc, _bc );
const distXZ = Math.hypot( _toP.x, _toP.z );
_fwd.set( _toP.x, 0, _toP.z );
if ( _fwd.lengthSq() < 1e-6 ) _fwd.set( 0, 0, 1 ); else _fwd.normalize();
_rgt.set( - _fwd.z, 0, _fwd.x );
if ( this.strafeTimer <= 0 ) {
this.strafeTimer = 0.7 + Math.random() * 1.6;
this.strafeDir = Math.random() < 0.5 ? - 1 : 1;
}
// Balanced behavior:
// - far away → walk toward the player
// - too close → back off a little
// - in the sweet spot → strafe around them
const near = Math.max( 2, this.preferredRange * 0.6 );
const far = this.preferredRange;
_mv.set( 0, 0, 0 );
if ( distXZ > far + 2 ) {
_mv.copy( _fwd );
_mv.addScaledVector( _rgt, this.strafeDir * 0.4 );
} else if ( distXZ < near ) {
_mv.copy( _fwd ).multiplyScalar( - 0.7 );
_mv.addScaledVector( _rgt, this.strafeDir * 0.6 );
} else {
_mv.copy( _rgt ).multiplyScalar( this.strafeDir );
_mv.addScaledVector( _fwd, ( Math.random() - 0.5 ) * 0.35 );
}
if ( _mv.lengthSq() > 1e-6 ) _mv.normalize();
const targetSpeed = this.speed * ( this.onFloor ? 1 : 0.65 );
const accel = 1 - Math.exp( - 6 * dt );
this.velocity.x += ( _mv.x * targetSpeed - this.velocity.x ) * accel;
this.velocity.z += ( _mv.z * targetSpeed - this.velocity.z ) * accel;
if ( ! this.onFloor ) this.velocity.y -= GRAVITY * dt;
// ---- JUMP LOGIC ----
if ( this.onFloor && this.jumpTimer <= 0 ) {
let jumped = false;
if ( matchTime >= JUMP_LEARN_DELAY ) {
const sinceJump = matchTime - lastPlayerJumpTime;
if ( sinceJump < PLAYER_JUMP_MEMORY && Math.random() < this.jumpMimicSkill ) {
this.velocity.y = 11 + Math.random() * 3;
jumped = true;
}
}
if ( ! jumped && Math.random() < 0.18 ) {
this.velocity.y = 9 + Math.random() * 3;
jumped = true;
}
if ( jumped ) {
this.jumpTimer = 1.6 + Math.random() * 2.4;
} else {
this.jumpTimer = 0.8 + Math.random() * 1.2;
}
}
_dl.copy( this.velocity ).multiplyScalar( dt );
this.collider.translate( _dl );
const result = worldOctree.capsuleIntersect( this.collider );
this.onFloor = false;
if ( result ) {
this.onFloor = result.normal.y >= 0.15;
if ( ! this.onFloor ) {
this.velocity.addScaledVector( result.normal, - result.normal.dot( this.velocity ) );
}
if ( result.depth >= 1e-10 ) {
this.collider.translate( result.normal.multiplyScalar( result.depth ) );
}
}
const dx = _bc.x - _pc.x;
const dz = _bc.z - _pc.z;
const hd2 = dx * dx + dz * dz;
const minPlayerDist = 0.95;
if ( hd2 < minPlayerDist * minPlayerDist && hd2 > 1e-6 ) {
const hd = Math.sqrt( hd2 );
const push = ( minPlayerDist - hd );
this.collider.translate( _sepPush.set( dx / hd * push, 0, dz / hd * push ) );
}
this.syncMesh();
const hSpeedSq = this.velocity.x * this.velocity.x + this.velocity.z * this.velocity.z;
if ( hSpeedSq > 0.04 ) {
this.group.rotation.y = Math.atan2( this.velocity.x, this.velocity.z );
} else {
this.group.rotation.y = Math.atan2( _fwd.x, _fwd.z );
}
// Throw only when reasonably close and on a slower cadence.
if ( this.throwTimer <= 0 && distXZ < 22 ) {
this.throwBallAt( _pc );
this.throwTimer = 1.6 + Math.random() * 1.6;
}
}
}
function spawnBot() {
const angle = Math.random() * Math.PI * 2;
const dist = BOT_SPAWN_MIN_DIST + Math.random() * ( BOT_SPAWN_MAX_DIST - BOT_SPAWN_MIN_DIST );
const px = camera.position.x + Math.cos( angle ) * dist;
const pz = camera.position.z + Math.sin( angle ) * dist;
const py = Math.max( 2, camera.position.y + 1 + Math.random() * 2 );
bots.push( new Bot( new THREE.Vector3( px, py, pz ) ) );
}
function startWave( n ) {
wave = n;
matchTime = 0;
for ( const bot of bots ) {
scene.remove( bot.group );
bot.material.dispose();
}
bots.length = 0;
for ( const ball of enemyBalls ) {
if ( ball.active ) deactivateEnemyBall( ball );
}
for ( const ball of spheres ) recyclePlayerBall( ball );
for ( let i = 0; i < n; i ++ ) spawnBot();
playerHealth = PLAYER_MAX_HEALTH;
playerInvuln = 1.2;
showWaveMessage( `WAVE ${ n }` );
updateHUD();
}
function updateBots( dt ) {
for ( const bot of bots ) bot.update( dt );
separateBots();
}
function separateBots() {
for ( let i = 0; i < bots.length; i ++ ) {
const a = bots[ i ];
if ( ! a.alive ) continue;
a.center( _sepA );
for ( let j = i + 1; j < bots.length; j ++ ) {
const b = bots[ j ];
if ( ! b.alive ) continue;
b.center( _sepB );
const dx = _sepA.x - _sepB.x;
const dz = _sepA.z - _sepB.z;
const d2 = dx * dx + dz * dz;
const minD = BOT_RADIUS * 2.3;
if ( d2 < minD * minD && d2 > 1e-6 ) {
const d = Math.sqrt( d2 );
const push = ( minD - d ) * 0.5;
const nx = dx / d;
const nz = dz / d;
a.collider.translate( _sepPush.set( nx * push, 0, nz * push ) );
b.collider.translate( _sepPush.set( - nx * push, 0, - nz * push ) );
a.syncMesh();
b.syncMesh();
}
}
}
}
function removeDeadBots() {
if ( gameOver ) return;
let removed = 0;
for ( let i = bots.length - 1; i >= 0; i -- ) {
if ( ! bots[ i ].alive ) {
scene.remove( bots[ i ].group );
bots[ i ].material.dispose();
bots.splice( i, 1 );
removed ++;
}
}
if ( removed > 0 ) {
totalKills += removed;
updateHUD();
}
if ( bots.length === 0 && ! gameOver ) {
startWave( wave + 1 );
}
}
/* ---------------- BOT TRACKER HUD ---------------- */
const _trackVec = new THREE.Vector3();
const _camDir = new THREE.Vector3();
const _camRight = new THREE.Vector3();
function updateBotTracker() {
if ( bots.length === 0 ) {
botTrackerEl.innerHTML = '';
return;
}
camera.getWorldDirection( _camDir );
_camRight.set( _camDir.z, 0, - _camDir.x ).normalize();
let html = '';
for ( const bot of bots ) {
if ( ! bot.alive ) continue;
bot.center( _trackVec );
_trackVec.sub( camera.position );
const forward = _trackVec.dot( _camDir );
const right = _trackVec.dot( _camRight );
const dist = _trackVec.length();
let arrow = '●';
if ( forward < 0 ) arrow = '↓';
else if ( right > 2 ) arrow = '→';
else if ( right < - 2 ) arrow = '←';
else arrow = '↑';
const nearClass = dist < 8 ? 'near' : 'arrow';
html += `<span class="${ nearClass }">${ arrow }</span> ${ dist.toFixed( 0 ) }m<br/>`;
}
botTrackerEl.innerHTML = html;
}
/* ---------------- DAMAGE / GAME OVER ---------------- */
function damagePlayer( amount ) {
if ( playerInvuln > 0 || gameOver ) return;
playerHealth = Math.max( 0, playerHealth - amount );
updateHUD();
flashDamage();
if ( playerHealth <= 0 ) triggerGameOver();
}
function triggerGameOver() {
gameOver = true;
finalKillsEl.textContent = `Total Kills: ${ totalKills }`;
gameOverEl.style.display = 'flex';
for ( const bot of bots ) {
scene.remove( bot.group );
bot.material.dispose();
}
bots.length = 0;
for ( const ball of enemyBalls ) {
if ( ball.active ) deactivateEnemyBall( ball );
}
updateHUD();
}
function resetGame() {
gameOver = false;
gameOverEl.style.display = 'none';
totalKills = 0;
wave = 0;
matchTime = 0;
lastPlayerJumpTime = - 999;
playerHealth = PLAYER_MAX_HEALTH;
playerInvuln = 0;
playerVelocity.set( 0, 0, 0 );
playerCollider.start.set( 0, 0.35, 0 );
playerCollider.end.set( 0, 1, 0 );
camera.position.copy( playerCollider.end );
camera.rotation.set( 0, 0, 0 );
startWave( 1 );
}
/* ---------------- ENEMY BALL UPDATE ---------------- */
function updateEnemyBalls( deltaTime ) {
if ( gameOver ) return;
for ( const ball of enemyBalls ) {
if ( ! ball.active ) continue;
ball.life -= deltaTime;
if ( ball.life <= 0 ) {
deactivateEnemyBall( ball );
continue;
}
ball.collider.center.addScaledVector( ball.velocity, deltaTime );
const result = worldOctree.sphereIntersect( ball.collider );
if ( result ) {
ball.velocity.addScaledVector( result.normal, - result.normal.dot( ball.velocity ) * 1.6 );
ball.collider.center.add( result.normal.multiplyScalar( result.depth ) );
} else {
ball.velocity.y -= GRAVITY * deltaTime;
}
const damping = Math.exp( - 0.4 * deltaTime ) - 1;
ball.velocity.addScaledVector( ball.velocity, damping );
ball.mesh.position.copy( ball.collider.center );
if ( sphereCapsuleIntersect( ball.collider, playerCollider ) ) {
damagePlayer( PLAYER_HIT_DAMAGE );
playerVelocity.y = Math.max( playerVelocity.y, 3 );
deactivateEnemyBall( ball );
}
}
}
/* ---------------- SHARED HELPERS ---------------- */
const _seg = new THREE.Vector3();
const _pt = new THREE.Vector3();
function sphereCapsuleIntersect( sphere, capsule ) {
_seg.subVectors( capsule.end, capsule.start );
const lenSq = _seg.lengthSq();
_pt.subVectors( sphere.center, capsule.start );
const t = lenSq > 0 ? THREE.MathUtils.clamp( _pt.dot( _seg ) / lenSq, 0, 1 ) : 0;
_pt.copy( capsule.start ).addScaledVector( _seg, t );
const r = sphere.radius + capsule.radius;
return _pt.distanceToSquared( sphere.center ) < r * r;
}
function playerBallVsBots( sphere ) {
if ( bots.length === 0 ) return;
const sc = sphere.collider.center;
for ( const bot of bots ) {
if ( ! bot.alive || bot.hitCooldown > 0 ) continue;
bot.center( _hitBotCenter );
if ( sc.distanceToSquared( _hitBotCenter ) > 4 ) continue;
if ( sphereCapsuleIntersect( sphere.collider, bot.collider ) ) {
bot.hit( BOT_HIT_DAMAGE );
bot.hitCooldown = 0.4;
_hitNormal.subVectors( sc, _hitBotCenter );
if ( _hitNormal.lengthSq() < 1e-6 ) _hitNormal.set( 0, 1, 0 ); else _hitNormal.normalize();
sphere.velocity.addScaledVector( _hitNormal, - _hitNormal.dot( sphere.velocity ) * 1.2 + 3 );
sphere.collider.center.addScaledVector( _hitNormal, 0.25 );
}
}
}
/* ---------------- INPUT ---------------- */
document.addEventListener( 'keydown', ( event ) => {
keyStates[ event.code ] = true;
if ( event.code === 'KeyR' && gameOver ) resetGame();
} );
document.addEventListener( 'keyup', ( event ) => {
keyStates[ event.code ] = false;
} );
container.addEventListener( 'mousedown', () => {
if ( gameOver ) return;
document.body.requestPointerLock();
mouseTime = performance.now();
} );
document.addEventListener( 'mouseup', () => {
if ( document.pointerLockElement !== null && ! gameOver ) throwBall();
} );
document.body.addEventListener( 'mousemove', ( event ) => {
if ( document.pointerLockElement === document.body ) {
camera.rotation.y -= event.movementX / 500;
camera.rotation.x -= event.movementY / 500;
}
} );
window.addEventListener( 'resize', onWindowResize );
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}
function throwBall() {
const sphere = spheres[ sphereIdx ];
camera.getWorldDirection( playerDirection );
sphere.collider.center.copy( playerCollider.end ).addScaledVector( playerDirection, playerCollider.radius * 1.5 );
const impulse = 15 + 30 * ( 1 - Math.exp( ( mouseTime - performance.now() ) * 0.001 ) );
sphere.velocity.copy( playerDirection ).multiplyScalar( impulse );
sphere.velocity.addScaledVector( playerVelocity, 2 );
sphere.life = PLAYER_BALL_LIFE;
sphereIdx = ( sphereIdx + 1 ) % spheres.length;
}
function playerCollisions() {
const result = worldOctree.capsuleIntersect( playerCollider );
playerOnFloor = false;
if ( result ) {
playerOnFloor = result.normal.y >= 0.15;
if ( ! playerOnFloor ) {
playerVelocity.addScaledVector( result.normal, - result.normal.dot( playerVelocity ) );
}
if ( result.depth >= 1e-10 ) {
playerCollider.translate( result.normal.multiplyScalar( result.depth ) );
}
}
}
function updatePlayer( deltaTime ) {
let damping = Math.exp( - 4 * deltaTime ) - 1;
if ( ! playerOnFloor ) {
playerVelocity.y -= GRAVITY * deltaTime;
damping *= 0.1;
}
playerVelocity.addScaledVector( playerVelocity, damping );
const deltaPosition = playerVelocity.clone().multiplyScalar( deltaTime );
playerCollider.translate( deltaPosition );
playerCollisions();
camera.position.copy( playerCollider.end );
}
function playerSphereCollision( sphere ) {
const center = vector1.addVectors( playerCollider.start, playerCollider.end ).multiplyScalar( 0.5 );
const sphere_center = sphere.collider.center;
const r = playerCollider.radius + sphere.collider.radius;
const r2 = r * r;
for ( const point of [ playerCollider.start, playerCollider.end, center ] ) {
const d2 = point.distanceToSquared( sphere_center );
if ( d2 < r2 ) {
const normal = vector1.subVectors( point, sphere_center ).normalize();
const v1 = vector2.copy( normal ).multiplyScalar( normal.dot( playerVelocity ) );
const v2 = vector3.copy( normal ).multiplyScalar( normal.dot( sphere.velocity ) );
playerVelocity.add( v2 ).sub( v1 );
sphere.velocity.add( v1 ).sub( v2 );
const d = ( r - Math.sqrt( d2 ) ) / 2;
sphere_center.addScaledVector( normal, - d );
}
}
}
function spheresCollisions() {
for ( let i = 0, length = spheres.length; i < length; i ++ ) {
const s1 = spheres[ i ];
for ( let j = i + 1; j < length; j ++ ) {
const s2 = spheres[ j ];
const d2 = s1.collider.center.distanceToSquared( s2.collider.center );
const r = s1.collider.radius + s2.collider.radius;
const r2 = r * r;
if ( d2 < r2 ) {
const normal = vector1.subVectors( s1.collider.center, s2.collider.center ).normalize();
const v1 = vector2.copy( normal ).multiplyScalar( normal.dot( s1.velocity ) );
const v2 = vector3.copy( normal ).multiplyScalar( normal.dot( s2.velocity ) );
s1.velocity.add( v2 ).sub( v1 );
s2.velocity.add( v1 ).sub( v2 );
const d = ( r - Math.sqrt( d2 ) ) / 2;
s1.collider.center.addScaledVector( normal, d );
s2.collider.center.addScaledVector( normal, - d );
}
}
}
}
function updateSpheres( deltaTime ) {
spheres.forEach( sphere => {
if ( sphere.life <= 0 ) return;
sphere.life -= deltaTime;
if ( sphere.life <= 0 ) {
recyclePlayerBall( sphere );
return;
}
sphere.collider.center.addScaledVector( sphere.velocity, deltaTime );
const result = worldOctree.sphereIntersect( sphere.collider );
if ( result ) {
sphere.velocity.addScaledVector( result.normal, - result.normal.dot( sphere.velocity ) * 1.5 );
sphere.collider.center.add( result.normal.multiplyScalar( result.depth ) );
} else {
sphere.velocity.y -= GRAVITY * deltaTime;
}
const damping = Math.exp( - 1.5 * deltaTime ) - 1;
sphere.velocity.addScaledVector( sphere.velocity, damping );
playerSphereCollision( sphere );
playerBallVsBots( sphere );
} );
spheresCollisions();
for ( const sphere of spheres ) {
sphere.mesh.position.copy( sphere.collider.center );
}
}
function getForwardVector() {
camera.getWorldDirection( playerDirection );
playerDirection.y = 0;
playerDirection.normalize();
return playerDirection;
}
function getSideVector() {
camera.getWorldDirection( playerDirection );
playerDirection.y = 0;
playerDirection.normalize();
playerDirection.cross( camera.up );
return playerDirection;
}
function controls( deltaTime ) {
const speedDelta = deltaTime * ( playerOnFloor ? 25 : 8 );
if ( keyStates[ 'KeyW' ] ) playerVelocity.add( getForwardVector().multiplyScalar( speedDelta ) );
if ( keyStates[ 'KeyS' ] ) playerVelocity.add( getForwardVector().multiplyScalar( - speedDelta ) );
if ( keyStates[ 'KeyA' ] ) playerVelocity.add( getSideVector().multiplyScalar( - speedDelta ) );
if ( keyStates[ 'KeyD' ] ) playerVelocity.add( getSideVector().multiplyScalar( speedDelta ) );
if ( playerOnFloor && keyStates[ 'Space' ] ) {
lastPlayerJumpTime = matchTime;
playerVelocity.y = 15;
}
}
const loader = new GLTFLoader();
loader.load( MODEL_URL, ( gltf ) => {
scene.add( gltf.scene );
worldOctree.fromGraphNode( gltf.scene );
gltf.scene.traverse( child => {
if ( child.isMesh ) {
child.castShadow = true;
child.receiveShadow = true;
if ( child.material.map ) {
child.material.map.anisotropy = 4;
}
}
} );
const helper = new OctreeHelper( worldOctree );
helper.visible = false;
scene.add( helper );
const gui = new GUI( { width: 200 } );
gui.add( { debug: false }, 'debug' )
.onChange( function ( value ) {
helper.visible = value;
} );
startWave( 1 );
}, undefined, ( err ) => {
console.error( 'Failed to load model:', err );
} );
function teleportPlayerIfOob() {
if ( camera.position.y <= - 25 ) {
playerCollider.start.set( 0, 0.35, 0 );
playerCollider.end.set( 0, 1, 0 );
playerCollider.radius = 0.35;
camera.position.copy( playerCollider.end );
camera.rotation.set( 0, 0, 0 );
}
}
function animate() {
timer.update();
const deltaTime = Math.min( 0.05, timer.getDelta() ) / STEPS_PER_FRAME;
for ( let i = 0; i < STEPS_PER_FRAME; i ++ ) {
if ( playerInvuln > 0 ) playerInvuln -= deltaTime;
if ( ! gameOver ) {
matchTime += deltaTime;
controls( deltaTime );
updatePlayer( deltaTime );
updateBots( deltaTime );
updateSpheres( deltaTime );
updateEnemyBalls( deltaTime );
teleportPlayerIfOob();
removeDeadBots();
}
}
updateBotTracker();
renderer.render( scene, camera );
stats.update();
}
updateHUD();
</script>
</body>
</html>Expand