missile-interceptor / index.html
broadfield-dev's picture
Update index.html
26997b4 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Patriot Intercept Simulator • v5</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
body { margin: 0; overflow: hidden; font-family: system-ui; }
canvas { display: block; }
.sidebar {
transition: width 0.4s cubic-bezier(0.32, 0.72, 0, 1), padding 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}
.sidebar.collapsed {
width: 0 !important;
padding: 0 !important;
overflow: hidden;
}
</style>
</head>
<body class="bg-zinc-950 text-white">
<div class="flex h-screen">
<!-- Sidebar -->
<div id="sidebar" class="w-80 flex flex-col border-r border-zinc-800 sidebar p-6 overflow-y-auto bg-gradient-to-b from-[#111827] to-[#1f2937]">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-gradient-to-br from-red-500 to-amber-500 rounded-2xl flex items-center justify-center text-2xl shadow-lg">🛡️</div>
<div>
<h1 class="text-3xl font-black tracking-[-2px]">PATRIOT SIM</h1>
<p class="text-emerald-400 text-sm -mt-1">v5 • PERFECT HOMING </p>
</div>
</div>
<button onclick="toggleSidebar()" class="text-zinc-400 hover:text-white p-2 rounded-xl transition-colors">
<svg id="toggleIcon" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7" />
</svg>
</button>
</div>
<div class="space-y-6">
<div>
<h2 class="uppercase text-xs tracking-widest text-zinc-400 mb-3">THREAT SCENARIO</h2>
<div class="space-y-5">
<div>
<label class="text-xs text-zinc-400 block mb-1">NUMBER OF INCOMING</label>
<input id="numThreats" type="range" min="1" max="12" value="5" class="w-full accent-red-500">
<div class="flex justify-between text-[10px] text-zinc-400 mt-1"><span>1</span><span id="numThreatsVal" class="font-mono text-white">5</span><span>12</span></div>
</div>
<div>
<label class="text-xs text-zinc-400 block mb-1">THREAT SPEED (km/s)</label>
<input id="vm" type="range" min="1" max="3" value="1.4" step="0.1" class="w-full accent-red-500">
<div class="flex justify-between text-[10px] text-zinc-400 mt-1"><span>1.0</span><span id="vmVal" class="font-mono text-white">1.4</span><span>3.0</span></div>
</div>
<div>
<label class="text-xs text-zinc-400 block mb-1">INTERCEPTOR ACCEL (g)</label>
<input id="accelG" type="range" min="8" max="60" value="35" step="1" class="w-full accent-sky-500">
<div class="flex justify-between text-[10px] text-zinc-400 mt-1"><span>8g</span><span id="accelGVal" class="font-mono text-white">35g</span><span>60g</span></div>
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<button onclick="launchScenario()" class="col-span-2 bg-red-600 hover:bg-red-700 py-4 rounded-2xl font-bold text-lg flex items-center justify-center gap-2 transition-all active:scale-95">
🚀 LAUNCH WAVE
</button>
<button onclick="launchInterceptors()" class="bg-sky-600 hover:bg-sky-700 py-4 rounded-2xl font-bold flex items-center justify-center gap-2 transition-all active:scale-95">
🛡️ ENGAGE
</button>
</div>
<div class="flex gap-2">
<button onclick="toggleAutoEngage()" id="autoBtn" class="flex-1 py-3 bg-amber-600 hover:bg-amber-700 rounded-2xl text-sm font-semibold">AUTO ENGAGE: OFF</button>
<button onclick="togglePause()" id="pauseBtn" class="flex-1 py-3 bg-zinc-700 hover:bg-zinc-600 rounded-2xl text-sm font-semibold">PAUSE</button>
<button onclick="resetSim()" class="flex-1 py-3 bg-zinc-700 hover:bg-zinc-600 rounded-2xl text-sm font-semibold">RESET</button>
</div>
</div>
<div class="mt-8 bg-zinc-900/70 rounded-3xl p-5 border border-zinc-700">
<div class="flex justify-between items-baseline mb-4">
<div class="text-emerald-400 text-5xl font-mono tabular-nums" id="intercepts">0</div>
<div class="text-right">
<div class="text-xs text-zinc-400">INTERCEPTS</div>
<div class="text-3xl font-mono text-amber-400" id="misses">0</div>
<div class="text-xs text-zinc-400">MISSES</div>
</div>
<div class="text-right">
<div class="text-5xl font-mono text-red-400" id="baseHits">0</div>
<div class="text-xs text-zinc-400">BASE HITS</div>
</div>
</div>
<div id="successRate" class="text-center text-2xl font-bold text-emerald-400">100%</div>
<div id="timeDisplay" class="text-center font-mono text-xl mt-1 text-white/60">T+ 00:00</div>
</div>
<div class="mt-auto pt-8 text-[10px] text-zinc-500">
v5 • PERFECT HOMING • Collapsible sidebar<br>
Production-ready Flask app
</div>
</div>
<!-- 3D Viewport -->
<div class="flex-1 relative" id="viewport">
<canvas id="canvas"></canvas>
<div class="absolute top-6 left-1/2 -translate-x-1/2 bg-black/70 backdrop-blur-xl px-8 py-2 rounded-3xl font-mono text-sm flex items-center gap-6 shadow-2xl">
<div id="status" class="px-4 py-1 bg-emerald-500/20 text-emerald-400 rounded-2xl">READY — PRESS LAUNCH</div>
</div>
<div class="absolute top-6 right-6 bg-black/60 backdrop-blur-md px-5 py-2.5 rounded-3xl font-mono text-xs flex flex-col gap-1 text-right">
<div>THREATS: <span id="threatCount" class="text-red-400">0</span></div>
<div>INTERCEPTORS: <span id="intCount" class="text-sky-400">0</span></div>
</div>
<div id="hitFlash" class="absolute inset-0 bg-red-600 pointer-events-none opacity-0 transition-all duration-200"></div>
<!-- Floating toggle when collapsed -->
<button onclick="toggleSidebar()" id="floatingToggle"
class="hidden fixed left-4 top-6 bg-zinc-800 hover:bg-zinc-700 text-white p-3 rounded-2xl shadow-2xl z-50 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<script>
// ==================== THREE.JS CORE ====================
let scene, camera, renderer, clock;
let threats = [], interceptors = [], particles = [], launchSmoke = [];
let baseMesh;
let isPaused = false, autoEngage = false, autoTimer = null;
let simTime = 0, intercepts = 0, baseHits = 0, misses = 0;
let sidebarCollapsed = false;
const BASE_POS = new THREE.Vector3(0, 0, 0);
const GRAVITY = -0.00981;
const G = 0.00981;
let threatSpeed = 1.4;
let interceptorAccelG = 35;
let theta = 2.4, phi = 0.9, radius = 65;
let isDragging = false, prevMouse = {x:0, y:0};
class Threat {
constructor(pos, vel) {
this.pos = pos.clone();
this.vel = vel.clone();
this.mesh = createMissile(0xff3300);
this.mesh.position.copy(this.pos);
scene.add(this.mesh);
this.trail = createTrail(0xff8855);
scene.add(this.trail);
this.trailPoints = [];
this.alive = true;
this.assigned = false;
}
update(dt) {
this.vel.y += GRAVITY * dt;
this.pos.addScaledVector(this.vel, dt);
this.mesh.position.copy(this.pos);
this.mesh.lookAt(this.pos.clone().add(this.vel));
this.trailPoints.push(this.pos.clone());
if (this.trailPoints.length > 180) this.trailPoints.shift();
this.trail.geometry.setFromPoints(this.trailPoints);
}
remove() {
scene.remove(this.mesh);
scene.remove(this.trail);
this.alive = false;
}
}
class Interceptor {
constructor(target) {
this.pos = BASE_POS.clone().add(new THREE.Vector3(0, 1.5, 0));
this.target = target;
this.accel = interceptorAccelG * G;
const d = this.target.pos.clone().sub(this.pos).length();
const vm = this.target.vel.length();
let t = 3.0;
if (vm > 0.01) {
const disc = vm * vm + 2 * this.accel * d;
if (disc > 0) t = (-vm + Math.sqrt(disc)) / this.accel;
t = Math.max(t, 0.8);
}
const predicted = this.target.pos.clone().addScaledVector(this.target.vel, t);
const launchDir = predicted.sub(this.pos).normalize();
this.vel = launchDir.multiplyScalar(0.95);
this.mesh = createMissile(0x00ddff);
this.mesh.position.copy(this.pos);
scene.add(this.mesh);
this.trail = createTrail(0x88eeff, 0.85);
scene.add(this.trail);
this.trailPoints = [];
this.alive = true;
}
update(dt) {
if (!this.target || !this.target.alive) return;
const toTarget = this.target.pos.clone().sub(this.pos);
const d = toTarget.length();
const vm = this.target.vel.length();
let t = 1.2;
if (vm > 0.01) {
const disc = vm * vm + 2 * this.accel * d;
if (disc > 0) t = (-vm + Math.sqrt(disc)) / this.accel;
t = Math.max(t, 0.4);
}
const predicted = this.target.pos.clone().addScaledVector(this.target.vel, t);
const desiredDir = predicted.sub(this.pos).normalize();
const accelVec = desiredDir.multiplyScalar(this.accel * 1.8);
this.vel.addScaledVector(accelVec, dt);
if (this.vel.length() > 2.2) this.vel.setLength(2.2);
this.pos.addScaledVector(this.vel, dt);
this.mesh.position.copy(this.pos);
this.mesh.lookAt(this.pos.clone().add(this.vel));
this.trailPoints.push(this.pos.clone());
if (this.trailPoints.length > 110) this.trailPoints.shift();
this.trail.geometry.setFromPoints(this.trailPoints);
}
remove() {
scene.remove(this.mesh);
scene.remove(this.trail);
this.alive = false;
}
}
function createMissile(color) {
const group = new THREE.Group();
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 2.4, 12), new THREE.MeshPhongMaterial({color, emissive: color, emissiveIntensity: 0.95}));
body.rotation.x = Math.PI / 2;
group.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.09, 1.0, 12), new THREE.MeshPhongMaterial({color: 0x111111, emissive: color}));
nose.position.z = 1.7; nose.rotation.x = Math.PI / 2;
group.add(nose);
const glow = new THREE.PointLight(color, 3, 12);
glow.position.z = 0.8;
group.add(glow);
return group;
}
function createTrail(color, opacity = 0.9) {
return new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({color, linewidth: 7, transparent: true, opacity}));
}
function createExplosion(pos, big = false) {
const count = big ? 48 : 24;
for (let i = 0; i < count; i++) {
const p = new THREE.Mesh(new THREE.SphereGeometry(big ? 0.7 : 0.4, 8, 8), new THREE.MeshBasicMaterial({color: Math.random() > 0.5 ? 0xffaa00 : 0xffff44}));
p.position.copy(pos);
scene.add(p);
const vel = new THREE.Vector3((Math.random()-0.5)*28, (Math.random()-0.5)*28 + (big?15:8), (Math.random()-0.5)*28);
particles.push({mesh: p, vel, life: big ? 2.4 : 1.5});
}
}
function launchSmokeEffect(pos) {
for (let i = 0; i < 12; i++) {
const s = new THREE.Mesh(new THREE.SphereGeometry(0.5 + Math.random()*0.6, 6, 6), new THREE.MeshBasicMaterial({color: 0xcccccc, transparent: true, opacity: 0.7}));
s.position.copy(pos);
scene.add(s);
launchSmoke.push({mesh: s, life: 2.2, vel: new THREE.Vector3((Math.random()-0.5)*4, 6 + Math.random()*8, (Math.random()-0.5)*4)});
}
}
function init() {
clock = new THREE.Clock();
const canvas = document.getElementById('canvas');
renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setSize(window.innerWidth - 320, window.innerHeight);
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a1421, 0.011);
scene.background = new THREE.Color(0x0a1421);
camera = new THREE.PerspectiveCamera(52, (window.innerWidth-320)/window.innerHeight, 0.1, 500);
updateCamera();
scene.add(new THREE.AmbientLight(0x6688aa, 0.7));
const sun = new THREE.DirectionalLight(0xffeecc, 1.5);
sun.position.set(90, 140, 70);
scene.add(sun);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshPhongMaterial({color: 0x1a2a1a}));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
baseMesh = new THREE.Group();
const bunker = new THREE.Mesh(new THREE.BoxGeometry(8, 3, 12), new THREE.MeshPhongMaterial({color: 0x444444}));
bunker.position.y = 1.5;
baseMesh.add(bunker);
const radar = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.6, 0.7, 48), new THREE.MeshPhongMaterial({color: 0x99bbdd}));
radar.rotation.x = Math.PI / 2;
radar.position.y = 5.2;
baseMesh.add(radar);
scene.add(baseMesh);
const grid = new THREE.GridHelper(300, 60, 0x334422, 0x223311);
grid.position.y = 0.02;
scene.add(grid);
window.addEventListener('resize', onResize);
const vp = document.getElementById('viewport');
vp.addEventListener('mousedown', e => { if (e.button === 0) { isDragging = true; prevMouse = {x: e.clientX, y: e.clientY}; } });
vp.addEventListener('mousemove', onMouseMove);
vp.addEventListener('mouseup', () => isDragging = false);
vp.addEventListener('wheel', e => { radius = Math.max(35, Math.min(160, radius - e.deltaY * 0.09)); updateCamera(); });
updateUI();
animate();
}
function updateCamera() {
const x = radius * Math.sin(phi) * Math.cos(theta);
const z = radius * Math.sin(phi) * Math.sin(theta);
camera.position.set(x, radius * Math.cos(phi) * 0.65 + 20, z);
camera.lookAt(6, 14, 0);
}
function onMouseMove(e) {
if (!isDragging) return;
theta -= (e.clientX - prevMouse.x) * 0.0058;
phi = Math.max(0.25, Math.min(1.75, phi - (e.clientY - prevMouse.y) * 0.0058));
updateCamera();
prevMouse = {x: e.clientX, y: e.clientY};
}
function onResize() {
const w = window.innerWidth - (sidebarCollapsed ? 0 : 320);
camera.aspect = w / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(w, window.innerHeight);
}
function launchScenario() {
resetSim(false);
const n = parseInt(document.getElementById('numThreats').value);
threatSpeed = parseFloat(document.getElementById('vm').value);
for (let i = 0; i < n; i++) {
const spread = (i - (n-1)/2) * 0.72;
const range = 52 + Math.random() * 18;
const elev = 0.38 + Math.random() * 0.32;
const px = range * Math.cos(elev) * Math.cos(spread);
const pz = range * Math.cos(elev) * Math.sin(spread);
const py = range * Math.sin(elev) + 6;
const start = new THREE.Vector3(px, py, pz);
const dir = start.clone().negate().normalize();
const vel = dir.multiplyScalar(threatSpeed);
threats.push(new Threat(start, vel));
}
document.getElementById('status').innerHTML = 'INCOMING — ENGAGE!';
document.getElementById('status').className = 'px-4 py-1 bg-amber-500/30 text-amber-300 rounded-2xl';
if (autoEngage) autoTimer = setTimeout(launchInterceptors, 900);
}
function launchInterceptors() {
interceptorAccelG = parseFloat(document.getElementById('accelG').value);
threats.forEach(th => {
if (th.alive && !th.assigned) {
const intc = new Interceptor(th);
interceptors.push(intc);
th.assigned = true;
launchSmokeEffect(intc.pos);
}
});
document.getElementById('status').innerHTML = 'MISSILES AWAY — HOMING';
document.getElementById('status').className = 'px-4 py-1 bg-sky-500/30 text-sky-300 rounded-2xl';
}
function checkCollisions() {
for (let i = interceptors.length - 1; i >= 0; i--) {
const intc = interceptors[i];
for (let j = threats.length - 1; j >= 0; j--) {
const th = threats[j];
if (th.alive && intc.pos.distanceTo(th.pos) < 3.2) {
createExplosion(intc.pos, true);
th.remove();
intc.remove();
threats.splice(j, 1);
interceptors.splice(i, 1);
intercepts++;
updateStats();
cameraShake();
return;
}
}
}
for (let j = threats.length - 1; j >= 0; j--) {
const th = threats[j];
if (th.alive && th.pos.length() < 8 && th.pos.y < 9) {
createExplosion(th.pos, true);
th.remove();
threats.splice(j, 1);
baseHits++;
updateStats();
document.getElementById('hitFlash').style.opacity = '0.8';
setTimeout(() => document.getElementById('hitFlash').style.opacity = '0', 260);
cameraShake(1.8);
}
}
}
function updateStats() {
document.getElementById('intercepts').textContent = intercepts;
document.getElementById('baseHits').textContent = baseHits;
document.getElementById('misses').textContent = misses;
const total = intercepts + baseHits + misses;
document.getElementById('successRate').textContent = total ? Math.round(intercepts / total * 100) + '%' : '100%';
document.getElementById('threatCount').textContent = threats.length;
document.getElementById('intCount').textContent = interceptors.length;
}
function cameraShake(intensity = 1.0) {
const orig = camera.position.clone();
let t = 0;
const intv = setInterval(() => {
t += 0.22;
camera.position.copy(orig).add(new THREE.Vector3((Math.random()-0.5)*intensity*2, (Math.random()-0.5)*intensity, (Math.random()-0.5)*intensity*2));
if (t > 0.9) { clearInterval(intv); camera.position.copy(orig); updateCamera(); }
}, 35);
}
function updateUI() {
document.getElementById('numThreatsVal').textContent = document.getElementById('numThreats').value;
document.getElementById('vmVal').textContent = document.getElementById('vm').value;
document.getElementById('accelGVal').textContent = document.getElementById('accelG').value + 'g';
}
['numThreats','vm','accelG'].forEach(id => document.getElementById(id).addEventListener('input', updateUI));
function togglePause() { isPaused = !isPaused; document.getElementById('pauseBtn').textContent = isPaused ? 'RESUME' : 'PAUSE'; }
function toggleAutoEngage() {
autoEngage = !autoEngage;
const btn = document.getElementById('autoBtn');
btn.textContent = 'AUTO ENGAGE: ' + (autoEngage ? 'ON' : 'OFF');
btn.classList.toggle('bg-emerald-600', autoEngage);
}
function resetSim(full = true) {
if (autoTimer) clearTimeout(autoTimer);
threats.forEach(t => t.remove());
interceptors.forEach(i => i.remove());
particles.forEach(p => scene.remove(p.mesh));
launchSmoke.forEach(s => scene.remove(s.mesh));
threats = []; interceptors = []; particles = []; launchSmoke = [];
if (full) { intercepts = baseHits = misses = 0; simTime = 0; }
updateStats();
document.getElementById('status').innerHTML = 'READY';
document.getElementById('status').className = 'px-4 py-1 bg-emerald-500/20 text-emerald-400 rounded-2xl';
}
function toggleSidebar() {
sidebarCollapsed = !sidebarCollapsed;
const sb = document.getElementById('sidebar');
const float = document.getElementById('floatingToggle');
if (sidebarCollapsed) {
sb.classList.add('collapsed');
float.classList.remove('hidden');
} else {
sb.classList.remove('collapsed');
float.classList.add('hidden');
}
setTimeout(onResize, 420);
}
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1);
if (!isPaused) {
simTime += dt;
const m = Math.floor(simTime/60), s = Math.floor(simTime%60);
document.getElementById('timeDisplay').textContent = `T+ ${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
threats.forEach(t => { if (t.alive) t.update(dt); });
interceptors.forEach(i => { if (i.alive) i.update(dt); });
checkCollisions();
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
p.vel.y += GRAVITY * 1.6 * dt;
p.mesh.position.addScaledVector(p.vel, dt);
p.mesh.scale.setScalar(Math.max(0.1, p.life * 1.8));
if (p.life <= 0) { scene.remove(p.mesh); particles.splice(i,1); }
}
for (let i = launchSmoke.length - 1; i >= 0; i--) {
const s = launchSmoke[i];
s.life -= dt * 1.3;
s.mesh.position.addScaledVector(s.vel, dt);
s.mesh.material.opacity = s.life * 0.65;
s.mesh.scale.setScalar(s.life * 1.6);
if (s.life <= 0) { scene.remove(s.mesh); launchSmoke.splice(i,1); }
}
}
renderer.render(scene, camera);
}
window.addEventListener('keydown', e => {
if (e.key === ' ') togglePause();
if (e.key.toLowerCase() === 'r') resetSim();
if (e.key.toLowerCase() === 'l') launchScenario();
if (e.key.toLowerCase() === 'e') launchInterceptors();
if (e.key.toLowerCase() === 'a') toggleAutoEngage();
if (e.key === 's' || e.key === 'S') toggleSidebar();
});
window.onload = () => {
init();
setTimeout(() => {
document.getElementById('numThreats').value = 5;
updateUI();
launchScenario();
setTimeout(() => {
launchInterceptors();
toggleAutoEngage();
}, 950);
}, 500);
};
</script>
</body>
</html>