The FPS game is built around four core systems working together: PointerLockControls for immersive mouse-look, a projectile system that fires spheres along the camera's forward vector, raycaster collision detection between projectiles and enemy cubes, and a triangle explosion system that spawns shrapnel on impact.
When the player isn't engaged (pointer not locked), the camera automatically orbits the scene — giving the game an attract-screen feel. Once the player clicks "Play Now," the pointer locks and they get full WASD movement with boundary collision. The game loop runs at 60fps using requestAnimationFrame with a delta-time clock for frame-rate-independent movement.
Scene & Renderer Setup
The renderer uses logarithmicDepthBuffer: true to prevent z-fighting on overlapping geometry, and Reinhard tone mapping for balanced lighting. It's positioned as a fixed background layer with z-index: -1.
var scene = new THREE.Scene();
let camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 4000);
camera.position.set(9, 0.75, 3);
var renderer = new THREE.WebGLRenderer({
alpha: true,
depth: true,
logarithmicDepthBuffer: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.setClearColor(0x000000, 1);
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.zIndex = '-1';
document.body.appendChild(renderer.domElement);
PointerLockControls & Movement
PointerLockControls captures the mouse cursor and converts mouse movement into camera rotation. When locked, WASD keys move the player with acceleration and deceleration for smooth feel. Boundary collision prevents walking off the grid.
var controls = new PointerLockControls(camera, document.body);
// Movement with acceleration
if (controls.isLocked) {
const acceleration = 0.012;
const maxSpeed = 0.45;
if (moveForward || moveBackward || moveLeft || moveRight) {
controls.speed = Math.min(controls.speed + acceleration, maxSpeed);
} else {
controls.speed *= 0.85; // quick deceleration
}
const moveAmount = controls.speed * delta * 60;
if (moveForward) {
controls.moveForward(moveAmount);
if (checkCollision(controls.getObject().position))
controls.moveForward(-moveAmount); // undo if hitting boundary
}
}
The collision check is simple — if the player's position exceeds the grid bounds (plus or minus 9.9 units), the movement is reversed. This "move then undo" pattern is common in FPS games because it handles all edge cases cleanly.
Projectile System
Projectiles are small spheres spawned at the camera position and given a velocity in the camera's forward direction. They travel in a straight line each frame and are removed when they exceed a distance threshold.
function createParticle() {
playLaserSound();
var geometry = new THREE.SphereGeometry(0.05, 16, 16);
var material = new THREE.MeshBasicMaterial({ color: 0xADD8E6 });
var particle = new THREE.Mesh(geometry, material);
particle.position.copy(camera.position);
particle.initialDirection = camera.getWorldDirection(new THREE.Vector3());
particle.velocity = particle.initialDirection.clone().multiplyScalar(0.25);
scene.add(particle);
particles.push(particle);
}
function updateParticles() {
for (var i = particles.length - 1; i >= 0; i--) {
particles[i].position.add(particles[i].velocity);
if (particles[i].position.distanceTo(camera.position) > 20) {
scene.remove(particles[i]);
particles.splice(i, 1);
}
}
}
Firing is triggered by left mouse click or spacebar. The key insight is using camera.getWorldDirection() to get the exact direction the player is looking, then multiplying by a speed scalar for the velocity vector.
Collision Detection
Hit detection uses a Raycaster cast from each projectile in its travel direction. If the ray intersects an enemy cube, the cube explodes and teleports to a new random position with a new color.
function checkParticleCollision() {
for (var j = 0; j < cubes.length; j++) {
var cube = cubes[j];
for (var i = 0; i < particles.length; i++) {
var particle = particles[i];
var particleEdge = particle.position.clone()
.add(particle.velocity.clone().normalize().multiplyScalar(0.1));
raycaster.set(particle.position,
particleEdge.sub(particle.position).normalize());
var intersects = raycaster.intersectObject(cube);
if (intersects.length > 0) {
explosion(cube);
moveCubeRandomly(cube);
break;
}
}
}
}
The raycaster is set from the projectile's current position toward a point slightly ahead (0.1 units in the velocity direction). This short-range ray catches collisions even at high projectile speeds where frame-by-frame position checks might miss.
Explosion Particle System
When a projectile hits a cube, 50 triangle shrapnel pieces spawn at the cube's position. Each triangle gets a random direction, speed, rotation axis, and rotation speed — creating a satisfying burst effect. Triangles are removed after traveling 3 units.
function explosion(cube) {
playExplosionSound();
for (var i = 0; i < 50; i++) {
var triangle = createTriangle(cube);
scene.add(triangle);
triangles.push(triangle);
triangle.userData = {
direction: new THREE.Vector3(
Math.random() * 2 - 1,
Math.random() * 2 - 1,
Math.random() * 2 - 1
).normalize(),
speed: Math.random() * 0.1 + 0.02,
rotationAxis: new THREE.Vector3(
Math.random(), Math.random(), Math.random()
).normalize(),
rotationSpeed: Math.random() * 0.1 + 0.005,
distance: 0
};
}
}
Each triangle inherits the cube's color, faces the camera on spawn, and gets a random scale between 0.5x and 3.5x. The rotateOnWorldAxis call each frame gives each piece a unique tumbling motion as it flies outward.
Enemy AI & Orbit Camera
Enemy cubes patrol horizontally with simple back-and-forth movement. They check for collisions with other cubes and reverse direction on contact or when hitting boundaries. This creates unpredictable movement patterns from very simple logic.
When the pointer isn't locked, the camera smoothly orbits a center point using sine/cosine on the elapsed time. This gives the game a polished "attract mode" that shows off the scene before the player engages.
// Orbit camera when not playing
function orbitCameraAroundPoint() {
const angle = Date.now() * 0.000025;
const x = center.x + radius * Math.cos(angle);
const z = center.z + radius * Math.sin(angle);
camera.position.set(x, center.y, z);
camera.lookAt(center);
}
Audio System
The game uses the Web Audio API for three sound channels: background music (looped with fade-in), laser fire sounds, and explosion sounds. Each has independent volume control. Music toggles with the M key, and audio context is created on first user interaction to comply with browser autoplay policies.
Sound buffers are loaded once and reused — the laser and explosion sounds create new BufferSource nodes each time they play, which is the correct pattern for overlapping sound effects.
Key Takeaways
PointerLockControls — essential for any FPS. Captures mouse, provides moveForward/moveRight helpers.
camera.getWorldDirection() — the key to firing projectiles where you're looking.
Raycaster for hit detection — cast short rays from projectiles to catch fast-moving collisions.
userData for particle state — store velocity, rotation, and lifetime on each mesh's userData object.
Delta-time movement — multiply all speeds by delta to stay frame-rate independent.
Move-then-undo collision — simple and reliable boundary enforcement pattern.
COMPLETE SOURCE CODE
Copy this code into an HTML file and open it in your browser to see it in action:
HTML / Three.js
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js First Person Shooter Game Starter</title>
<style>
/* Load a custom font for the play button */
@font-face {
font-family: 'Robus-BWqOd';
src: url('https://www.shanebrumback.com/fonts/Robus-BWqOd.otf') format('opentype');
}
/* Remove default page margins so the game fills the whole screen */
body { margin: 0; }
/* Make the 3D canvas fill its container */
canvas { display: block; }
/* Semi-transparent dark overlay that covers the screen when paused */
#blocker {
position: fixed; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
/* Center the instructions/play button in the middle of the screen */
#instructions {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); color: white;
}
/* Crosshair image — centered on screen, hidden until the game starts */
#crosshair {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 100px; height: 100px; display: none;
}
/* Style the "Play Now" button with the custom font */
#playButton {
font-family: 'Robus-BWqOd'; font-size: 5vw;
color: white; text-shadow: 2px 2px 4px rgba(0,0,0,0.75);
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); white-space: nowrap;
}
/* Default paragraph styling for the control instructions */
p { font-family: Arial; font-size: medium; text-align: center; }
/* On mobile screens, make the play button and text bigger */
@media (max-width: 900px) {
#playButton { font-size: 15vw; }
p { font-size: 4vw; }
}
</style>
</head>
<body>
<!-- BLOCKER: This dark overlay is shown when the game is paused -->
<div id="blocker">
<div id="instructions">
<div id="playButton">
Play Now
<!-- Control instructions shown below the play button -->
<p>
ESC - Menu<br>
WASD ARROWS - Move<br>
LEFT MOUSE - Fire<br>
SPACEBAR - Fire<br>
M - Play / Pause Music
</p>
</div>
</div>
</div>
<!-- CROSSHAIR: Aiming reticle shown in the center of the screen during gameplay -->
<img id="crosshair" src="https://www.shanebrumback.com/images/reticle.png" alt="Crosshair">
<!-- LIBRARIES: Load Three.js (3D engine) and PointerLockControls (FPS camera) from CDN -->
<script src="https://cdn.jsdelivr.net/npm/three@latest/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@latest/examples/js/controls/PointerLockControls.js"></script>
<script type="module">
// ============================================================
// MOBILE DETECTION
// Check if the user is on a phone/tablet so we can show
// touch controls instead of mouse/keyboard controls.
// ============================================================
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
var mobileActive = false; // Tracks if mobile game mode is running
// ============================================================
// SCENE SETUP
// The scene is the container that holds all 3D objects.
// The camera is the player's "eyes" — it defines what you see.
// ============================================================
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(9, 0.3, 3);
// ============================================================
// RENDERER SETUP
// The renderer draws the 3D scene onto a 2D canvas element.
// ============================================================
var renderer = new THREE.WebGLRenderer({ alpha: true, depth: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.setClearColor(0x000000, 1);
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.zIndex = '-1';
renderer.domElement.style.left = '0';
renderer.domElement.style.top = '0';
document.body.appendChild(renderer.domElement);
// ============================================================
// GAME VARIABLES
// ============================================================
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
var particles = []; // Active bullet projectiles
var triangles = []; // Active explosion pieces
let cubes = []; // Enemy cubes
// ============================================================
// POINTER LOCK CONTROLS (Desktop FPS camera)
// ============================================================
var controls = new THREE.PointerLockControls(camera, document.body);
// ============================================================
// GRID (THE FLOOR)
// ============================================================
var gridHelper = new THREE.GridHelper(20, 20);
gridHelper.material.color.set(0xffffff);
scene.add(gridHelper);
// ============================================================
// ENEMY CUBES — 5 colored cubes that move back and forth
// ============================================================
var colors = [0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xffa500];
for (var i = 0; i < 5; i++) {
var geometry = new THREE.BoxGeometry(1, 1, 1);
var material = new THREE.MeshBasicMaterial({
color: colors[Math.floor(Math.random() * colors.length)]
});
var cube = new THREE.Mesh(geometry, material);
cube.position.set(Math.random() * 16 - 8, 0.5, Math.random() * 16 - 8);
cube.userData.speed = 1 + Math.random() * 2;
cube.userData.direction = Math.random() > 0.5 ? 1 : -1;
scene.add(cube);
cubes.push(cube);
}
// ============================================================
// FIRE FOUNTAIN — decorative particle effect in the center
// ============================================================
var fireParticles = [];
var fireGroup = new THREE.Group();
fireGroup.position.set(0, 0, 0);
scene.add(fireGroup);
function createFireParticle() {
var geo = new THREE.SphereGeometry(0.05 + Math.random() * 0.05, 8, 8);
var mat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(0.05 + Math.random() * 0.08, 1, 0.5)
});
var p = new THREE.Mesh(geo, mat);
p.position.set((Math.random() - 0.5) * 0.3, 0, (Math.random() - 0.5) * 0.3);
p.userData.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.02, 0.05 + Math.random() * 0.05, (Math.random() - 0.5) * 0.02
);
p.userData.life = 1.0;
fireGroup.add(p);
fireParticles.push(p);
}
function updateFire() {
for (var i = 0; i < 3; i++) createFireParticle();
for (var i = fireParticles.length - 1; i >= 0; i--) {
var p = fireParticles[i];
p.position.add(p.userData.velocity);
p.userData.velocity.y -= 0.001;
p.userData.life -= 0.02;
p.material.opacity = p.userData.life;
p.material.transparent = true;
var s = p.userData.life * 0.8;
p.scale.set(s, s, s);
if (p.userData.life <= 0) {
fireGroup.remove(p);
fireParticles.splice(i, 1);
}
}
}
camera.lookAt(cube.position);
// ============================================================
// UI SETUP — PLAY BUTTON & POINTER LOCK
// On desktop: clicking Play locks the pointer for FPS controls
// On mobile: tapping Play in landscape shows touch controls
// ============================================================
var blocker = document.getElementById('blocker');
var instructions = document.getElementById('instructions');
var playButton = document.getElementById('playButton');
if (isMobile) {
// Mobile: change button text, require landscape
playButton.textContent = '📱 Tap to Play (Landscape)';
playButton.addEventListener('touchstart', function (e) {
e.preventDefault();
if (window.innerWidth > window.innerHeight) {
// Landscape — start mobile game
mobileActive = true;
blocker.style.display = 'none';
instructions.style.display = 'none';
document.getElementById('crosshair').style.display = 'block';
showMobileUI();
} else {
playButton.textContent = '↻ Rotate to Landscape';
setTimeout(function() { playButton.textContent = '📱 Tap to Play (Landscape)'; }, 2000);
}
});
} else {
// Desktop: pointer lock
playButton.addEventListener('click', function () { controls.lock(); });
}
controls.addEventListener('lock', function () {
instructions.style.display = 'none';
blocker.style.display = 'none';
document.getElementById('crosshair').style.display = 'block';
});
controls.addEventListener('unlock', function () {
blocker.style.display = 'block';
instructions.style.display = '';
document.getElementById('crosshair').style.display = 'none';
});
scene.add(controls.getObject());
// ============================================================
// KEYBOARD MOVEMENT (Desktop)
// ============================================================
var moveForward = false, moveBackward = false, moveLeft = false, moveRight = false;
document.addEventListener('keydown', function (event) {
switch (event.keyCode) {
case 38: case 87: moveForward = true; break;
case 37: case 65: moveLeft = true; break;
case 40: case 83: moveBackward = true; break;
case 39: case 68: moveRight = true; break;
}
});
document.addEventListener('keyup', function (event) {
switch (event.keyCode) {
case 38: case 87: moveForward = false; break;
case 37: case 65: moveLeft = false; break;
case 40: case 83: moveBackward = false; break;
case 39: case 68: moveRight = false; break;
}
});
// ============================================================
// BOUNDARY COLLISION — keeps player inside the grid
// ============================================================
function checkCollision(position) {
var margin = 0.1;
return (
position.x < -9.9 + margin || position.x > 9.9 - margin ||
position.z < -9.9 + margin || position.z > 9.9 - margin
);
}
// ============================================================
// MOBILE TOUCH CONTROLS
// Left side: virtual joystick for movement (drag to move)
// Right side: fire button (tap to shoot)
// Touch drag anywhere else: camera look (aim)
// ============================================================
var joystickData = { active: false, startX: 0, startY: 0, moveX: 0, moveY: 0 };
var lookData = { active: false, lastX: 0, lastY: 0, touchId: null };
// Creates and shows the mobile UI elements (joystick + fire button)
function showMobileUI() {
if (document.getElementById('mobile-joystick')) {
document.getElementById('mobile-joystick').style.display = 'flex';
document.getElementById('mobile-fire-btn').style.display = 'flex';
return;
}
// Virtual joystick — bottom left corner
var joystick = document.createElement('div');
joystick.id = 'mobile-joystick';
joystick.style.cssText = 'position:fixed;bottom:20px;left:20px;width:120px;height:120px;border-radius:50%;background:rgba(255,255,255,0.1);border:2px solid rgba(42,209,214,0.5);z-index:100;touch-action:none;display:flex;align-items:center;justify-content:center;';
joystick.innerHTML = '<div id="joystick-knob" style="width:50px;height:50px;border-radius:50%;background:rgba(42,209,214,0.6);pointer-events:none;"></div>';
document.body.appendChild(joystick);
// Fire button — bottom right corner
var fireBtn = document.createElement('div');
fireBtn.id = 'mobile-fire-btn';
fireBtn.style.cssText = 'position:fixed;bottom:30px;right:30px;width:80px;height:80px;border-radius:50%;background:rgba(255,50,50,0.7);border:3px solid rgba(255,100,100,0.8);z-index:100;touch-action:none;display:flex;align-items:center;justify-content:center;font-family:Arial;font-weight:bold;font-size:14px;color:#fff;user-select:none;';
fireBtn.textContent = 'FIRE';
document.body.appendChild(fireBtn);
// Joystick touch events
joystick.addEventListener('touchstart', function(e) {
e.preventDefault(); e.stopPropagation();
var rect = joystick.getBoundingClientRect();
joystickData.active = true;
joystickData.startX = rect.left + rect.width / 2;
joystickData.startY = rect.top + rect.height / 2;
}, { passive: false });
joystick.addEventListener('touchmove', function(e) {
e.preventDefault(); e.stopPropagation();
if (!joystickData.active) return;
var touch = e.touches[0];
var dx = touch.clientX - joystickData.startX;
var dy = touch.clientY - joystickData.startY;
var maxDist = 40;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > maxDist) { dx = dx / dist * maxDist; dy = dy / dist * maxDist; }
joystickData.moveX = dx / maxDist; // -1 to 1 (left/right)
joystickData.moveY = dy / maxDist; // -1 to 1 (up/down)
var knob = document.getElementById('joystick-knob');
if (knob) knob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
}, { passive: false });
joystick.addEventListener('touchend', function(e) {
e.preventDefault(); e.stopPropagation();
joystickData.active = false;
joystickData.moveX = 0; joystickData.moveY = 0;
var knob = document.getElementById('joystick-knob');
if (knob) knob.style.transform = 'translate(0,0)';
}, { passive: false });
// Fire button — tap to shoot
fireBtn.addEventListener('touchstart', function(e) {
e.preventDefault();
if (mobileActive) createParticle();
}, { passive: false });
// Camera look — touch drag on the rest of the screen
document.addEventListener('touchstart', function(e) {
if (e.target.closest('#mobile-joystick') || e.target.closest('#mobile-fire-btn')) return;
if (!mobileActive) return;
var touch = e.changedTouches[0];
lookData.active = true;
lookData.lastX = touch.clientX;
lookData.lastY = touch.clientY;
lookData.touchId = touch.identifier;
}, { passive: false });
document.addEventListener('touchmove', function(e) {
if (!lookData.active || !mobileActive) return;
for (var i = 0; i < e.changedTouches.length; i++) {
var touch = e.changedTouches[i];
if (touch.identifier === lookData.touchId) {
var dx = touch.clientX - lookData.lastX;
var dy = touch.clientY - lookData.lastY;
lookData.lastX = touch.clientX;
lookData.lastY = touch.clientY;
// Replicate PointerLockControls mouse look exactly:
// Extract Euler (YXZ order) from camera, modify yaw+pitch, set back
var _euler = new THREE.Euler(0, 0, 0, 'YXZ');
_euler.setFromQuaternion(camera.quaternion);
_euler.y -= dx * 0.004;
_euler.x -= dy * 0.004;
// Clamp pitch so you can't look upside down
_euler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, _euler.x));
camera.quaternion.setFromEuler(_euler);
break;
}
}
}, { passive: false });
document.addEventListener('touchend', function(e) {
for (var i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookData.touchId) {
lookData.active = false; break;
}
}
}, { passive: false });
}
// Pause mobile game if user rotates back to portrait
window.addEventListener('resize', function() {
if (isMobile && mobileActive && window.innerHeight > window.innerWidth) {
mobileActive = false;
blocker.style.display = 'block';
instructions.style.display = '';
document.getElementById('crosshair').style.display = 'none';
var j = document.getElementById('mobile-joystick');
var f = document.getElementById('mobile-fire-btn');
if (j) j.style.display = 'none';
if (f) f.style.display = 'none';
}
});
// ============================================================
// MAIN GAME LOOP — runs ~60 times per second
// Handles movement (keyboard + joystick), bullets, explosions
// ============================================================
function animate() {
requestAnimationFrame(animate);
updateParticles();
checkParticleCollision();
updateFire();
// Move enemy cubes back and forth
cubes.forEach(function(c) {
c.position.x += 0.02 * (c.userData.speed || 2) * (c.userData.direction || 1);
if (c.position.x > 9 || c.position.x < -9) c.userData.direction *= -1;
});
// Player movement — works with both keyboard AND joystick
if (controls.isLocked || mobileActive) {
var delta = 0.008; // Movement speed per frame (keep low for mobile)
// Read joystick input (0 if not touching)
var joyFwd = joystickData.moveY < -0.2;
var joyBack = joystickData.moveY > 0.2;
var joyLeft = joystickData.moveX < -0.2;
var joyRight = joystickData.moveX > 0.2;
if (moveForward || joyFwd) {
controls.moveForward(delta);
if (checkCollision(controls.getObject().position)) controls.moveForward(-delta);
}
if (moveBackward || joyBack) {
controls.moveForward(-delta);
if (checkCollision(controls.getObject().position)) controls.moveForward(delta);
}
if (moveLeft || joyLeft) {
controls.moveRight(-delta);
if (checkCollision(controls.getObject().position)) controls.moveRight(delta);
}
if (moveRight || joyRight) {
controls.moveRight(delta);
if (checkCollision(controls.getObject().position)) controls.moveRight(-delta);
}
}
updateTriangles();
renderer.render(scene, camera);
}
animate();
// ============================================================
// PROJECTILE SYSTEM — fires light-blue spheres
// ============================================================
function createParticle() {
playLaserSound();
var geo = new THREE.SphereGeometry(0.05, 16, 16);
var mat = new THREE.MeshBasicMaterial({ color: 0xADD8E6 });
var particle = new THREE.Mesh(geo, mat);
particle.position.copy(camera.position);
particle.initialDirection = camera.getWorldDirection(new THREE.Vector3());
particle.velocity = particle.initialDirection.clone().multiplyScalar(0.25);
scene.add(particle);
particles.push(particle);
}
function updateParticles() {
for (var i = particles.length - 1; i >= 0; i--) {
particles[i].position.add(particles[i].velocity);
if (particles[i].position.distanceTo(camera.position) > 20) {
scene.remove(particles[i]);
particles.splice(i, 1);
}
}
}
// Desktop firing: mouse click and spacebar
document.addEventListener('mousedown', function (event) {
if (controls.isLocked && event.button === 0) createParticle();
});
document.addEventListener('keydown', function (event) {
if (event.key === ' ' && controls.isLocked) { event.preventDefault(); createParticle(); }
if (event.key === 'm' || event.key === 'M') playMusic();
});
// ============================================================
// COLLISION DETECTION — raycaster checks bullet vs cube hits
// ============================================================
function checkParticleCollision() {
for (var j = 0; j < cubes.length; j++) {
var cube = cubes[j];
if (!cube.visible) continue;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var edge = p.position.clone().add(p.velocity.clone().normalize().multiplyScalar(0.1));
raycaster.set(p.position, edge.sub(p.position).normalize());
if (raycaster.intersectObject(cube).length > 0) {
explosion(cube);
moveCubeRandomly(cube);
break;
}
}
}
}
function moveCubeRandomly(cube) {
cube.position.x = Math.floor(Math.random() * 20) - 10;
cube.position.z = Math.floor(Math.random() * 20) - 10;
cube.material.color.set(colors[Math.floor(Math.random() * colors.length)]);
}
// ============================================================
// EXPLOSION SYSTEM — 50 triangles burst outward on hit
// ============================================================
function explosion(cube) {
playExplosionSound();
for (var i = 0; i < 50; i++) {
var triangle = createTriangle(cube);
scene.add(triangle);
triangles.push(triangle);
triangle.userData = {
direction: new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1).normalize(),
speed: Math.random() * 0.05 + 0.01,
rotationAxis: new THREE.Vector3(Math.random(), Math.random(), Math.random()).normalize(),
rotationSpeed: Math.random() * 0.1 + 0.005,
distance: 0, remove: false
};
}
}
function createTriangle(cube) {
var geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.1,0,0, 0.1,0,0, 0,0.1,0]), 3));
geo.setIndex(new THREE.BufferAttribute(new Uint16Array([0,1,2]), 1));
var mat = new THREE.MeshBasicMaterial({ color: cube.material.color.getHex(), side: THREE.DoubleSide });
var tri = new THREE.Mesh(geo, mat);
tri.position.copy(cube.position);
tri.lookAt(camera.position);
var s = Math.random() * 1 + 0.5;
tri.scale.set(s, s, s);
return tri;
}
function updateTriangles() {
for (var i = 0; i < triangles.length; i++) {
var t = triangles[i], u = t.userData;
t.position.add(u.direction.clone().multiplyScalar(u.speed));
t.rotateOnWorldAxis(u.rotationAxis, u.rotationSpeed);
u.distance += u.speed;
if (u.distance >= 2) u.remove = true;
}
for (var i = triangles.length - 1; i >= 0; i--) {
if (triangles[i].userData.remove) { scene.remove(triangles[i]); triangles.splice(i, 1); }
}
}
// ============================================================
// AUDIO SYSTEM — Web Audio API for music, laser, explosion
// ============================================================
var audioContext = null, musicBuffer = null, laserSoundBuffer = null;
var explosionSoundBuffer = null, isMusicPlaying = false, musicSource = null;
function loadAudioFile(url, callback) {
var req = new XMLHttpRequest();
req.open('GET', url, true);
req.responseType = 'arraybuffer';
req.onload = function () { audioContext.decodeAudioData(req.response, callback); };
req.send();
}
function playMusic() {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (!musicBuffer) {
loadAudioFile('https://www.shanebrumback.com/sounds/first-person-shooter-music.wav', function (buf) {
musicBuffer = buf; playLoopedSound(buf, 0.35); isMusicPlaying = true;
});
} else {
if (isMusicPlaying) { pauseSound(); isMusicPlaying = false; }
else { resumeSound(); isMusicPlaying = true; }
}
}
function playLoopedSound(buffer, volume) {
musicSource = audioContext.createBufferSource();
musicSource.buffer = buffer; musicSource.loop = true;
var gain = audioContext.createGain();
gain.gain.setValueAtTime(0, audioContext.currentTime);
gain.gain.linearRampToValueAtTime(volume, audioContext.currentTime + 2);
musicSource.connect(gain); gain.connect(audioContext.destination);
musicSource.start(audioContext.currentTime + 0.1);
}
function pauseSound() { if (musicSource) { musicSource.stop(); musicSource.disconnect(); musicSource = null; } }
function resumeSound() { if (musicBuffer) playLoopedSound(musicBuffer, 0.35); }
function playLaserSound() {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (!laserSoundBuffer) { loadAudioFile('https://www.shanebrumback.com/sounds/laser.wav', function (buf) { laserSoundBuffer = buf; playSound(buf, 1); }); }
else playSound(laserSoundBuffer, 1);
}
function playExplosionSound() {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (!explosionSoundBuffer) { loadAudioFile('https://www.shanebrumback.com/sounds/explosion.wav', function (buf) { explosionSoundBuffer = buf; playSound(buf, 0.25); }); }
else playSound(explosionSoundBuffer, 0.25);
}
function playSound(buffer, volume) {
var src = audioContext.createBufferSource();
var gain = audioContext.createGain(); gain.gain.value = volume;
src.buffer = buffer; src.connect(gain); gain.connect(audioContext.destination); src.start(0);
}
// ============================================================
// WINDOW RESIZE HANDLER
// ============================================================
window.addEventListener('resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>