FPS

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