1194 lines
55 KiB
HTML
1194 lines
55 KiB
HTML
|
||
<!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 1–6 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 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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<CombatHUD />);
|
||
</script>
|
||
</body>
|
||
</html>
|