Files
Space-Game/combat-hud-2.html
2026-05-25 13:00:20 -04:00

1194 lines
55 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VOID::NAV — Combat HUD</title>
<style>
/* ═══ TOKENS ═══ */
:root {
--bg: #080c14;
--surface: rgba(12, 18, 30, 0.88);
--surface-solid: #0f1623;
--surface-raised: #162032;
--fg: #d4dce8;
--fg-bright: #f1f5f9;
--fg-dim: #94a3b8;
--muted: #5a6b82;
--border: rgba(28, 42, 63, 0.7);
--border-light: rgba(37, 53, 80, 0.5);
--accent: #f0a030;
--accent-bg: rgba(240,160,48,0.08);
--accent-border: rgba(240,160,48,0.25);
--cyan: #22d3ee;
--cyan-bg: rgba(34,211,238,0.08);
--red: #ef4444;
--red-bg: rgba(239,68,68,0.08);
--green: #22c55e;
--green-bg: rgba(34,197,94,0.08);
--purple: #a78bfa;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace;
--font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
--radius: 8px;
--radius-lg: 10px;
--blur: blur(10px);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { font-family: var(--font-body); background: #040810; color: var(--fg); -webkit-font-smoothing: antialiased; }
#root { width: 100%; height: 100%; }
/* ═══ LAYOUT ═══ */
.hud { width: 100%; height: 100%; position: relative; overflow: hidden; }
.hud-viewport { position: absolute; inset: 0; z-index: 0; }
.hud-viewport canvas { display: block; width: 100%; height: 100%; }
.hud-layer {
position: absolute; inset: 0; z-index: 1;
display: grid;
grid-template-rows: 40px 1fr auto;
grid-template-columns: 210px 1fr 240px;
grid-template-areas:
"top top top"
"left center right"
"bottom bottom bottom";
pointer-events: none;
}
.hud-layer > * { pointer-events: auto; }
/* ═══ TOP BAR ═══ */
.topbar {
grid-area: top;
display: flex; align-items: center;
padding: 0 16px; gap: 20px;
background: linear-gradient(180deg, rgba(6,10,18,0.94) 0%, rgba(6,10,18,0.6) 85%, transparent 100%);
font-family: var(--font-mono); font-size: 11px;
}
.topbar-sep { width: 1px; height: 18px; background: var(--border-light); }
.topbar-label { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
.topbar-val { color: var(--fg-dim); font-variant-numeric: tabular-nums; }
.topbar .ship-name-label { font-size: 13px; font-weight: 600; color: var(--fg-bright); font-family: var(--font-body); letter-spacing: -0.01em; }
.topbar .system-label { color: var(--accent); font-weight: 600; }
.topbar .status-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 5px var(--green); }
/* ═══ PANEL BASE ═══ */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
backdrop-filter: var(--blur);
overflow: hidden;
}
.panel-head {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 9px;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted);
}
.panel-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.panel-body { padding: 8px 10px; }
/* ═══ LEFT ═══ */
.hud-left {
grid-area: left;
display: flex; flex-direction: column; gap: 6px;
padding: 6px; pointer-events: auto;
}
/* Health bars */
.hbar { display: flex; align-items: center; gap: 6px; }
.hbar-label { font-family: var(--font-mono); font-size: 9px; color: var(--muted); width: 14px; text-transform: uppercase; letter-spacing: 0.06em; flex-shrink: 0; }
.hbar-track { flex: 1; height: 5px; background: rgba(255,255,255,0.04); border-radius: 999px; overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 999px; transition: width 0.5s cubic-bezier(0.23,1,0.32,1); }
.hbar-pct { font-family: var(--font-mono); font-size: 10px; color: var(--fg-dim); font-variant-numeric: tabular-nums; width: 30px; text-align: right; flex-shrink: 0; }
.shield-fill { background: linear-gradient(90deg, #0891b2, #22d3ee); }
.armor-fill { background: linear-gradient(90deg, #b47818, #f0a030); }
.hull-fill { background: linear-gradient(90deg, #16a34a, #22c55e); }
.cap-fill { background: linear-gradient(90deg, #6366f1, #a78bfa); }
.cap-fill.low { background: linear-gradient(90deg, #dc2626, #ef4444); }
/* Speed */
.speed-num { text-align: center; font-family: var(--font-mono); font-size: 20px; font-weight: 700; color: var(--fg-bright); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.speed-unit { font-size: 9px; font-weight: 400; color: var(--muted); margin-left: 3px; letter-spacing: 0.06em; }
/* ═══ CENTER ═══ */
.hud-center {
grid-area: center;
pointer-events: none;
display: flex; align-items: center; justify-content: center;
position: relative;
}
/* Crosshair */
.crosshair { width: 50px; height: 50px; position: relative; opacity: 0.3; }
.crosshair::before, .crosshair::after { content: ''; position: absolute; background: var(--fg-dim); }
.crosshair::before { width: 1px; height: 16px; left: 50%; top: 0; transform: translateX(-50%); }
.crosshair::after { width: 1px; height: 16px; left: 50%; bottom: 0; transform: translateX(-50%); }
.ch-ring { width: 36px; height: 36px; border: 1px solid rgba(212,220,232,0.25); border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); }
.ch-h::before { content: ''; position: absolute; background: var(--fg-dim); height: 1px; width: 16px; top: 50%; left: 0; transform: translateY(-50%); }
.ch-h::after { content: ''; position: absolute; background: var(--fg-dim); height: 1px; width: 16px; top: 50%; right: 0; transform: translateY(-50%); }
/* Lock progress — center of screen */
.lock-hud {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
text-align: center; pointer-events: none;
}
.lock-hud .lock-ring {
width: 120px; height: 120px; border: 2px solid var(--accent-border);
border-radius: 50%; margin: 0 auto 8px; position: relative;
}
.lock-hud .lock-fill-ring {
position: absolute; inset: -2px;
border-radius: 50%;
border: 2px solid var(--accent);
clip-path: polygon(50% 0%, 50% 50%, 100% 0%, 100% 100%, 0% 100%, 0% 0%);
transition: clip-path 0.1s;
}
.lock-hud .lock-text {
font-family: var(--font-mono); font-size: 11px; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.1em;
}
/* ═══ RIGHT ═══ */
.hud-right {
grid-area: right;
display: flex; flex-direction: column; gap: 6px;
padding: 6px; pointer-events: auto;
}
/* Subsystem target grid */
.subsys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.subsys-btn {
padding: 5px 6px; border-radius: 4px;
border: 1px solid var(--border); background: var(--surface-solid);
cursor: pointer; text-align: left; transition: all 0.15s;
}
.subsys-btn:hover { border-color: var(--border-light); }
.subsys-btn.active { background: rgba(240,160,48,0.06); }
.subsys-btn .ss-label { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; display: block; margin-bottom: 3px; }
.subsys-btn .ss-bar { height: 3px; background: rgba(255,255,255,0.04); border-radius: 999px; overflow: hidden; }
.subsys-btn .ss-fill { height: 100%; border-radius: 999px; transition: width 0.3s; }
/* ═══ BOTTOM ═══ */
.hud-bottom {
grid-area: bottom;
display: flex; gap: 6px;
padding: 0 6px 6px;
}
/* Ability bar */
.ability-bar {
display: flex; gap: 4px; align-items: flex-end;
padding: 8px 10px;
}
.ability-btn {
width: 58px; height: 48px; border-radius: 6px;
border: 1.5px solid var(--border); background: var(--surface-solid);
cursor: pointer; position: relative; overflow: hidden;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1px; transition: all 0.12s;
}
.ability-btn:hover { border-color: var(--border-light); }
.ability-btn.can-cast { border-color: var(--accent-border); background: rgba(240,160,48,0.04); cursor: pointer; }
.ability-btn.can-cast:hover { border-color: var(--accent); }
.ability-btn .ab-icon { font-size: 13px; position: relative; z-index: 1; }
.ability-btn .ab-key { font-family: var(--font-mono); font-size: 9px; color: var(--muted); position: relative; z-index: 1; }
.ability-btn .ab-cost { position: absolute; bottom: 2px; right: 3px; font-family: var(--font-mono); font-size: 8px; color: var(--muted); z-index: 1; }
.ability-btn .cd-overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.6); transition: height 0.1s; z-index: 0;
}
.ability-btn .no-nrg { position: absolute; inset: 0; background: rgba(80,15,15,0.3); z-index: 0; }
.ability-btn.can-cast .ab-key { color: var(--accent); }
/* Reactor power */
.reactor-bar {
display: flex; flex-direction: column; gap: 5px;
padding: 8px 10px;
}
.reactor-row {
display: flex; align-items: center; gap: 4px;
}
.reactor-label {
font-family: var(--font-mono); font-size: 8px; text-transform: uppercase;
letter-spacing: 0.08em; width: 10px; flex-shrink: 0; text-align: center;
}
.reactor-bars { display: flex; gap: 2px; flex: 1; }
.reactor-bar-seg { flex: 1; height: 7px; border-radius: 2px; background: rgba(255,255,255,0.04); transition: background 0.15s; }
.reactor-pm {
width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border);
background: var(--surface-raised); color: var(--fg-dim); font-size: 10px;
cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1;
transition: all 0.1s;
}
.reactor-pm:hover { border-color: var(--accent-border); color: var(--accent); }
.reactor-desc { font-family: var(--font-mono); font-size: 8px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 80px; }
/* Combat log */
.combat-log { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.log-scroll {
flex: 1; overflow-y: auto; max-height: 90px; padding: 4px 8px;
font-family: var(--font-mono); font-size: 10px;
}
.log-scroll::-webkit-scrollbar { width: 3px; }
.log-scroll::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
.log-entry { display: flex; gap: 6px; margin-bottom: 1px; line-height: 1.4; }
.log-time { color: var(--muted); flex-shrink: 0; }
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Engage button */
.engage-btn {
padding: 8px 24px; border-radius: 6px;
border: 1.5px solid var(--accent-border);
background: var(--accent-bg); color: var(--accent);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.1em;
cursor: pointer; transition: all 0.15s;
}
.engage-btn:hover { background: rgba(240,160,48,0.15); border-color: var(--accent); }
/* Status pills */
.buff-pill {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 6px; border-radius: 3px;
font-family: var(--font-mono); font-size: 9px;
}
/* Overload flash */
@keyframes overload-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.overload-tag { color: var(--accent); font-weight: 700; animation: overload-pulse 0.8s infinite; }
/* Target locked flash */
@keyframes lock-flash { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }
/* Center engage prompt */
.engage-prompt {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
text-align: center; pointer-events: auto;
}
.engage-prompt p { color: var(--fg-dim); font-size: 13px; margin-bottom: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"
integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"
integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"
integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"
crossorigin="anonymous"></script>
<!-- Three.js (UMD global) -->
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
/* ═══════════════════════════════════════════════════════════
THREE.JS HELPERS (inline — no external deps)
═══════════════════════════════════════════════════════════ */
const TH = {
createRenderer(container, opts = {}) {
const r = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
r.setPixelRatio(window.devicePixelRatio);
r.setSize(container.clientWidth, container.clientHeight);
r.setClearColor(opts.clearColor || 0x040810, 1);
r.toneMapping = THREE.ACESFilmicToneMapping;
r.toneMappingExposure = 1.0;
container.appendChild(r.domElement);
r.domElement.style.display = 'block';
return r;
},
handleResize(r, cam, el) {
const w = el.clientWidth, h = el.clientHeight;
r.setSize(w, h);
cam.aspect = w / h;
cam.updateProjectionMatrix();
},
createStarField(count = 2500, spread = 2000) {
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
pos[i*3] = (Math.random()-0.5)*spread;
pos[i*3+1] = (Math.random()-0.5)*spread;
pos[i*3+2] = (Math.random()-0.5)*spread;
const b = 0.4+Math.random()*0.6;
col[i*3]=b; col[i*3+1]=b; col[i*3+2]=b+Math.random()*0.15;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
geo.setAttribute('color', new THREE.BufferAttribute(col,3));
return new THREE.Points(geo, new THREE.PointsMaterial({ size:1.5, vertexColors:true, transparent:true, opacity:0.8, sizeAttenuation:true }));
},
addNebula(scene, color, pos, scale) {
const m = new THREE.Mesh(
new THREE.SphereGeometry(1, 16, 16),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.025 })
);
m.position.set(...pos);
m.scale.setScalar(scale);
scene.add(m);
return m;
},
createShipMesh(bodyColor, accentColor, scale = 1) {
const group = new THREE.Group();
// Fuselage
const bodyGeo = new THREE.ConeGeometry(0.5*scale, 3*scale, 6);
bodyGeo.rotateX(Math.PI/2);
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.6, roughness: 0.3, emissive: accentColor, emissiveIntensity: 0.1 });
group.add(new THREE.Mesh(bodyGeo, bodyMat));
// Wings
const wingGeo = new THREE.BoxGeometry(3*scale, 0.05*scale, 1*scale);
const wingMat = new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.5, roughness: 0.4 });
const wings = new THREE.Mesh(wingGeo, wingMat);
wings.position.z = 0.5*scale;
group.add(wings);
return group;
},
createEngineGlow(color, size, intensity) {
const light = new THREE.PointLight(color, intensity, size*5);
return light;
},
createShield(radius, color, opacity) {
const geo = new THREE.SphereGeometry(radius, 24, 24);
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false });
return new THREE.Mesh(geo, mat);
},
createLockBrackets(size, color) {
const group = new THREE.Group();
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 });
const s = size;
const bLen = s * 0.3;
// Four corner brackets
[[-1,-1],[1,-1],[1,1],[-1,1]].forEach(([sx,sy]) => {
const pts = [
new THREE.Vector3(sx*s, sy*(s-bLen), 0),
new THREE.Vector3(sx*s, sy*s, 0),
new THREE.Vector3(sx*(s-bLen), sy*s, 0),
];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
group.add(new THREE.Line(geo, mat));
});
return group;
},
createGlowSprite(color, size) {
const canvas = document.createElement('canvas');
canvas.width = 64; canvas.height = 64;
const ctx = canvas.getContext('2d');
const hex = '#' + new THREE.Color(color).getHexString();
const grad = ctx.createRadialGradient(32,32,0,32,32,32);
grad.addColorStop(0, hex);
grad.addColorStop(0.3, hex + '88');
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.fillRect(0,0,64,64);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.setScalar(size);
return sprite;
},
setupSpaceLighting(scene) {
scene.add(new THREE.AmbientLight(0x304060, 0.6));
const dir = new THREE.DirectionalLight(0xffeedd, 1.2);
dir.position.set(50, 30, 20);
scene.add(dir);
const fill = new THREE.DirectionalLight(0x4488cc, 0.3);
fill.position.set(-30, -10, -20);
scene.add(fill);
},
};
/* ═══════════════════════════════════════════════════════════
PROJECTILE POOL
═══════════════════════════════════════════════════════════ */
function createProjectilePool(scene) {
const pool = [];
return {
spawn(x,y,z, tx,ty,tz, color, dmg, type, subsystem) {
let mesh;
if (type === 'beam') {
const dx=tx-x, dy=ty-y, dz=tz-z;
const len = Math.sqrt(dx*dx+dy*dy+dz*dz);
const geo = new THREE.CylinderGeometry(0.06, 0.06, len, 4);
geo.rotateX(Math.PI/2);
mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }));
mesh.position.set((x+tx)/2,(y+ty)/2,(z+tz)/2);
mesh.lookAt(tx,ty,tz);
mesh.userData = { life:6, maxLife:6, type, dmg, subsystem, tx,ty,tz, color };
} else if (type === 'pulse') {
const geo = new THREE.RingGeometry(1,2,24);
mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.7, side:THREE.DoubleSide }));
mesh.position.set(x,y,z);
mesh.userData = { life:15, maxLife:15, type, dmg:0, subsystem:'none', scale:1 };
} else {
mesh = new THREE.Mesh(
new THREE.SphereGeometry(type==='missile'?0.18:0.1, 4, 4),
new THREE.MeshBasicMaterial({ color })
);
const dx=tx-x, dy=ty-y, dz=tz-z;
const dist = Math.sqrt(dx*dx+dy*dy+dz*dz);
const speed = type==='missile'?1.5:2.5;
mesh.position.set(x,y,z);
mesh.userData = { vx:(dx/dist)*speed, vy:(dy/dist)*speed, vz:(dz/dist)*speed, life:Math.ceil(dist/speed), maxLife:Math.ceil(dist/speed), type, dmg, subsystem, tx,ty,tz, color };
}
scene.add(mesh);
pool.push(mesh);
},
tick() {
for (let i=pool.length-1; i>=0; i--) {
const m=pool[i], ud=m.userData;
ud.life--;
if (ud.type==='beam'||ud.type==='pulse') {
m.material.opacity = Math.max(0, ud.life/ud.maxLife);
if (ud.type==='pulse') { ud.scale+=0.15; m.scale.setScalar(ud.scale); }
} else {
m.position.x+=ud.vx; m.position.y+=ud.vy; m.position.z+=ud.vz;
}
if (ud.life<=0) { scene.remove(m); m.geometry.dispose(); m.material.dispose(); pool.splice(i,1); }
}
},
getArrived() { return pool.filter(m => m.userData.life<=1); },
clear() { pool.forEach(m => { scene.remove(m); m.geometry.dispose(); m.material.dispose(); }); pool.length=0; },
};
}
/* ═══════════════════════════════════════════════════════════
IMPACT FLASH POOL
═══════════════════════════════════════════════════════════ */
function createImpactPool(scene) {
const impacts = [];
return {
spawn(x,y,z, color, size) {
const glow = TH.createGlowSprite(color, size||3);
glow.position.set(x,y,z);
scene.add(glow);
impacts.push({ mesh:glow, life:8, maxLife:8 });
},
tick() {
for (let i=impacts.length-1; i>=0; i--) {
impacts[i].life--;
const p = 1-impacts[i].life/impacts[i].maxLife;
impacts[i].mesh.material.opacity = (1-p)*0.8;
impacts[i].mesh.scale.setScalar((impacts[i].mesh.scale.x||3)*(1+p*0.5));
if (impacts[i].life<=0) { scene.remove(impacts[i].mesh); impacts[i].mesh.material.map?.dispose(); impacts[i].mesh.material.dispose(); impacts.splice(i,1); }
}
},
};
}
/* ═══════════════════════════════════════════════════════════
CONSTANTS
═══════════════════════════════════════════════════════════ */
const MAX_POWER = 8;
const TICK_MS = 50;
/* ═══════════════════════════════════════════════════════════
COMBAT HUD COMPONENT
═══════════════════════════════════════════════════════════ */
function CombatHUD() {
const containerRef = useRef(null);
const sceneRef = useRef(null);
const animIdRef = useRef(null);
const tickRef = useRef(0);
const projRef = useRef(null);
const impRef = useRef(null);
const playerShipRef = useRef(null);
const enemyShipRef = useRef(null);
const playerShieldRef = useRef(null);
const enemyLockRef = useRef(null);
const targetLineRef = useRef(null);
const playerPosRef = useRef({ orbitAngle: 0 });
const enemyPosRef = useRef({ orbitAngle: Math.PI });
const autoFireRef = useRef(0);
const enemyFireRef = useRef(0);
// ── State ──
const [player, setPlayer] = useState({ shields:100, armor:100, hull:100, energy:100, maxEnergy:100, speed:0, maxSpeed:420 });
const playerRef = useRef(player);
useEffect(() => { playerRef.current = player; }, [player]);
const [power, setPower] = useState({ weapons:3, shields:2, engines:2, aux:1 });
const powerRef = useRef(power);
useEffect(() => { powerRef.current = power; }, [power]);
const [modules, setModules] = useState([
{ id:'q', key:'1', name:'Railgun', icon:'⊕', type:'weapon', cd:0, maxCd:3, cost:12, damage:18, desc:'Kinetic turret', color:'#ef4444' },
{ id:'w', key:'2', name:'Shield Bst', icon:'◎', type:'shield', cd:0, maxCd:8, cost:20, damage:0, desc:'Burst recharge', color:'#22d3ee' },
{ id:'e', key:'3', name:'EM Pulse', icon:'⟐', type:'ewar', cd:0, maxCd:12, cost:30, damage:0, desc:'Disrupt systems', color:'#a78bfa' },
{ id:'r', key:'4', name:'Overload', icon:'⚡', type:'reactor', cd:0, maxCd:30, cost:45, damage:0, desc:'Push reactor', color:'#f0a030' },
{ id:'d', key:'5', name:'Afterburn', icon:'»', type:'engine', cd:0, maxCd:6, cost:10, damage:0, desc:'Emergency thrust', color:'#22c55e' },
{ id:'f', key:'6', name:'Hull Patch', icon:'✚', type:'repair', cd:0, maxCd:15, cost:25, damage:0, desc:'Nanite repair', color:'#fb923c' },
]);
const modulesRef = useRef(modules);
useEffect(() => { modulesRef.current = modules; }, [modules]);
const [playerBuffs, setPlayerBuffs] = useState([{ id:'b1', name:'Dmg Ctrl', icon:'↯', duration:-1, color:'#22c55e' }]);
const [enemyBuffs, setEnemyBuffs] = useState([]);
const [target, setTarget] = useState(null);
const [subsystem, setSubsystem] = useState('hull');
const [enemy, setEnemy] = useState({
name:'Guristas Pirata', class:'Frigate', shields:100, armor:100, hull:100,
weapons:100, engines:100, locked:false, lockTimer:0, lockTime:3,
});
const enemyRef = useRef(enemy);
useEffect(() => { enemyRef.current = enemy; }, [enemy]);
const targetRef = useRef(target);
useEffect(() => { targetRef.current = target; }, [target]);
const subsystemRef = useRef(subsystem);
useEffect(() => { subsystemRef.current = subsystem; }, [subsystem]);
const [overloaded, setOverloaded] = useState(false);
const overloadRef = useRef(false);
const [combatLog, setCombatLog] = useState([]);
const logRef = useRef([]);
const logScrollRef = useRef(null);
const addLog = useCallback((msg, color) => {
const time = new Date().toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
logRef.current = [...logRef.current.slice(-50), {time,msg,color}];
setCombatLog(logRef.current);
}, []);
// ── Derived stats ──
const totalPower = power.weapons+power.shields+power.engines+power.aux;
const weaponMult = 1+power.weapons*0.20;
const shieldRegen = power.shields*1.2;
const shieldAbsorb = 0.4+power.shields*0.08;
const dodgeChance = power.engines*4;
const speedMult = 1+power.engines*0.25;
const cdReduction = power.aux*6;
const energyRegen = 2+power.aux*2;
// ── Build 3D scene ──
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x040810, 0.0008);
const camera = new THREE.PerspectiveCamera(55, container.clientWidth/container.clientHeight, 0.1, 3000);
camera.position.set(0, 30, 55);
camera.lookAt(0, 0, 0);
const renderer = TH.createRenderer(container, { clearColor:0x040810 });
TH.handleResize(renderer, camera, container);
const stars = TH.createStarField(3000, 2000);
scene.add(stars);
TH.addNebula(scene, 0x22d3ee, [-100,40,-200], 200);
TH.addNebula(scene, 0xa78bfa, [80,-30,-150], 150);
const grid = new THREE.GridHelper(300, 15, 0x0d1520, 0x0d1520);
grid.material.transparent = true; grid.material.opacity = 0.12;
scene.add(grid);
TH.setupSpaceLighting(scene);
// Player ship
const pGroup = new THREE.Group();
const pMesh = TH.createShipMesh(0xc8d6e5, 0xf0a030, 0.6);
pMesh.rotation.y = Math.PI/2;
pGroup.add(pMesh);
const pEngine = TH.createEngineGlow(0x22d3ee, 3, 15);
pEngine.position.set(0,0,6);
pGroup.add(pEngine);
const pShield = TH.createShield(3, 0x22d3ee, 0.06);
pGroup.add(pShield);
playerShieldRef.current = pShield;
pGroup.position.set(-15,0,0);
scene.add(pGroup);
playerShipRef.current = pGroup;
// Enemy ship
const eGroup = new THREE.Group();
const eMesh = TH.createShipMesh(0x7f1d1d, 0xef4444, 0.5);
eMesh.rotation.y = Math.PI/2;
eGroup.add(eMesh);
const eEngine = TH.createEngineGlow(0xef4444, 2, 10);
eEngine.position.set(0,0,5);
eGroup.add(eEngine);
eGroup.position.set(15,0,0);
scene.add(eGroup);
enemyShipRef.current = eGroup;
// Target line
const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(1,0,0)]);
const lineMat = new THREE.LineDashedMaterial({ color:0xf0a030, dashSize:1, gapSize:0.5, transparent:true, opacity:0.2 });
const targetLine = new THREE.Line(lineGeo, lineMat);
targetLine.computeLineDistances();
targetLine.visible = false;
scene.add(targetLine);
targetLineRef.current = targetLine;
// Lock brackets
const lockB = TH.createLockBrackets(3, 0xf0a030);
lockB.visible = false;
scene.add(lockB);
enemyLockRef.current = lockB;
const projPool = createProjectilePool(scene);
projRef.current = projPool;
const impPool = createImpactPool(scene);
impRef.current = impPool;
sceneRef.current = { scene, camera, renderer, stars, pGroup, eGroup, pEngine, eEngine, lockB, pMesh };
const clock = new THREE.Clock();
const animate = () => {
animIdRef.current = requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const pwr = powerRef.current;
const eng = enemyRef.current;
const tgt = targetRef.current;
// Player orbit
const pp = playerPosRef.current;
pp.orbitAngle += 0.008*(1+pwr.engines*0.25);
const pr = 12+pwr.engines*1.5;
pGroup.position.x = -pr*Math.cos(pp.orbitAngle);
pGroup.position.z = pr*Math.sin(pp.orbitAngle)*0.6;
pGroup.position.y = Math.sin(pp.orbitAngle*1.3)*2;
if (tgt && eng.locked) pGroup.lookAt(eGroup.position);
pEngine.intensity = 2+pwr.engines*0.5+(overloadRef.current?3:0);
if (overloadRef.current) {
pMesh.material.emissive.setHex(0xfbbf24);
pMesh.material.emissiveIntensity = 0.3+Math.sin(t*8)*0.15;
} else {
pMesh.material.emissive.setHex(0xf0a030);
pMesh.material.emissiveIntensity = 0.15;
}
pShield.material.opacity = 0.03+pwr.shields*0.015;
pShield.scale.setScalar(1+pwr.shields*0.05);
// Enemy orbit
const ep = enemyPosRef.current;
const eScale = eng.engines/100;
ep.orbitAngle += 0.01*eScale;
const er = 14*eScale;
eGroup.position.x = 15+er*Math.cos(ep.orbitAngle);
eGroup.position.z = er*Math.sin(ep.orbitAngle)*0.5;
eGroup.position.y = Math.sin(ep.orbitAngle*1.1)*1.5*eScale;
eEngine.intensity = 1.5*eScale;
if (tgt) eGroup.lookAt(pGroup.position);
if (lockB.visible) { lockB.position.copy(eGroup.position); lockB.rotation.y = t*0.3; }
if (targetLine.visible && tgt && eng.locked) {
const pPos=pGroup.position, ePos=eGroup.position;
const positions = targetLine.geometry.attributes.position;
positions.setXYZ(0, pPos.x, pPos.y, pPos.z);
positions.setXYZ(1, ePos.x, ePos.y, ePos.z);
positions.needsUpdate = true;
targetLine.computeLineDistances();
}
stars.rotation.y = t*0.003;
projPool.tick();
impPool.tick();
renderer.render(scene, camera);
};
animate();
const onResize = () => TH.handleResize(renderer, camera, container);
window.addEventListener('resize', onResize);
return () => {
if (animIdRef.current) cancelAnimationFrame(animIdRef.current);
window.removeEventListener('resize', onResize);
projPool.clear();
};
}, []);
// Auto-scroll log
useEffect(() => {
if (logScrollRef.current) logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
}, [combatLog]);
// ── Lock Target ──
const lockTarget = useCallback(() => {
addLog('Initiating target lock…', '#f0a030');
setEnemy(prev => ({ ...prev, lockTimer:0, locked:false }));
setTarget('Guristas Pirata');
if (targetLineRef.current) targetLineRef.current.visible = true;
}, [addLog]);
// ── Adjust Power ──
const adjustPower = useCallback((system, delta) => {
setPower(prev => {
const nv = prev[system]+delta;
if (nv < 0) return prev;
const nt = Object.entries(prev).reduce((s,[k,v]) => s+(k===system?nv:v), 0);
if (nt > MAX_POWER) return prev;
return { ...prev, [system]:nv };
});
}, []);
// ── Cast Module ──
const castModule = useCallback((abilityId) => {
const ab = modulesRef.current.find(a => a.id===abilityId);
if (!ab) return;
if (!targetRef.current) { addLog('No target locked.', '#ef4444'); return; }
if (!enemyRef.current.locked) { addLog('Target not locked yet.', '#ef4444'); return; }
if (ab.cd > 0) { addLog(`${ab.name} on cooldown (${ab.cd.toFixed(1)}s).`, '#ef4444'); return; }
const pwr = powerRef.current;
const pl = playerRef.current;
if (pl.energy < ab.cost) { addLog('Insufficient energy.', '#ef4444'); return; }
setPlayer(prev => ({ ...prev, energy: prev.energy-ab.cost }));
const actualCd = ab.maxCd*(1-pwr.aux*0.06);
setModules(prev => prev.map(a => a.id===abilityId ? { ...a, cd:Math.max(0.5,actualCd) } : a));
const pPos = playerShipRef.current?.position || {x:-15,y:0,z:0};
const ePos = enemyShipRef.current?.position || {x:15,y:0,z:0};
const proj = projRef.current;
const imp = impRef.current;
if (ab.id==='q') {
const dmg = ab.damage*weaponMult;
proj.spawn(pPos.x+3, pPos.y, pPos.z, ePos.x, ePos.y, ePos.z, 0xef4444, dmg, 'beam', subsystemRef.current);
addLog(`Railgun → ${subsystemRef.current.toUpperCase()} (×${weaponMult.toFixed(1)})`, '#ef4444');
}
if (ab.id==='w') {
const restore = 15+pwr.shields*6;
setPlayer(prev => ({ ...prev, shields:Math.min(100, prev.shields+restore) }));
imp.spawn(pPos.x, pPos.y, pPos.z, 0x22d3ee, 5);
addLog(`Shield Boost: +${Math.round(restore)}%`, '#22d3ee');
}
if (ab.id==='e') {
const dur = 3+pwr.aux*0.5;
const mid = {x:(pPos.x+ePos.x)/2, y:(pPos.y+ePos.y)/2, z:(pPos.z+ePos.z)/2};
proj.spawn(mid.x, mid.y, mid.z, ePos.x, ePos.y, ePos.z, 0xa78bfa, 0, 'pulse', 'none');
setEnemyBuffs([{ id:'emp', name:'EM Disrupted', icon:'⟐', duration:Math.round(dur), color:'#a78bfa' }]);
setEnemy(prev => ({ ...prev, weapons:Math.max(0,prev.weapons-15-pwr.aux*3), engines:Math.max(0,prev.engines-10-pwr.aux*2) }));
addLog(`EM Pulse! Enemy disrupted ${dur.toFixed(1)}s`, '#a78bfa');
setTimeout(() => { setEnemyBuffs([]); addLog('Enemy systems restored.', '#5a6b82'); }, dur*1000);
}
if (ab.id==='r') {
overloadRef.current = true;
setOverloaded(true);
setPlayerBuffs(prev => [...prev, { id:'overload', name:'Overload', icon:'⚡', duration:8, color:'#f0a030' }]);
addLog('⚡ OVERLOAD — double fire rate 8s!', '#f0a030');
setTimeout(() => { overloadRef.current=false; setOverloaded(false); setPlayerBuffs(prev => prev.filter(b=>b.id!=='overload')); addLog('Overload ended.', '#5a6b82'); }, 8000);
}
if (ab.id==='d') {
playerPosRef.current.speed = 300*speedMult;
addLog(`Afterburners! Speed ×${speedMult.toFixed(1)}`, '#22c55e');
setTimeout(() => { playerPosRef.current.speed=0; }, 2500);
}
if (ab.id==='f') {
const repair = 12+pwr.aux*4;
setPlayer(prev => ({ ...prev, hull:Math.min(100, prev.hull+repair) }));
imp.spawn(pPos.x, pPos.y, pPos.z, 0xfb923c, 6);
addLog(`Hull Patch: +${Math.round(repair)}%`, '#fb923c');
}
}, [addLog, weaponMult, speedMult]);
// ── Keyboard ──
useEffect(() => {
const handler = (e) => {
const key = e.key.toLowerCase();
if (['1','2','3','4','5','6'].includes(key)) { e.preventDefault(); castModule(key); }
if (key===' ' && !targetRef.current) { e.preventDefault(); lockTarget(); }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [castModule, lockTarget]);
// ── Combat Tick ──
useEffect(() => {
const interval = setInterval(() => {
tickRef.current++;
const pwr = powerRef.current;
const eng = enemyRef.current;
const sub = subsystemRef.current;
const tgt = targetRef.current;
const tps = 1000/TICK_MS;
// Cooldown tick
setModules(prev => prev.map(a => ({ ...a, cd:Math.max(0, a.cd-TICK_MS/1000) })));
// Energy regen
const eRegen = (2+pwr.aux*2)/tps;
const eDrain = overloadRef.current ? (3+pwr.weapons*0.5)/tps : 0;
setPlayer(prev => ({ ...prev, energy:Math.min(prev.maxEnergy, Math.max(0, prev.energy+eRegen-eDrain)) }));
// Shield regen
setPlayer(prev => {
if (prev.shields<100) return { ...prev, shields:Math.min(100, prev.shields+(pwr.shields*1.2)/tps) };
return prev;
});
const proj = projRef.current;
const imp = impRef.current;
const pPos = playerShipRef.current?.position;
const ePos = enemyShipRef.current?.position;
if (!pPos||!ePos) return;
// Auto-fire
if (tgt && eng.locked && eng.hull>0) {
const baseInterval = overloadRef.current ? 15 : 40;
autoFireRef.current++;
if (autoFireRef.current >= baseInterval) {
autoFireRef.current = 0;
const bullets = Math.max(1, Math.floor(pwr.weapons/2));
const dmgPerBullet = (3+pwr.weapons*1.5)/bullets;
for (let b=0; b<bullets; b++) {
const jx=ePos.x+(Math.random()-0.5)*2;
const jy=ePos.y+(Math.random()-0.5)*1;
const jz=ePos.z+(Math.random()-0.5)*2;
proj.spawn(pPos.x+2, pPos.y, pPos.z, jx, jy, jz, overloadRef.current?0xfbbf24:0xf0a030, dmgPerBullet, 'bullet', sub);
}
}
enemyFireRef.current++;
if (enemyFireRef.current>=30 && eng.weapons>10) {
enemyFireRef.current = 0;
const enemyDmg = 4*(eng.weapons/100);
if (Math.random()<pwr.engines*0.04) {
proj.spawn(ePos.x-1, ePos.y, ePos.z, pPos.x+(Math.random()-0.5)*4, pPos.y+(Math.random()-0.5)*2, pPos.z+(Math.random()-0.5)*4, 0xef4444, 0, 'bullet', 'none');
} else {
proj.spawn(ePos.x-1, ePos.y, ePos.z, pPos.x, pPos.y, pPos.z, 0xef4444, enemyDmg, 'bullet', 'hull');
}
}
}
// Target lock progress
if (tgt && !eng.locked && eng.lockTimer<eng.lockTime) {
setEnemy(prev => {
const nt = prev.lockTimer+TICK_MS/1000;
if (nt>=prev.lockTime) {
addLog('★ TARGET LOCKED', '#22c55e');
if (enemyLockRef.current) enemyLockRef.current.visible = true;
return { ...prev, locked:true, lockTimer:nt };
}
return { ...prev, lockTimer:nt };
});
}
// Process hits
const arrived = proj.getArrived();
for (const p of arrived) {
if (p.userData.dmg<=0) continue;
const isPlayer = p.userData.color===0xf0a030||p.userData.color===0xfbbf24;
if (isPlayer) {
imp.spawn(p.userData.tx, p.userData.ty, p.userData.tz, 0xef4444, 2);
setEnemy(prev => {
let ne = {...prev};
const d = p.userData.dmg;
if (p.userData.subsystem==='shields') { ne.shields=Math.max(0,ne.shields-d); }
else if (p.userData.subsystem==='hull') {
if (ne.shields>0) ne.shields=Math.max(0,ne.shields-d*0.4);
else { ne.armor=Math.max(0,ne.armor-d*0.5); ne.hull=Math.max(0,ne.hull-d*0.5); }
} else if (p.userData.subsystem==='weapons') { ne.weapons=Math.max(0,ne.weapons-d); }
else if (p.userData.subsystem==='engines') { ne.engines=Math.max(0,ne.engines-d); }
return ne;
});
} else {
imp.spawn(p.userData.tx, p.userData.ty, p.userData.tz, 0xef4444, 2);
setPlayer(prev => {
let np = {...prev};
const d = p.userData.dmg;
const absorb = 0.4+pwr.shields*0.08;
if (np.shields>0) { const ab=Math.min(d*absorb,np.shields); np.shields=Math.max(0,np.shields-ab); const bl=d-ab; if(bl>0) np.armor=Math.max(0,np.armor-bl*0.5); }
else if (np.armor>0) { np.armor=Math.max(0,np.armor-d*0.6); np.hull=Math.max(0,np.hull-d*0.4); }
else { np.hull=Math.max(0,np.hull-d); }
return np;
});
}
}
// Buff duration tick
if (tickRef.current%Math.round(tps)===0) {
setPlayerBuffs(prev => prev.map(b => b.duration>0?{...b,duration:b.duration-1}:b).filter(b=>b.duration!==0));
}
}, TICK_MS);
return () => clearInterval(interval);
}, [addLog]);
/* ═══ RENDER ═══ */
const lockPct = target && !enemy.locked ? Math.min(100, (enemy.lockTimer/enemy.lockTime)*100) : 0;
return (
<div className="hud">
{/* 3D Viewport */}
<div ref={containerRef} className="hud-viewport" />
{/* HUD Layer */}
<div className="hud-layer">
{/* ── TOP BAR ── */}
<div className="topbar">
<span className="system-label">Jita</span>
<span style={{fontFamily:'var(--font-mono)',fontSize:'10px',padding:'1px 7px',borderRadius:'999px',fontWeight:600,background:'rgba(34,197,94,0.08)',color:'var(--green)',border:'1px solid rgba(34,197,94,0.3)'}}>1.0 HIGH</span>
<div className="topbar-sep" />
<span className="topbar-label">Ship</span>
<span className="topbar-val" style={{color:'var(--fg-bright)',fontWeight:600}}>USS ENTERPRISE</span>
<div className="topbar-sep" />
<span className="topbar-label">Class</span>
<span className="topbar-val">VENTURE-CLASS</span>
<div className="topbar-sep" />
<span className="topbar-label">Speed</span>
<span className="topbar-val">{player.speed.toFixed(0)} m/s</span>
<div style={{flex:1}} />
{overloaded && <span className="overload-tag"> OVERLOAD</span>}
{target && enemy.locked && <span style={{color:'var(--red)',fontFamily:'var(--font-mono)',fontSize:'10px',fontWeight:600}}> TARGET LOCKED</span>}
<div className="topbar-sep" />
<span style={{display:'flex',alignItems:'center',gap:'5px'}}>
<span className="status-dot" />
<span className="topbar-val" style={{color:'var(--green)'}}>ONLINE</span>
</span>
</div>
{/* ── LEFT — Ship Status ── */}
<div className="hud-left">
{/* Health */}
<div className="panel">
<div className="panel-head"><span className="panel-dot" />Ship Status</div>
<div className="panel-body">
{[
{label:'SH', value:player.shields, fill:'shield-fill', color:'#22d3ee'},
{label:'AR', value:player.armor, fill:'armor-fill', color:'#f0a030'},
{label:'HU', value:player.hull, fill:'hull-fill', color:'#22c55e'},
{label:'NRG',value:player.energy, fill:player.energy>25?'cap-fill':'cap-fill low', color:player.energy>25?'#a78bfa':'#ef4444'},
].map(b => (
<div className="hbar" key={b.label} style={{marginBottom:'6px'}}>
<span className="hbar-label" style={{color:b.color}}>{b.label}</span>
<div className="hbar-track">
<div className={`hbar-fill ${b.fill}`} style={{width:`${b.value}%`}} />
</div>
<span className="hbar-pct" style={{color:b.color}}>{b.label==='NRG'?Math.round(b.value):b.value.toFixed(0)}</span>
</div>
))}
</div>
</div>
{/* Reactor — FTL Power */}
<div className="panel" style={{flex:1}}>
<div className="panel-head">
<span className="panel-dot" />Reactor
<span style={{marginLeft:'auto',fontSize:'8px',color:totalPower<MAX_POWER?'var(--green)':'var(--fg-dim)'}}>{totalPower}/{MAX_POWER}</span>
</div>
<div className="panel-body" style={{padding:'6px 10px'}}>
{[
{key:'weapons', label:'WPN', color:'#ef4444'},
{key:'shields', label:'SHD', color:'#22d3ee'},
{key:'engines', label:'ENG', color:'#22c55e'},
{key:'aux', label:'AUX', color:'#a78bfa'},
].map(sys => (
<div className="reactor-row" key={sys.key} style={{marginBottom:'4px'}}>
<span className="reactor-label" style={{color:sys.color}}>{sys.label}</span>
<button className="reactor-pm" onClick={()=>adjustPower(sys.key,-1)}></button>
<div className="reactor-bars">
{Array.from({length:MAX_POWER},(_,i)=>(
<div key={i} className="reactor-bar-seg" style={{background:i<power[sys.key]?sys.color:'rgba(255,255,255,0.04)'}} />
))}
</div>
<button className="reactor-pm" onClick={()=>adjustPower(sys.key,1)}>+</button>
</div>
))}
<div style={{fontFamily:'var(--font-mono)',fontSize:'8px',color:'var(--muted)',marginTop:'4px',lineHeight:1.5}}>
<span style={{color:'#ef4444'}}>WPN</span> ×{weaponMult.toFixed(1)} dmg
{' · '}
<span style={{color:'#22d3ee'}}>SHD</span> {shieldRegen.toFixed(0)}/s
{' · '}
<span style={{color:'#22c55e'}}>ENG</span> {dodgeChance}% dodge
{' · '}
<span style={{color:'#a78bfa'}}>AUX</span> {energyRegen.toFixed(0)} NRG/s
</div>
</div>
</div>
{/* Status Effects */}
<div className="panel">
<div className="panel-head"><span className="panel-dot" />Buffs</div>
<div className="panel-body" style={{padding:'4px 8px',display:'flex',gap:'3px',flexWrap:'wrap'}}>
{playerBuffs.map(b => (
<span key={b.id} className="buff-pill" style={{background:`${b.color}12`,border:`1px solid ${b.color}30`,color:b.color}}>
{b.icon} {b.name}{b.duration>0?` ${b.duration}s`:''}
</span>
))}
{enemyBuffs.map(b => (
<span key={b.id} className="buff-pill" style={{background:`${b.color}12`,border:`1px solid ${b.color}30`,color:b.color}}>
{b.icon} {b.name} {b.duration>0?`${b.duration}s`:''}
</span>
))}
</div>
</div>
</div>
{/* ── CENTER ── */}
<div className="hud-center">
{/* Crosshair */}
<div className="crosshair">
<div className="ch-ring" />
<div className="ch-h" style={{position:'absolute',inset:0}} />
</div>
{/* Lock-in-progress ring */}
{target && !enemy.locked && (
<div className="lock-hud">
<div className="lock-ring" style={{borderColor:'var(--accent-border)'}}>
<svg width="120" height="120" style={{position:'absolute',top:-2,left:-2,transform:'rotate(-90deg)'}}>
<circle cx="60" cy="60" r="58" fill="none" stroke="var(--accent)" strokeWidth="2"
strokeDasharray={`${(lockPct/100)*365} 365`}
style={{transition:'stroke-dasharray 0.1s'}} />
</svg>
</div>
<div className="lock-text">LOCKING {lockPct.toFixed(0)}%</div>
</div>
)}
{/* Engage prompt */}
{!target && (
<div className="engage-prompt">
<p>No hostiles detected</p>
<button className="engage-btn" onClick={lockTarget}>ENGAGE TARGET [SPACE]</button>
</div>
)}
</div>
{/* ── RIGHT — Target Info ── */}
<div className="hud-right">
{/* Target panel */}
<div className="panel">
<div className="panel-head">
<span className="panel-dot" style={{background:'var(--red)'}} />
<span style={{color:target?'var(--fg-bright)':'var(--muted)'}}>{target||'NO TARGET'}</span>
{target && <span style={{marginLeft:'auto',fontSize:'8px',color:enemy.locked?'var(--green)':'var(--accent)'}}>{enemy.locked?'LOCKED':'LOCKING'}</span>}
</div>
<div className="panel-body">
{target && (
<>
{[
{label:'SH', value:enemy.shields, color:'#22d3ee'},
{label:'AR', value:enemy.armor, color:'#f0a030'},
{label:'HU', value:enemy.hull, color:'#22c55e'},
].map(b => (
<div className="hbar" key={b.label} style={{marginBottom:'5px'}}>
<span className="hbar-label" style={{color:b.color}}>{b.label}</span>
<div className="hbar-track">
<div className="hbar-fill" style={{width:`${b.value}%`,background:`linear-gradient(90deg, ${b.color}88, ${b.color})`}} />
</div>
<span className="hbar-pct" style={{color:b.color}}>{b.value.toFixed(0)}</span>
</div>
))}
</>
)}
</div>
</div>
{/* Subsystem Targeting */}
<div className="panel" style={{flex:1}}>
<div className="panel-head"><span className="panel-dot" />Subsystem Target</div>
<div className="panel-body" style={{padding:'6px 8px'}}>
<div className="subsys-grid">
{[
{key:'hull', label:'Hull', color:'#f0a030', hp:enemy.hull},
{key:'shields', label:'Shields', color:'#22d3ee', hp:enemy.shields},
{key:'weapons', label:'Weapons', color:'#ef4444', hp:enemy.weapons},
{key:'engines', label:'Engines', color:'#22c55e', hp:enemy.engines},
].map(sys => (
<button key={sys.key} className={`subsys-btn${subsystem===sys.key?' active':''}`}
style={{borderColor:subsystem===sys.key?sys.color:'var(--border)'}}
onClick={() => { setSubsystem(sys.key); addLog(`Retargeting: ${sys.key.toUpperCase()}`, '#a78bfa'); }}>
<span className="ss-label" style={{color:sys.color}}>{sys.label} {subsystem===sys.key?'◄':''}</span>
<div className="ss-bar">
<div className="ss-fill" style={{width:`${sys.hp}%`,background:sys.color}} />
</div>
<span style={{fontFamily:'var(--font-mono)',fontSize:'8px',color:'var(--fg-dim)',display:'block',marginTop:'2px'}}>{sys.hp.toFixed(0)}%</span>
</button>
))}
</div>
</div>
</div>
{/* Power coupling readout */}
<div className="panel">
<div className="panel-head"><span className="panel-dot" />Power Readout</div>
<div className="panel-body" style={{padding:'6px 10px',fontFamily:'var(--font-mono)',fontSize:'9px',color:'var(--fg-dim)',lineHeight:1.6}}>
<div><span style={{color:'#ef4444'}}>WPN {power.weapons}</span> bars ×{weaponMult.toFixed(1)} dmg, {Math.max(1,Math.floor(power.weapons/2))} bullets/volley</div>
<div><span style={{color:'#22d3ee'}}>SHD {power.shields}</span> bars {shieldRegen.toFixed(0)}/s regen, {(shieldAbsorb*100).toFixed(0)}% absorb</div>
<div><span style={{color:'#22c55e'}}>ENG {power.engines}</span> bars {dodgeChance}% dodge, orbit ×{speedMult.toFixed(1)}</div>
<div><span style={{color:'#a78bfa'}}>AUX {power.aux}</span> bars {energyRegen.toFixed(0)} NRG/s, {cdReduction}% CD</div>
</div>
</div>
</div>
{/* ── BOTTOM BAR ── */}
<div className="hud-bottom">
{/* Ability bar */}
<div className="panel" style={{flex:2}}>
<div className="panel-head" style={{padding:'4px 10px'}}>
<span className="panel-dot" />Modules
<span style={{marginLeft:'auto',fontSize:'8px'}}>Press 16 or click</span>
</div>
<div className="ability-bar">
{/* Reactor gauge */}
<div style={{display:'flex',flexDirection:'column',alignItems:'center',width:'48px',flexShrink:0,marginRight:'4px'}}>
<span style={{fontFamily:'var(--font-mono)',fontSize:'8px',color:'#a78bfa',marginBottom:'2px'}}>REACTOR</span>
<div style={{width:'100%',height:'40px',background:'rgba(255,255,255,0.04)',borderRadius:'4px',overflow:'hidden',position:'relative'}}>
<div style={{position:'absolute',bottom:0,width:'100%',height:`${player.energy}%`,background:player.energy>25?'linear-gradient(0deg,#4f46e5,#8b5cf6)':'linear-gradient(0deg,#dc2626,#ef4444)',transition:'height 0.15s'}} />
<span style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',fontFamily:'var(--font-mono)',fontSize:'11px',fontWeight:700,color:'var(--fg-bright)'}}>{Math.round(player.energy)}</span>
</div>
</div>
<div style={{width:1,height:40,background:'var(--border)',flexShrink:0}} />
{/* Module buttons */}
{modules.map(ab => {
const onCd = ab.cd > 0;
const noNRG = player.energy < ab.cost;
const canCast = target && enemy.locked && !onCd && !noNRG;
return (
<button key={ab.id} className={`ability-btn${canCast?' can-cast':''}`}
onClick={() => castModule(ab.id)}
title={`${ab.name}${ab.desc} (${ab.cost} NRG)`}>
{onCd && <div className="cd-overlay" style={{height:`${(ab.cd/(ab.maxCd*(1-power.aux*0.06)))*100}%`}} />}
{noNRG && !onCd && <div className="no-nrg" />}
<span className="ab-icon" style={{opacity:canCast?1:0.4}}>{ab.icon}</span>
<span className="ab-key">{onCd ? ab.cd.toFixed(1) : ab.key}</span>
<span className="ab-cost" style={{color:noNRG&&!onCd?'#ef4444':'var(--muted)'}}>{ab.cost}</span>
</button>
);
})}
</div>
</div>
{/* Combat log */}
<div className="panel combat-log" style={{flex:1.2}}>
<div className="panel-head" style={{padding:'4px 10px'}}>
<span className="panel-dot" />Combat Log
</div>
<div className="log-scroll" ref={logScrollRef}>
{combatLog.length === 0 && <div style={{color:'var(--muted)',padding:'4px 0'}}>Press SPACE to engage, then 16 for modules.</div>}
{combatLog.map((entry, i) => (
<div className="log-entry" key={i}>
<span className="log-time">{entry.time}</span>
<span className="log-msg" style={{color:entry.color}}>{entry.msg}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<CombatHUD />);
</script>
</body>
</html>