1468 lines
48 KiB
HTML
1468 lines
48 KiB
HTML
<!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>
|