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

1468 lines
48 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>COMBAT HUD — VOID NAV</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<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>
<style>
/* ═══════════════════════════════════════════════════════════
COMBAT HUD — Diegetic Cockpit UI
Full-screen 3D viewport + HUD overlay
═══════════════════════════════════════════════════════════ */
:root {
--hud-bg: rgba(4, 8, 16, 0.75);
--hud-border: rgba(34, 211, 238, 0.25);
--hud-border-bright: rgba(34, 211, 238, 0.55);
--hud-glow: rgba(34, 211, 238, 0.08);
--cyan: #22d3ee;
--amber: #f0a030;
--red: #ef4444;
--green: #22c55e;
--purple: #a78bfa;
--white: #e2e8f0;
--dim: #5a6b82;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace;
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#root { width: 100%; height: 100%; }
/* ── Viewport ── */
.viewport {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #020408;
cursor: crosshair;
}
.viewport canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
}
/* ── Scanline overlay ── */
.scanlines {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
}
/* ── Vignette ── */
.vignette {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%);
}
/* ── HUD overlay container ── */
.hud-overlay {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
font-family: var(--mono);
color: var(--white);
user-select: none;
}
.hud-overlay > * { pointer-events: auto; }
/* ── Shared HUD panel ── */
.hud-panel {
background: var(--hud-bg);
border: 1px solid var(--hud-border);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ── Crosshair ── */
.crosshair {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 60px; height: 60px;
pointer-events: none;
z-index: 4;
}
.crosshair::before, .crosshair::after {
content: '';
position: absolute;
background: rgba(34, 211, 238, 0.5);
}
.crosshair::before {
width: 1px; height: 100%;
left: 50%; top: 0;
}
.crosshair::after {
width: 100%; height: 1px;
top: 50%; left: 0;
}
.crosshair-ring {
position: absolute;
inset: 8px;
border: 1px solid rgba(34, 211, 238, 0.35);
border-radius: 50%;
}
/* ── Top bar ── */
.top-bar {
position: absolute;
top: 0; left: 0; right: 0;
height: 32px;
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
background: linear-gradient(180deg, var(--hud-bg) 0%, transparent 100%);
border-bottom: 1px solid var(--hud-border);
pointer-events: auto;
}
.top-bar .sys-name { color: var(--cyan); font-weight: 700; font-size: 12px; letter-spacing: 0.12em; }
.top-bar .sec-status { color: var(--green); font-size: 9px; padding: 1px 6px; border: 1px solid rgba(34,197,94,0.3); border-radius: 2px; background: rgba(34,197,94,0.06); }
.top-bar .wallet { color: var(--amber); font-weight: 600; }
.top-bar .conn-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
.top-bar .time { color: var(--dim); }
/* ── Left panel — Ship status ── */
.ship-panel {
position: absolute;
top: 44px; left: 12px; bottom: 140px;
width: 180px;
padding: 10px;
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%);
}
.ship-panel .panel-title {
font-size: 8px;
letter-spacing: 0.15em;
color: var(--dim);
text-transform: uppercase;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--hud-border);
}
/* Health bars */
.hp-bar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.hp-bar .label {
font-size: 9px;
font-weight: 700;
width: 18px;
text-align: right;
}
.hp-bar .track {
flex: 1;
height: 6px;
background: rgba(255,255,255,0.04);
border-radius: 1px;
overflow: hidden;
position: relative;
}
.hp-bar .fill {
height: 100%;
border-radius: 1px;
transition: width 0.3s ease-out;
position: relative;
}
.hp-bar .fill::after {
content: '';
position: absolute;
right: 0; top: 0; bottom: 0;
width: 3px;
background: inherit;
filter: brightness(2);
}
.hp-bar .value {
font-size: 9px;
color: var(--dim);
width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Speed gauge */
.speed-gauge {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--hud-border);
text-align: center;
}
.speed-gauge .speed-val {
font-size: 22px;
font-weight: 700;
color: var(--white);
letter-spacing: -0.03em;
line-height: 1;
}
.speed-gauge .speed-unit {
font-size: 8px;
color: var(--dim);
letter-spacing: 0.1em;
margin-top: 2px;
}
.speed-gauge .speed-track {
height: 3px;
background: rgba(255,255,255,0.04);
border-radius: 1px;
overflow: hidden;
margin-top: 6px;
}
.speed-gauge .speed-fill {
height: 100%;
background: var(--cyan);
border-radius: 1px;
transition: width 0.4s ease-out;
}
/* ── Right panel — Target info ── */
.target-panel {
position: absolute;
top: 44px; right: 12px;
width: 210px;
padding: 10px;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 calc(100% - 14px));
}
.target-panel .panel-title {
font-size: 8px;
letter-spacing: 0.15em;
color: var(--dim);
text-transform: uppercase;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--hud-border);
display: flex;
align-items: center;
gap: 6px;
}
.target-panel .lock-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--amber);
animation: pulse-lock 1s infinite;
}
@keyframes pulse-lock {
0%, 100% { opacity: 1; box-shadow: 0 0 4px var(--amber); }
50% { opacity: 0.4; box-shadow: none; }
}
.target-name {
font-size: 11px;
font-weight: 700;
color: var(--red);
margin-bottom: 2px;
}
.target-class {
font-size: 9px;
color: var(--dim);
margin-bottom: 8px;
}
/* Subsystem targeting grid */
.subsys-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 8px;
}
.subsys-btn {
padding: 5px 6px;
border-radius: 2px;
border: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.02);
cursor: pointer;
text-align: left;
transition: all 0.12s;
position: relative;
overflow: hidden;
}
.subsys-btn:hover {
background: rgba(255,255,255,0.05);
}
.subsys-btn.active {
border-color: currentColor;
background: rgba(255,255,255,0.04);
}
.subsys-btn .ss-name {
font-size: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
display: block;
}
.subsys-btn .ss-track {
height: 2px;
background: rgba(255,255,255,0.04);
border-radius: 1px;
overflow: hidden;
margin-top: 3px;
}
.subsys-btn .ss-fill {
height: 100%;
border-radius: 1px;
transition: width 0.3s ease-out;
}
.subsys-btn .ss-pct {
font-size: 7px;
color: var(--dim);
display: block;
margin-top: 2px;
}
/* Target distance/bounty */
.target-meta {
display: flex;
justify-content: space-between;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--hud-border);
font-size: 9px;
}
/* ── Bottom module bar ── */
.module-bar {
position: absolute;
bottom: 0; left: 200px; right: 200px;
height: 110px;
display: flex;
align-items: flex-end;
padding: 0 8px 10px;
gap: 6px;
pointer-events: auto;
}
/* Reactor gauge */
.reactor-gauge {
width: 48px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.reactor-gauge .reactor-label {
font-size: 7px;
letter-spacing: 0.12em;
color: var(--purple);
text-transform: uppercase;
margin-bottom: 3px;
}
.reactor-gauge .reactor-tube {
width: 100%;
height: 72px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--hud-border);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.reactor-gauge .reactor-fill {
position: absolute;
bottom: 0; left: 0; right: 0;
transition: height 0.15s ease-out;
border-radius: 0 0 1px 1px;
}
.reactor-gauge .reactor-val {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--white);
text-shadow: 0 0 6px rgba(0,0,0,0.8);
}
/* Module button */
.module-btn {
width: 62px;
height: 72px;
border-radius: 4px;
border: 1px solid var(--hud-border);
background: rgba(255,255,255,0.02);
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
transition: all 0.12s;
flex-shrink: 0;
}
.module-btn:hover { background: rgba(255,255,255,0.05); }
.module-btn.can-cast {
border-color: var(--hud-border-bright);
background: rgba(34, 211, 238, 0.06);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.08);
}
.module-btn .cd-overlay {
position: absolute;
bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.65);
transition: height 0.1s linear;
}
.module-btn .no-nrg {
position: absolute;
inset: 0;
background: rgba(100, 20, 20, 0.3);
}
.module-btn .m-icon {
font-size: 16px;
position: relative;
z-index: 1;
}
.module-btn .m-key {
font-size: 10px;
font-weight: 700;
position: relative;
z-index: 1;
}
.module-btn .m-cost {
position: absolute;
bottom: 2px; right: 4px;
font-size: 7px;
color: var(--dim);
z-index: 1;
}
/* ── Reactor power bar ── */
.reactor-panel {
position: absolute;
bottom: 0; left: 12px;
width: 180px;
height: 130px;
padding: 10px;
clip-path: polygon(14px 0, 100% 0, 100% 100%, 0 100%, 0 14px);
}
.reactor-panel .panel-title {
font-size: 8px;
letter-spacing: 0.15em;
color: var(--dim);
text-transform: uppercase;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--hud-border);
display: flex;
justify-content: space-between;
}
.power-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.power-row .p-label {
font-size: 8px;
font-weight: 700;
width: 12px;
text-align: center;
}
.power-row .p-btn {
width: 14px; height: 14px;
border-radius: 2px;
border: 1px solid var(--hud-border);
background: rgba(255,255,255,0.03);
color: var(--dim);
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.power-row .p-btn:hover {
background: rgba(255,255,255,0.08);
}
.power-row .p-bars {
display: flex;
gap: 1px;
flex: 1;
}
.power-row .p-bar {
flex: 1;
height: 6px;
border-radius: 1px;
background: rgba(255,255,255,0.04);
transition: background 0.15s;
}
/* ── Combat log ── */
.log-panel {
position: absolute;
bottom: 0; right: 12px;
width: 210px;
height: 130px;
padding: 8px 10px;
overflow: hidden;
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%);
}
.log-panel .panel-title {
font-size: 8px;
letter-spacing: 0.15em;
color: var(--dim);
text-transform: uppercase;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid var(--hud-border);
}
.log-scroll {
overflow-y: auto;
max-height: 85px;
scrollbar-width: none;
}
.log-scroll::-webkit-scrollbar { display: none; }
.log-entry {
display: flex;
gap: 6px;
margin-bottom: 2px;
font-size: 9px;
line-height: 1.3;
}
.log-entry .log-time { color: var(--dim); white-space: nowrap; }
.log-entry .log-msg { flex: 1; }
/* ── Overload warning ── */
.overload-banner {
position: absolute;
top: 36px; left: 50%;
transform: translateX(-50%);
padding: 3px 16px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--amber);
border: 1px solid rgba(240, 160, 48, 0.4);
background: rgba(240, 160, 48, 0.08);
animation: flash-overload 0.6s infinite;
pointer-events: none;
}
@keyframes flash-overload {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Engage prompt ── */
.engage-prompt {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: auto;
}
.engage-prompt p {
font-size: 11px;
color: var(--dim);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 10px;
}
.engage-btn {
padding: 8px 24px;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--amber);
background: rgba(240, 160, 48, 0.08);
border: 1px solid rgba(240, 160, 48, 0.4);
cursor: pointer;
transition: all 0.15s;
}
.engage-btn:hover {
background: rgba(240, 160, 48, 0.15);
border-color: rgba(240, 160, 48, 0.7);
box-shadow: 0 0 20px rgba(240, 160, 48, 0.15);
}
/* ── Lock progress ── */
.lock-ring {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 120px; height: 120px;
pointer-events: none;
}
.lock-ring svg {
width: 100%; height: 100%;
}
/* ── Target brackets on 3D view ── */
.target-brackets {
position: absolute;
pointer-events: none;
z-index: 4;
}
/* Buff chips */
.buff-strip {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
}
.buff-chip {
padding: 2px 8px;
font-size: 8px;
border-radius: 2px;
letter-spacing: 0.06em;
border: 1px solid;
}
</style>
</head>
<body>
<div id="root"></div>
<script src="js/lib/three-helpers.js"></script>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
const TH = window.GDD.THREE;
const MAX_POWER = 8;
const TICK_MS = 50;
/* ═══ Projectile Pool ═══ */
function createProjectilePool3D(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.08, 0.08, len, 4);
geo.rotateX(Math.PI / 2);
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 });
mesh = new THREE.Mesh(geo, mat);
mesh.position.set((x + tx) / 2, (y + ty) / 2, (z + tz) / 2);
mesh.lookAt(tx, ty, tz);
mesh.userData = { life: 8, maxLife: 8, type, dmg, subsystem };
} else if (type === 'pulse') {
const geo = new THREE.RingGeometry(1, 2, 24);
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7, side: THREE.DoubleSide });
mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.userData = { life: 20, maxLife: 20, type, dmg: 0, subsystem: 'none', scale: 1 };
} else {
mesh = new THREE.Mesh(
new THREE.SphereGeometry(type === 'missile' ? 0.2 : 0.12, 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]; const 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; },
};
}
function createImpactPool3D(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: 10, maxLife: 10 });
},
tick() {
for (let i = impacts.length - 1; i >= 0; i--) {
impacts[i].life--;
const progress = 1 - impacts[i].life / impacts[i].maxLife;
impacts[i].mesh.material.opacity = (1 - progress) * 0.8;
impacts[i].mesh.scale.setScalar((impacts[i].mesh.scale.x || 3) * (1 + progress * 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); }
}
},
};
}
/* ═══════════════════════════════════════════════════
CombatHUD — Full-screen Game UI
═══════════════════════════════════════════════════ */
function CombatHUD() {
const containerRef = useRef(null);
const sceneRef = useRef(null);
const animIdRef = useRef(null);
const tickRef = useRef(0);
const projectilePoolRef = useRef(null);
const impactPoolRef = 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, speed: 0 });
const enemyPosRef = useRef({ orbitAngle: Math.PI });
const logScrollRef = useRef(null);
const autoFireTimerRef = useRef(0);
const enemyFireTimerRef = useRef(0);
const [player, setPlayer] = useState({ shields: 100, armor: 100, hull: 100, energy: 100, maxEnergy: 100, speed: 0 });
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 Boost', icon: '◎', type: 'shield', cd: 0, maxCd: 8, cost: 20, damage: 0, desc: 'Burst shield recharge', color: '#22d3ee' },
{ id: 'e', key: '3', name: 'EM Pulse', icon: '⟐', type: 'ewar', cd: 0, maxCd: 12, cost: 30, damage: 0, desc: 'Disrupt enemy systems', color: '#a78bfa' },
{ id: 'r', key: '4', name: 'Overload', icon: '⚡', type: 'reactor', cd: 0, maxCd: 30, cost: 45, damage: 0, desc: 'Push reactor past limits', 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 hull 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 Pirate', 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 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(-40), { time, msg, color }];
setCombatLog(logRef.current);
}, []);
// Derived
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;
/* ── 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, 35, 60);
camera.lookAt(0, 0, 0);
const renderer = TH.createRenderer(container, { clearColor: 0x020408 });
TH.handleResize(renderer, camera, container);
const stars = TH.createStarField(4000, 2500);
scene.add(stars);
TH.addNebula(scene, 0x22d3ee, [-100, 40, -200], 200);
TH.addNebula(scene, 0xa78bfa, [80, -30, -150], 150);
TH.setupSpaceLighting(scene);
// Grid
const grid = new THREE.GridHelper(300, 15, 0x0d1520, 0x0d1520);
grid.material.transparent = true; grid.material.opacity = 0.12;
scene.add(grid);
// Player
const playerGroup = new THREE.Group();
const pMesh = TH.createShipMesh(0xc8d6e5, 0xf0a030, 0.6);
pMesh.rotation.y = Math.PI / 2;
playerGroup.add(pMesh);
const pEngine = TH.createEngineGlow(0x22d3ee, 3, 15);
pEngine.position.set(0, 0, 6);
playerGroup.add(pEngine);
const pShield = TH.createShield(3, 0x22d3ee, 0.06);
playerGroup.add(pShield);
playerShieldRef.current = pShield;
playerGroup.position.set(-15, 0, 0);
scene.add(playerGroup);
playerShipRef.current = playerGroup;
// Enemy
const enemyGroup = new THREE.Group();
const eMesh = TH.createShipMesh(0x7f1d1d, 0xef4444, 0.5);
eMesh.rotation.y = Math.PI / 2;
enemyGroup.add(eMesh);
const eEngine = TH.createEngineGlow(0xef4444, 2, 10);
eEngine.position.set(0, 0, 5);
enemyGroup.add(eEngine);
enemyGroup.position.set(15, 0, 0);
scene.add(enemyGroup);
enemyShipRef.current = enemyGroup;
// 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 lockBrackets = TH.createLockBrackets(3, 0xf0a030);
lockBrackets.visible = false;
scene.add(lockBrackets); enemyLockRef.current = lockBrackets;
// Asteroids
[
{ x: -25, y: -3, z: -15, s: 3 }, { x: -20, y: -2, z: -10, s: 2 },
{ x: -30, y: -4, z: -20, s: 2.5 }, { x: 30, y: -3, z: -18, s: 2 },
{ x: 35, y: -2, z: -12, s: 1.5 }, { x: -15, y: -5, z: -30, s: 3.5 },
{ x: 20, y: -4, z: -35, s: 2 },
].forEach(a => {
const ast = TH.createAsteroid(a.s);
ast.position.set(a.x, a.y, a.z);
ast.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
scene.add(ast);
});
const projPool = createProjectilePool3D(scene);
projectilePoolRef.current = projPool;
const impPool = createImpactPool3D(scene);
impactPoolRef.current = impPool;
sceneRef.current = { scene, camera, renderer, stars, playerGroup, enemyGroup, pEngine, eEngine, pShield, 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;
playerGroup.position.x = -pr * Math.cos(pp.orbitAngle);
playerGroup.position.z = pr * Math.sin(pp.orbitAngle) * 0.6;
playerGroup.position.y = Math.sin(pp.orbitAngle * 1.3) * 2;
if (tgt && eng.locked) playerGroup.lookAt(enemyGroup.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;
enemyGroup.position.x = 15 + er * Math.cos(ep.orbitAngle);
enemyGroup.position.z = er * Math.sin(ep.orbitAngle) * 0.5;
enemyGroup.position.y = Math.sin(ep.orbitAngle * 1.1) * 1.5 * eScale;
eEngine.intensity = 1.5 * eScale;
if (tgt) enemyGroup.lookAt(playerGroup.position);
if (lockBrackets.visible) {
lockBrackets.position.copy(enemyGroup.position);
lockBrackets.rotation.y = t * 0.3;
}
if (targetLine.visible && tgt && eng.locked) {
const positions = targetLine.geometry.attributes.position;
positions.setXYZ(0, playerGroup.position.x, playerGroup.position.y, playerGroup.position.z);
positions.setXYZ(1, enemyGroup.position.x, enemyGroup.position.y, enemyGroup.position.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();
};
}, []);
// Lock target
const lockTarget = useCallback(() => {
addLog('Initiating target lock…', '#f0a030');
setEnemy(prev => ({ ...prev, lockTimer: 0, locked: false }));
setTarget('Guristas Pirate');
if (targetLineRef.current) targetLineRef.current.visible = true;
}, [addLog]);
// Adjust power
const adjustPower = useCallback((system, delta) => {
setPower(prev => {
const newVal = prev[system] + delta;
if (newVal < 0) return prev;
const newTotal = Object.entries(prev).reduce((sum, [k, v]) => sum + (k === system ? newVal : v), 0);
if (newTotal > MAX_POWER) return prev;
return { ...prev, [system]: newVal };
});
}, []);
// Cast module
const castModule = useCallback((abilityId) => {
const ab = modulesRef.current.find(a => a.id === abilityId);
if (!ab) return;
if (!targetRef.current || !enemyRef.current.locked) { addLog('No target locked.', '#ef4444'); return; }
if (ab.cd > 0) 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 = projectilePoolRef.current;
const imp = impactPoolRef.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()}`, '#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 +${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! ${dur.toFixed(1)}s`, '#a78bfa');
setTimeout(() => { setEnemyBuffs([]); addLog('Enemy 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(`Afterburn! ×${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 +${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;
setModules(prev => prev.map(a => ({ ...a, cd: Math.max(0, a.cd - TICK_MS/1000) })));
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)) }));
setPlayer(prev => prev.shields < 100 ? { ...prev, shields: Math.min(100, prev.shields + (pwr.shields * 1.2) / tps) } : prev);
const proj = projectilePoolRef.current;
const imp = impactPoolRef.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;
autoFireTimerRef.current++;
if (autoFireTimerRef.current >= baseInterval) {
autoFireTimerRef.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);
}
}
enemyFireTimerRef.current++;
if (enemyFireTimerRef.current >= 30 && eng.weapons > 10) {
enemyFireTimerRef.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');
}
}
}
// Lock timer
if (tgt && !eng.locked && eng.lockTimer < eng.lockTime) {
setEnemy(prev => {
const newTimer = prev.lockTimer + TICK_MS/1000;
if (newTimer >= prev.lockTime) {
addLog('★ TARGET LOCKED', '#22c55e');
if (enemyLockRef.current) enemyLockRef.current.visible = true;
return { ...prev, locked: true, lockTimer: newTimer };
}
return { ...prev, lockTimer: newTimer };
});
}
// 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;
});
}
}
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]);
// Auto-scroll log
useEffect(() => {
if (logScrollRef.current) logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
}, [combatLog]);
const lockPct = target && !enemy.locked ? Math.round((enemy.lockTimer / enemy.lockTime) * 100) : 0;
return (
<div className="viewport">
{/* 3D Canvas */}
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
{/* Scanlines + Vignette */}
<div className="scanlines" />
<div className="vignette" />
{/* Crosshair */}
{target && <div className="crosshair"><div className="crosshair-ring" /></div>}
{/* HUD Overlay */}
<div className="hud-overlay">
{/* ── TOP BAR ── */}
<div className="top-bar">
<span className="sys-name">JITA</span>
<span className="sec-status">0.9 SEC</span>
<span style={{ color: 'var(--dim)' }}></span>
<span className="wallet">125,000</span>
<span style={{ color: 'var(--dim)' }}></span>
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span className="conn-dot" />
<span style={{ color: 'var(--dim)', fontSize: '9px' }}>TQ ONLINE</span>
</span>
<span style={{ marginLeft: 'auto' }} className="time">14:23 UTC</span>
</div>
{/* ── OVERLOAD BANNER ── */}
{overloaded && <div className="overload-banner"> REACTOR OVERLOAD </div>}
{/* ── BUFF STRIP ── */}
{(playerBuffs.length > 1 || enemyBuffs.length > 0) && (
<div className="buff-strip">
{playerBuffs.map(b => (
<span key={b.id} className="buff-chip" style={{ color: b.color, borderColor: b.color + '40', background: b.color + '10' }}>
{b.icon} {b.name}{b.duration > 0 ? ` ${b.duration}s` : ''}
</span>
))}
{enemyBuffs.map(b => (
<span key={b.id} className="buff-chip" style={{ color: b.color, borderColor: b.color + '40', background: b.color + '10' }}>
{b.icon} {b.name} {b.duration > 0 ? `${b.duration}s` : ''}
</span>
))}
</div>
)}
{/* ── ENGAGE PROMPT ── */}
{!target && (
<div className="engage-prompt">
<p>No hostiles detected</p>
<button className="engage-btn" onClick={lockTarget}>ENGAGE TARGET [SPACE]</button>
</div>
)}
{/* ── LOCK PROGRESS ── */}
{target && !enemy.locked && (
<div className="lock-ring">
<svg viewBox="0 0 120 120">
{/* Background ring */}
<circle cx="60" cy="60" r="50" fill="none" stroke="rgba(240,160,48,0.15)" strokeWidth="2" />
{/* Progress arc */}
<circle cx="60" cy="60" r="50" fill="none" stroke="#f0a030" strokeWidth="2"
strokeDasharray={`${lockPct * 3.14} 314`}
transform="rotate(-90 60 60)"
strokeLinecap="round" />
{/* Center text */}
<text x="60" y="55" textAnchor="middle" fill="#f0a030" fontSize="14" fontFamily="var(--mono)" fontWeight="700">{lockPct}%</text>
<text x="60" y="72" textAnchor="middle" fill="#5a6b82" fontSize="8" fontFamily="var(--mono)" letterSpacing="0.12em">LOCKING</text>
</svg>
</div>
)}
{/* ── LEFT — SHIP STATUS ── */}
<div className="ship-panel hud-panel">
<div className="panel-title">USS Enterprise Venture-Class</div>
{[
{ label: 'SH', value: player.shields, color: '#22d3ee' },
{ label: 'AR', value: player.armor, color: '#f0a030' },
{ label: 'HU', value: player.hull, color: '#22c55e' },
].map(bar => (
<div className="hp-bar" key={bar.label}>
<span className="label" style={{ color: bar.color }}>{bar.label}</span>
<div className="track">
<div className="fill" style={{
width: `${bar.value}%`,
background: `linear-gradient(90deg, ${bar.color}88, ${bar.color})`,
}} />
</div>
<span className="value">{bar.value.toFixed(0)}</span>
</div>
))}
{/* Capacitor */}
<div style={{ marginTop: '6px', paddingTop: '6px', borderTop: '1px solid var(--hud-border)' }}>
<div className="hp-bar">
<span className="label" style={{ color: '#a78bfa' }}>NRG</span>
<div className="track" style={{ height: '8px' }}>
<div className="fill" style={{
width: `${player.energy}%`,
background: player.energy > 25
? 'linear-gradient(90deg, #4f46e5, #a78bfa)'
: 'linear-gradient(90deg, #dc2626, #ef4444)',
}} />
</div>
<span className="value" style={{ color: player.energy > 25 ? '#a78bfa' : '#ef4444' }}>{Math.round(player.energy)}</span>
</div>
</div>
{/* Speed gauge */}
<div className="speed-gauge">
<div className="speed-val" style={{ color: player.speed > 0 ? 'var(--cyan)' : 'var(--dim)' }}>
{player.speed || 0}
</div>
<div className="speed-unit">M/S</div>
<div className="speed-track">
<div className="speed-fill" style={{ width: `${(player.speed / 420) * 100}%` }} />
</div>
</div>
</div>
{/* ── RIGHT — TARGET INFO ── */}
<div className="target-panel hud-panel" style={{ display: target ? 'block' : 'none' }}>
<div className="panel-title">
<span>Target Lock</span>
{enemy.locked && <span className="lock-dot" />}
</div>
<div className="target-name">{enemy.name}</div>
<div className="target-class">{enemy.class} · {enemy.locked ? 'LOCKED' : `LOCKING ${lockPct}%`}</div>
{[
{ label: 'SH', value: enemy.shields, color: '#22d3ee' },
{ label: 'AR', value: enemy.armor, color: '#f0a030' },
{ label: 'HU', value: enemy.hull, color: '#22c55e' },
].map(bar => (
<div className="hp-bar" key={bar.label}>
<span className="label" style={{ color: bar.color }}>{bar.label}</span>
<div className="track">
<div className="fill" style={{ width: `${bar.value}%`, background: bar.color }} />
</div>
<span className="value">{bar.value.toFixed(0)}</span>
</div>
))}
{/* Subsystem targeting */}
<div className="subsys-grid">
{[
{ key: 'hull', label: 'Hull', color: '#f0a030', hp: enemy.hull },
{ key: 'shields', label: 'Shield', color: '#22d3ee', hp: enemy.shields },
{ key: 'weapons', label: 'Wpn', color: '#ef4444', hp: enemy.weapons },
{ key: 'engines', label: 'Eng', color: '#22c55e', hp: enemy.engines },
].map(sys => (
<button key={sys.key}
className={`subsys-btn${subsystem === sys.key ? ' active' : ''}`}
style={{ color: sys.color }}
onClick={() => { setSubsystem(sys.key); addLog(`Retargeting: ${sys.key.toUpperCase()}`, '#a78bfa'); }}>
<span className="ss-name">{sys.label}</span>
<div className="ss-track"><div className="ss-fill" style={{ width: `${sys.hp}%`, background: sys.color }} /></div>
<span className="ss-pct">{sys.hp.toFixed(0)}%</span>
</button>
))}
</div>
<div className="target-meta">
<span style={{ color: 'var(--cyan)' }}>12.4 km</span>
<span style={{ color: 'var(--amber)' }}>85,000</span>
</div>
</div>
{/* ── BOTTOM LEFT — REACTOR POWER ── */}
<div className="reactor-panel hud-panel">
<div className="panel-title">
<span>Reactor</span>
<span style={{ color: totalPower < MAX_POWER ? 'var(--green)' : 'var(--dim)' }}>{totalPower}/{MAX_POWER}</span>
</div>
{[
{ key: 'weapons', label: 'W', color: '#ef4444' },
{ key: 'shields', label: 'S', color: '#22d3ee' },
{ key: 'engines', label: 'E', color: '#22c55e' },
{ key: 'aux', label: 'A', color: '#a78bfa' },
].map(sys => (
<div className="power-row" key={sys.key}>
<span className="p-label" style={{ color: sys.color }}>{sys.label}</span>
<button className="p-btn" onClick={() => adjustPower(sys.key, -1)}></button>
<div className="p-bars">
{Array.from({ length: MAX_POWER }, (_, i) => (
<div key={i} className="p-bar" style={{ background: i < power[sys.key] ? sys.color : 'rgba(255,255,255,0.04)' }} />
))}
</div>
<button className="p-btn" onClick={() => adjustPower(sys.key, 1)}>+</button>
</div>
))}
</div>
{/* ── BOTTOM CENTER — MODULES ── */}
<div className="module-bar">
{/* Reactor gauge */}
<div className="reactor-gauge">
<span className="reactor-label">NRG</span>
<div className="reactor-tube">
<div className="reactor-fill" style={{
height: `${player.energy}%`,
background: player.energy > 25
? 'linear-gradient(0deg, #4f46e5, #8b5cf6)'
: 'linear-gradient(0deg, #dc2626, #ef4444)',
}} />
<span className="reactor-val">{Math.round(player.energy)}</span>
</div>
</div>
<div style={{ width: 1, height: '50px', background: 'var(--hud-border)', flexShrink: 0, alignSelf: 'center' }} />
{/* 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={`module-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="m-icon" style={{ opacity: canCast ? 1 : 0.35, color: canCast ? ab.color : 'var(--dim)' }}>{ab.icon}</span>
<span className="m-key" style={{ color: canCast ? ab.color : 'var(--dim)' }}>
{onCd ? ab.cd.toFixed(1) : ab.key}
</span>
<span className="m-cost" style={{ color: noNRG && !onCd ? '#ef4444' : 'var(--dim)' }}>{ab.cost}</span>
</button>
);
})}
</div>
{/* ── BOTTOM RIGHT — COMBAT LOG ── */}
<div className="log-panel hud-panel">
<div className="panel-title">Combat Log</div>
<div className="log-scroll" ref={logScrollRef}>
{combatLog.length === 0 && (
<div style={{ color: 'var(--dim)', fontSize: '9px' }}>SPACE to engage, 1-6 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>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<CombatHUD />);
</script>
</body>
</html>