- Restructure flat static prototype into pnpm workspace monorepo - apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection - apps/docs: design docs and prototypes - apps/site: landing page - packages/ui: shared Button and Panel primitives - services/spacetimedb: backend module (9 tables, 11 reducers) - Archive legacy static files to archive/legacy-static/ - Game loop: connect, undock, target, approach, dock, mine, sell - Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
1746 lines
59 KiB
HTML
1746 lines
59 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 — Game HUD</title>
|
||
<style>
|
||
/* ===== TOKENS ===== */
|
||
:root {
|
||
--bg: #080c14;
|
||
--bg-subtle: #0b1120;
|
||
--surface: #0f1623;
|
||
--surface-raised: #162032;
|
||
--surface-hover: #1c2d45;
|
||
--fg: #d4dce8;
|
||
--fg-bright: #f1f5f9;
|
||
--fg-dim: #94a3b8;
|
||
--muted: #5a6b82;
|
||
--border: #1c2a3f;
|
||
--border-light: #253550;
|
||
--accent: #f0a030;
|
||
--accent-hover: #fbbf24;
|
||
--accent-dim: #b47818;
|
||
--accent-bg: rgba(240,160,48,0.08);
|
||
--accent-border: rgba(240,160,48,0.25);
|
||
--cyan: #22d3ee;
|
||
--cyan-dim: #0891b2;
|
||
--cyan-bg: rgba(34,211,238,0.08);
|
||
--red: #ef4444;
|
||
--red-dim: #dc2626;
|
||
--red-bg: rgba(239,68,68,0.08);
|
||
--green: #22c55e;
|
||
--green-dim: #16a34a;
|
||
--green-bg: rgba(34,197,94,0.08);
|
||
--purple: #a78bfa;
|
||
--purple-dim: #8b5cf6;
|
||
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace;
|
||
--font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||
--font-display: 'SF Pro Display', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||
--radius-md: 8px;
|
||
--radius-lg: 12px;
|
||
--radius-pill: 9999px;
|
||
}
|
||
|
||
/* ===== RESET ===== */
|
||
*, *::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: var(--bg);
|
||
color: var(--fg);
|
||
font-size: 14px;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
#root { width: 100%; height: 100%; }
|
||
|
||
/* ===== HUD LAYOUT ===== */
|
||
.hud-root {
|
||
width: 100%; height: 100%;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #060a12;
|
||
}
|
||
|
||
/* Main space viewport */
|
||
.hud-viewport {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
}
|
||
.hud-viewport canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* HUD overlay container */
|
||
.hud-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.hud-overlay > * { pointer-events: auto; }
|
||
|
||
/* ===== TOP BAR ===== */
|
||
.hud-topbar {
|
||
height: 42px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
gap: 24px;
|
||
background: linear-gradient(180deg, rgba(8,12,20,0.92) 0%, rgba(8,12,20,0.6) 80%, transparent 100%);
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
pointer-events: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.hud-topbar .system-name {
|
||
font-family: var(--font-display);
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--fg-bright);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.hud-topbar .sec-status {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
padding: 1px 8px;
|
||
border-radius: var(--radius-pill);
|
||
font-weight: 600;
|
||
}
|
||
.sec-high { background: var(--green-bg); color: var(--green); border: 1px solid rgba(34,197,94,0.3); }
|
||
.sec-low { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); }
|
||
.sec-null { background: var(--red-bg); color: var(--red); border: 1px solid rgba(239,68,68,0.3); }
|
||
.hud-topbar .topbar-sep {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--border-light);
|
||
}
|
||
.hud-topbar .topbar-label {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.hud-topbar .topbar-value {
|
||
color: var(--fg-dim);
|
||
font-size: 12px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.hud-topbar .credits-value {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.hud-topbar .status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.hud-topbar .status-dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
box-shadow: 0 0 6px var(--green);
|
||
}
|
||
|
||
/* ===== MIDDLE AREA ===== */
|
||
.hud-middle {
|
||
flex: 1;
|
||
display: flex;
|
||
pointer-events: none;
|
||
position: relative;
|
||
min-height: 0;
|
||
}
|
||
.hud-middle > * { pointer-events: auto; }
|
||
|
||
/* ===== LEFT PANEL — Ship Health + Speed ===== */
|
||
.hud-left {
|
||
width: 220px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
pointer-events: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.hud-panel {
|
||
background: rgba(15,22,35,0.88);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
backdrop-filter: blur(8px);
|
||
overflow: hidden;
|
||
}
|
||
.hud-panel-header {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
}
|
||
.hud-panel-header .panel-dot {
|
||
width: 4px; height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
}
|
||
.hud-panel-body {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
/* Health bars */
|
||
.health-bar-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.health-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.health-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
width: 52px;
|
||
flex-shrink: 0;
|
||
}
|
||
.health-track {
|
||
flex: 1;
|
||
height: 6px;
|
||
background: rgba(255,255,255,0.04);
|
||
border-radius: var(--radius-pill);
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.health-fill {
|
||
height: 100%;
|
||
border-radius: var(--radius-pill);
|
||
transition: width 0.6s cubic-bezier(0.23,1,0.32,1);
|
||
}
|
||
.health-fill.shield { background: linear-gradient(90deg, #0891b2, #22d3ee); }
|
||
.health-fill.armor { background: linear-gradient(90deg, #b47818, #f0a030); }
|
||
.health-fill.hull { background: linear-gradient(90deg, #16a34a, #22c55e); }
|
||
.health-fill.cap { background: linear-gradient(90deg, #6366f1, #a78bfa); }
|
||
.health-pct {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--fg-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
width: 32px;
|
||
text-align: right;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Speed dial */
|
||
.speed-dial {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.speed-display {
|
||
text-align: center;
|
||
font-family: var(--font-mono);
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: var(--fg-bright);
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.speed-display .speed-unit {
|
||
font-size: 10px;
|
||
font-weight: 400;
|
||
color: var(--muted);
|
||
letter-spacing: 0.06em;
|
||
margin-left: 4px;
|
||
}
|
||
.speed-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
justify-content: center;
|
||
}
|
||
.speed-btn {
|
||
width: 28px; height: 28px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface-raised);
|
||
color: var(--fg-dim);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s ease;
|
||
font-family: var(--font-mono);
|
||
}
|
||
.speed-btn:hover { border-color: var(--accent-border); color: var(--accent); background: var(--accent-bg); }
|
||
.speed-bar-track {
|
||
flex: 1;
|
||
height: 4px;
|
||
background: rgba(255,255,255,0.04);
|
||
border-radius: var(--radius-pill);
|
||
overflow: hidden;
|
||
}
|
||
.speed-bar-fill {
|
||
height: 100%;
|
||
background: var(--cyan);
|
||
border-radius: var(--radius-pill);
|
||
transition: width 0.4s cubic-bezier(0.23,1,0.32,1);
|
||
}
|
||
|
||
/* Warp indicator */
|
||
.warp-indicator {
|
||
text-align: center;
|
||
padding: 6px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
color: var(--cyan);
|
||
background: var(--cyan-bg);
|
||
border-radius: 6px;
|
||
animation: pulse-warp 2s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse-warp {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
.warp-indicator.idle {
|
||
color: var(--muted);
|
||
background: transparent;
|
||
animation: none;
|
||
}
|
||
|
||
/* Ship info */
|
||
.ship-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.ship-name-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
.ship-name {
|
||
font-family: var(--display);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--fg-bright);
|
||
}
|
||
.ship-class {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--accent);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* ===== CENTER — crosshair & target ===== */
|
||
.hud-center {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
position: relative;
|
||
}
|
||
.crosshair {
|
||
width: 60px; height: 60px;
|
||
position: relative;
|
||
opacity: 0.4;
|
||
}
|
||
.crosshair::before, .crosshair::after {
|
||
content: '';
|
||
position: absolute;
|
||
background: var(--fg-dim);
|
||
}
|
||
.crosshair::before {
|
||
width: 1px; height: 20px;
|
||
left: 50%; top: 0;
|
||
transform: translateX(-50%);
|
||
}
|
||
.crosshair-ring {
|
||
width: 40px; height: 40px;
|
||
border: 1px solid rgba(212,220,232,0.3);
|
||
border-radius: 50%;
|
||
position: absolute;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
/* Target info overlay — top right of center area */
|
||
.target-info {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 280px;
|
||
pointer-events: auto;
|
||
}
|
||
.target-name {
|
||
font-family: var(--font-display);
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--fg-bright);
|
||
margin-bottom: 4px;
|
||
}
|
||
.target-type {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
margin-bottom: 8px;
|
||
}
|
||
.target-distance {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--cyan);
|
||
margin-bottom: 8px;
|
||
}
|
||
.target-health {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* ===== RIGHT PANEL — Overview ===== */
|
||
.hud-right {
|
||
width: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
pointer-events: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.overview-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.overview-table th {
|
||
font-family: var(--font-mono);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
text-align: left;
|
||
padding: 4px 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
.overview-table td {
|
||
padding: 5px 8px;
|
||
border-bottom: 1px solid rgba(28,42,63,0.4);
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--fg-dim);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.overview-table tr:hover td { background: var(--surface-raised); }
|
||
.overview-table tr.selected td { background: var(--accent-bg); color: var(--fg-bright); }
|
||
.overview-table .entity-icon {
|
||
width: 16px;
|
||
text-align: center;
|
||
font-size: 10px;
|
||
}
|
||
.overview-table .entity-name { color: var(--fg); }
|
||
.overview-table .entity-dist {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.overview-scroll {
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
}
|
||
.overview-scroll::-webkit-scrollbar { width: 4px; }
|
||
.overview-scroll::-webkit-scrollbar-track { background: transparent; }
|
||
.overview-scroll::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; }
|
||
|
||
/* ===== BOTTOM BAR ===== */
|
||
.hud-bottom {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
padding: 0 8px 8px;
|
||
background: linear-gradient(0deg, rgba(8,12,20,0.92) 0%, rgba(8,12,20,0.6) 80%, transparent 100%);
|
||
}
|
||
|
||
/* Module rack */
|
||
.module-rack {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 8px 12px;
|
||
}
|
||
.module-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.module-row-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
width: 36px;
|
||
flex-shrink: 0;
|
||
}
|
||
.module-slot {
|
||
width: 48px; height: 40px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
position: relative;
|
||
overflow: hidden;
|
||
gap: 2px;
|
||
}
|
||
.module-slot:hover { border-color: var(--border-light); background: var(--surface-raised); }
|
||
.module-slot.active {
|
||
border-color: var(--accent-border);
|
||
background: var(--accent-bg);
|
||
}
|
||
.module-slot.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
height: 2px;
|
||
background: var(--accent);
|
||
animation: module-cycle 3s linear infinite;
|
||
}
|
||
@keyframes module-cycle {
|
||
0% { transform: scaleX(0); transform-origin: left; }
|
||
100% { transform: scaleX(1); transform-origin: left; }
|
||
}
|
||
.module-slot.empty {
|
||
border-style: dashed;
|
||
border-color: var(--border);
|
||
cursor: default;
|
||
opacity: 0.4;
|
||
}
|
||
.module-slot .mod-icon {
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
}
|
||
.module-slot .mod-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 8px;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 44px;
|
||
}
|
||
.module-slot.active .mod-label { color: var(--accent); }
|
||
|
||
/* Target quick-info */
|
||
.bottom-target {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
}
|
||
.bottom-target .hud-panel-body {
|
||
padding: 8px 12px;
|
||
}
|
||
.bt-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--fg-bright);
|
||
margin-bottom: 2px;
|
||
}
|
||
.bt-shields {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--cyan);
|
||
}
|
||
.bt-dist {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* Chat panel */
|
||
.bottom-chat {
|
||
width: 340px;
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.chat-tab {
|
||
padding: 6px 12px;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
background: none;
|
||
border-top: none;
|
||
border-left: none;
|
||
border-right: none;
|
||
}
|
||
.chat-tab:hover { color: var(--fg-dim); }
|
||
.chat-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||
.chat-messages {
|
||
height: 100px;
|
||
overflow-y: auto;
|
||
padding: 6px 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
.chat-messages::-webkit-scrollbar { width: 3px; }
|
||
.chat-messages::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
|
||
.chat-msg {
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
}
|
||
.chat-msg .msg-sender {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--cyan);
|
||
margin-right: 6px;
|
||
}
|
||
.chat-msg .msg-time {
|
||
font-family: var(--font-mono);
|
||
font-size: 9px;
|
||
color: var(--muted);
|
||
margin-left: 6px;
|
||
}
|
||
.chat-input-row {
|
||
display: flex;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.chat-input {
|
||
flex: 1;
|
||
background: var(--bg-subtle);
|
||
border: none;
|
||
padding: 6px 10px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--fg);
|
||
outline: none;
|
||
}
|
||
.chat-input::placeholder { color: var(--muted); }
|
||
.chat-send-btn {
|
||
padding: 6px 12px;
|
||
background: var(--accent-bg);
|
||
border: none;
|
||
border-left: 1px solid var(--border);
|
||
color: var(--accent);
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
.chat-send-btn:hover { background: var(--accent-border); }
|
||
|
||
/* Cargo mini-panel */
|
||
.bottom-cargo {
|
||
width: 220px;
|
||
flex-shrink: 0;
|
||
}
|
||
.cargo-capacity {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
}
|
||
.cargo-capacity .cap-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
}
|
||
.cargo-capacity .cap-value {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--fg-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.cargo-bar {
|
||
height: 4px;
|
||
background: rgba(255,255,255,0.04);
|
||
border-radius: var(--radius-pill);
|
||
overflow: hidden;
|
||
margin-bottom: 8px;
|
||
}
|
||
.cargo-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: var(--radius-pill);
|
||
transition: width 0.4s ease;
|
||
}
|
||
.cargo-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
.cargo-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
}
|
||
.cargo-item .item-name { color: var(--fg-dim); }
|
||
.cargo-item .item-qty { color: var(--accent); font-variant-numeric: tabular-nums; }
|
||
|
||
/* ===== MINIMAP OVERLAY ===== */
|
||
.minimap-toggle {
|
||
position: absolute;
|
||
bottom: 170px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 10;
|
||
pointer-events: auto;
|
||
}
|
||
.minimap-btn {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
padding: 4px 14px;
|
||
background: rgba(15,22,35,0.8);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-pill);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
backdrop-filter: blur(6px);
|
||
transition: all 0.15s;
|
||
}
|
||
.minimap-btn:hover { color: var(--accent); border-color: var(--accent-border); }
|
||
.minimap-overlay {
|
||
position: absolute;
|
||
bottom: 195px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 400px;
|
||
height: 260px;
|
||
z-index: 9;
|
||
pointer-events: auto;
|
||
}
|
||
.minimap-overlay canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: var(--radius-lg);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
/* ===== NOTIFICATION TOAST ===== */
|
||
.toast-container {
|
||
position: absolute;
|
||
top: 52px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
align-items: center;
|
||
pointer-events: none;
|
||
}
|
||
.toast {
|
||
padding: 6px 18px;
|
||
border-radius: var(--radius-pill);
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
letter-spacing: 0.03em;
|
||
backdrop-filter: blur(8px);
|
||
animation: toast-in 0.3s ease, toast-out 0.3s ease 2.7s forwards;
|
||
pointer-events: auto;
|
||
}
|
||
.toast-info { background: rgba(34,211,238,0.12); border: 1px solid rgba(34,211,238,0.25); color: var(--cyan); }
|
||
.toast-warn { background: rgba(240,160,48,0.12); border: 1px solid rgba(240,160,48,0.25); color: var(--accent); }
|
||
.toast-danger { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.25); color: var(--red); }
|
||
@keyframes toast-in {
|
||
from { opacity: 0; transform: translateY(-8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
@keyframes toast-out {
|
||
from { opacity: 1; }
|
||
to { opacity: 0; transform: translateY(-4px); }
|
||
}
|
||
|
||
/* ===== ALIGNMENT GRID LINES (subtle) ===== */
|
||
.hud-gridline-v {
|
||
position: absolute;
|
||
top: 42px;
|
||
bottom: 0;
|
||
left: 50%;
|
||
width: 1px;
|
||
background: linear-gradient(180deg, rgba(34,211,238,0.06) 0%, transparent 40%, transparent 60%, rgba(34,211,238,0.06) 100%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.hud-gridline-h {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 50%;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, rgba(34,211,238,0.06) 0%, transparent 30%, transparent 70%, rgba(34,211,238,0.06) 100%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Action buttons (Warp to, Approach, Orbit, etc.) */
|
||
.action-bar {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
}
|
||
.action-btn {
|
||
padding: 3px 10px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--fg-dim);
|
||
font-family: var(--font-mono);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.action-btn:hover { border-color: var(--cyan-dim); color: var(--cyan); background: var(--cyan-bg); }
|
||
.action-btn.primary { border-color: var(--accent-border); color: var(--accent); }
|
||
.action-btn.primary:hover { background: var(--accent-bg); border-color: var(--accent); }
|
||
.action-btn.danger { border-color: rgba(239,68,68,0.3); color: var(--red); }
|
||
.action-btn.danger:hover { background: var(--red-bg); border-color: var(--red-dim); }
|
||
</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>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
||
|
||
/* ===== GAME STATE ===== */
|
||
const INITIAL_STATE = {
|
||
ship: {
|
||
name: 'USS Enterprise',
|
||
className: 'Venture-class Frigate',
|
||
system: 'Sol',
|
||
speed: 142,
|
||
maxSpeed: 250,
|
||
warpSpeed: 3000,
|
||
isWarping: false,
|
||
warpDestination: null,
|
||
shields: 100,
|
||
armor: 92,
|
||
hull: 88,
|
||
capacitor: 73,
|
||
x: 400, y: 300,
|
||
},
|
||
target: {
|
||
id: 'npc1',
|
||
name: 'Guristas Pirate',
|
||
type: 'NPC Frigate',
|
||
typeKey: 'hostile',
|
||
distance: 45,
|
||
shields: 78,
|
||
armor: 100,
|
||
hull: 100,
|
||
locked: true,
|
||
bounty: 8500,
|
||
},
|
||
credits: 125740,
|
||
cargo: { used: 12400, total: 25000, items: [
|
||
{ name: 'Veldspar', qty: 8500 },
|
||
{ name: 'Scordite', qty: 2300 },
|
||
{ name: 'Kernite', qty: 400 },
|
||
{ name: 'Pyroxeres', qty: 1200 },
|
||
]},
|
||
modules: {
|
||
high: [
|
||
{ id: 'laser1', name: 'Mine Laser', icon: '⛏', active: false, cycle: 10 },
|
||
{ id: 'turret1', name: '150mm Rail', icon: '◆', active: true, cycle: 3 },
|
||
{ id: null },
|
||
],
|
||
med: [
|
||
{ id: 'shield1', name: 'Shield Bst', icon: '◎', active: false, cycle: 5 },
|
||
{ id: 'warp1', name: 'Afterburn', icon: '»', active: true, cycle: 0 },
|
||
{ id: 'scram1', name: 'Scrambler', icon: '↯', active: false, cycle: 0 },
|
||
],
|
||
low: [
|
||
{ id: 'armor1', name: 'Armor Plt', icon: '▭', active: false, cycle: 0 },
|
||
{ id: 'magstab1', name: 'Mag Field', icon: '⚡', active: false, cycle: 0 },
|
||
],
|
||
},
|
||
entities: [
|
||
{ id: 'npc1', name: 'Guristas Pirate', typeKey: 'hostile', dist: 45, icon: '✸' },
|
||
{ id: 'npc2', name: 'Serpentis Scout', typeKey: 'hostile', dist: 78, icon: '✸' },
|
||
{ id: 'ast1', name: 'Veldspar Belt', typeKey: 'asteroid', dist: 12, icon: '◉' },
|
||
{ id: 'ast2', name: 'Scordite Cluster', typeKey: 'asteroid', dist: 22, icon: '◉' },
|
||
{ id: 'ast3', name: 'Kernite Deposit', typeKey: 'asteroid', dist: 38, icon: '◉' },
|
||
{ id: 'stn1', name: 'Jita IV — Moon 4', typeKey: 'station', dist: 8, icon: '⬡' },
|
||
{ id: 'gate1', name: 'Amarr Gate', typeKey: 'gate', dist: 120, icon: '⊕' },
|
||
{ id: 'gate2', name: 'Hek Gate', typeKey: 'gate', dist: 95, icon: '⊕' },
|
||
{ id: 'pl1', name: 'CMDR LaForge', typeKey: 'player', dist: 55, icon: '◈' },
|
||
{ id: 'pl2', name: 'MinerBob', typeKey: 'player', dist: 68, icon: '◈' },
|
||
{ id: 'pl3', name: 'TraderAlice', typeKey: 'player', dist: 142, icon: '◈' },
|
||
],
|
||
chat: {
|
||
activeTab: 'local',
|
||
messages: [
|
||
{ sender: 'CMDR Picard', body: 'Heading to Jita with a cargo of Kernite.', time: '14:22' },
|
||
{ sender: 'CMDR Worf', body: 'Pirates spotted near U-IRTYR gate. Stay alert.', time: '14:25' },
|
||
{ sender: 'CMDR Data', body: 'Scordite prices up 12% in Amarr this hour.', time: '14:28' },
|
||
{ sender: 'CMDR Troi', body: 'Anyone want to form a mining fleet in Sol?', time: '14:31' },
|
||
],
|
||
},
|
||
system: { name: 'Sol', security: 1.0, type: 'G2V Star', planets: 8 },
|
||
selectedOverview: 'npc1',
|
||
showMinimap: false,
|
||
serverTime: '14:34:07',
|
||
connected: true,
|
||
};
|
||
|
||
function useGameState() {
|
||
const [state, setState] = useState(INITIAL_STATE);
|
||
const tick = useCallback(() => {
|
||
setState(s => {
|
||
let nextSpeed = s.ship.speed;
|
||
if (s.ship.isWarping) {
|
||
nextSpeed = s.ship.warpSpeed;
|
||
}
|
||
const sec = parseInt(s.serverTime.split(':')[2]);
|
||
const min = parseInt(s.serverTime.split(':')[1]);
|
||
const hr = parseInt(s.serverTime.split(':')[0]);
|
||
const totalSec = hr * 3600 + min * 60 + sec + 1;
|
||
const nh = Math.floor(totalSec / 3600) % 24;
|
||
const nm = Math.floor((totalSec % 3600) / 60);
|
||
const ns = totalSec % 60;
|
||
return {
|
||
...s,
|
||
serverTime: `${String(nh).padStart(2,'0')}:${String(nm).padStart(2,'0')}:${String(ns).padStart(2,'0')}`,
|
||
ship: { ...s.ship, speed: nextSpeed + (Math.random() - 0.5) * 2 | 0 },
|
||
};
|
||
});
|
||
}, []);
|
||
return [state, setState, tick];
|
||
}
|
||
|
||
/* ===== SPACE VIEWPORT CANVAS ===== */
|
||
function SpaceViewport({ ship, target }) {
|
||
const canvasRef = useRef(null);
|
||
const frameRef = useRef(null);
|
||
const starsRef = useRef([]);
|
||
const tRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
const stars = [];
|
||
for (let i = 0; i < 300; i++) {
|
||
stars.push({
|
||
x: Math.random(),
|
||
y: Math.random(),
|
||
size: 0.5 + Math.random() * 1.5,
|
||
brightness: 0.2 + Math.random() * 0.6,
|
||
twinkleSpeed: 0.5 + Math.random() * 2,
|
||
twinkleOffset: Math.random() * Math.PI * 2,
|
||
});
|
||
}
|
||
starsRef.current = stars;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const resize = () => {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
};
|
||
resize();
|
||
window.addEventListener('resize', resize);
|
||
|
||
const draw = () => {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.width / dpr;
|
||
const h = canvas.height / dpr;
|
||
tRef.current += 0.016;
|
||
const t = tRef.current;
|
||
|
||
ctx.save();
|
||
ctx.scale(dpr, dpr);
|
||
|
||
// Deep space background
|
||
const bgGrad = ctx.createRadialGradient(w * 0.5, h * 0.5, 0, w * 0.5, h * 0.5, w * 0.6);
|
||
bgGrad.addColorStop(0, '#0a0e18');
|
||
bgGrad.addColorStop(0.6, '#070a12');
|
||
bgGrad.addColorStop(1, '#040610');
|
||
ctx.fillStyle = bgGrad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Nebula glow
|
||
const neb1 = ctx.createRadialGradient(w * 0.2, h * 0.3, 0, w * 0.2, h * 0.3, w * 0.35);
|
||
neb1.addColorStop(0, 'rgba(34,211,238,0.03)');
|
||
neb1.addColorStop(0.5, 'rgba(34,211,238,0.01)');
|
||
neb1.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = neb1;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const neb2 = ctx.createRadialGradient(w * 0.8, h * 0.7, 0, w * 0.8, h * 0.7, w * 0.3);
|
||
neb2.addColorStop(0, 'rgba(240,160,48,0.025)');
|
||
neb2.addColorStop(0.5, 'rgba(240,160,48,0.008)');
|
||
neb2.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = neb2;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Stars
|
||
const speedFactor = ship.isWarping ? 40 : 0;
|
||
starsRef.current.forEach(s => {
|
||
const twinkle = 0.5 + 0.5 * Math.sin(t * s.twinkleSpeed + s.twinkleOffset);
|
||
const alpha = s.brightness * twinkle;
|
||
let sx = s.x * w;
|
||
let sy = s.y * h;
|
||
|
||
if (ship.isWarping) {
|
||
const cx = w / 2;
|
||
const cy = h / 2;
|
||
const dx = sx - cx;
|
||
const dy = sy - cy;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
const angle = Math.atan2(dy, dx);
|
||
const stretch = Math.min(dist * 0.3, speedFactor);
|
||
ctx.strokeStyle = `rgba(200,220,255,${alpha * 0.8})`;
|
||
ctx.lineWidth = s.size * 0.6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy);
|
||
ctx.lineTo(sx + Math.cos(angle) * stretch, sy + Math.sin(angle) * stretch);
|
||
ctx.stroke();
|
||
} else {
|
||
ctx.fillStyle = `rgba(200,220,255,${alpha})`;
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, s.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
});
|
||
|
||
// Grid overlay (subtle)
|
||
ctx.strokeStyle = 'rgba(34,211,238,0.015)';
|
||
ctx.lineWidth = 0.5;
|
||
const gridSize = 80;
|
||
for (let x = gridSize; x < w; x += gridSize) {
|
||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||
}
|
||
for (let y = gridSize; y < h; y += gridSize) {
|
||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||
}
|
||
|
||
// Sun glow (off-screen light source)
|
||
const sunGrad = ctx.createRadialGradient(-50, h * 0.4, 0, -50, h * 0.4, w * 0.4);
|
||
sunGrad.addColorStop(0, 'rgba(251,191,36,0.06)');
|
||
sunGrad.addColorStop(0.3, 'rgba(251,191,36,0.02)');
|
||
sunGrad.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = sunGrad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Player ship (small icon at center-bottom)
|
||
const shipX = w / 2;
|
||
const shipY = h * 0.55;
|
||
ctx.save();
|
||
ctx.translate(shipX, shipY);
|
||
|
||
// Ship body
|
||
ctx.fillStyle = 'rgba(212,220,232,0.9)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -10);
|
||
ctx.lineTo(-6, 8);
|
||
ctx.lineTo(0, 5);
|
||
ctx.lineTo(6, 8);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
// Engine glow
|
||
if (ship.speed > 10) {
|
||
const engineGlow = ctx.createRadialGradient(0, 12, 0, 0, 12, 8 + ship.speed / 20);
|
||
engineGlow.addColorStop(0, 'rgba(34,211,238,0.6)');
|
||
engineGlow.addColorStop(0.5, 'rgba(34,211,238,0.15)');
|
||
engineGlow.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = engineGlow;
|
||
ctx.fillRect(-10, 8, 20, 20);
|
||
}
|
||
|
||
ctx.restore();
|
||
|
||
// Target reticle on hostile
|
||
if (target && target.locked) {
|
||
const tx = w * 0.6 + Math.sin(t * 0.5) * 5;
|
||
const ty = h * 0.35 + Math.cos(t * 0.7) * 3;
|
||
const reticleSize = 18;
|
||
|
||
ctx.strokeStyle = 'rgba(239,68,68,0.6)';
|
||
ctx.lineWidth = 1;
|
||
// Outer ring
|
||
ctx.beginPath();
|
||
ctx.arc(tx, ty, reticleSize, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// Corner brackets
|
||
const bSize = reticleSize + 4;
|
||
ctx.strokeStyle = 'rgba(239,68,68,0.8)';
|
||
ctx.lineWidth = 1.5;
|
||
// Top-left
|
||
ctx.beginPath(); ctx.moveTo(tx - bSize, ty - bSize + 8); ctx.lineTo(tx - bSize, ty - bSize); ctx.lineTo(tx - bSize + 8, ty - bSize); ctx.stroke();
|
||
// Top-right
|
||
ctx.beginPath(); ctx.moveTo(tx + bSize, ty - bSize + 8); ctx.lineTo(tx + bSize, ty - bSize); ctx.lineTo(tx + bSize - 8, ty - bSize); ctx.stroke();
|
||
// Bottom-left
|
||
ctx.beginPath(); ctx.moveTo(tx - bSize, ty + bSize - 8); ctx.lineTo(tx - bSize, ty + bSize); ctx.lineTo(tx - bSize + 8, ty + bSize); ctx.stroke();
|
||
// Bottom-right
|
||
ctx.beginPath(); ctx.moveTo(tx + bSize, ty + bSize - 8); ctx.lineTo(tx + bSize, ty + bSize); ctx.lineTo(tx + bSize - 8, ty + bSize); ctx.stroke();
|
||
|
||
// Target ship icon
|
||
ctx.fillStyle = 'rgba(239,68,68,0.8)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, ty - 6);
|
||
ctx.lineTo(tx - 4, ty + 5);
|
||
ctx.lineTo(tx, ty + 3);
|
||
ctx.lineTo(tx + 4, ty + 5);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
// Distance label
|
||
ctx.font = '10px "JetBrains Mono", monospace';
|
||
ctx.fillStyle = 'rgba(239,68,68,0.7)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`${target.distance} km`, tx, ty + bSize + 14);
|
||
}
|
||
|
||
// Asteroid belt objects
|
||
const beltObjects = [
|
||
{ x: w * 0.25, y: h * 0.4, size: 3 },
|
||
{ x: w * 0.22, y: h * 0.42, size: 2 },
|
||
{ x: w * 0.28, y: h * 0.38, size: 2.5 },
|
||
{ x: w * 0.2, y: h * 0.45, size: 1.8 },
|
||
{ x: w * 0.3, y: h * 0.43, size: 2.2 },
|
||
];
|
||
beltObjects.forEach(a => {
|
||
ctx.fillStyle = `rgba(148,163,184,${0.3 + Math.random() * 0.2})`;
|
||
ctx.beginPath();
|
||
ctx.arc(a.x, a.y, a.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
});
|
||
|
||
// Station marker
|
||
const stnX = w * 0.15;
|
||
const stnY = h * 0.55;
|
||
ctx.strokeStyle = 'rgba(34,197,94,0.4)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.rect(stnX - 6, stnY - 4, 12, 8);
|
||
ctx.stroke();
|
||
ctx.fillStyle = 'rgba(34,197,94,0.2)';
|
||
ctx.fill();
|
||
|
||
// Warp tunnel effect
|
||
if (ship.isWarping) {
|
||
const tunnelGrad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w * 0.5);
|
||
tunnelGrad.addColorStop(0, 'rgba(34,211,238,0.03)');
|
||
tunnelGrad.addColorStop(0.7, 'rgba(34,211,238,0.01)');
|
||
tunnelGrad.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = tunnelGrad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
}
|
||
|
||
ctx.restore();
|
||
frameRef.current = requestAnimationFrame(draw);
|
||
};
|
||
|
||
frameRef.current = requestAnimationFrame(draw);
|
||
return () => {
|
||
window.removeEventListener('resize', resize);
|
||
cancelAnimationFrame(frameRef.current);
|
||
};
|
||
}, [ship.isWarping, ship.speed, target]);
|
||
|
||
return (
|
||
<div className="hud-viewport">
|
||
<canvas ref={canvasRef} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== MINIMAP CANVAS ===== */
|
||
function MinimapCanvas({ show }) {
|
||
const canvasRef = useRef(null);
|
||
if (!show) return null;
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = 400 * dpr;
|
||
canvas.height = 260 * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const systems = [
|
||
{ id: 'sol', name: 'Sol', x: 160, y: 130, security: 1.0, color: '#22c55e' },
|
||
{ id: 'amarr', name: 'Amarr', x: 250, y: 70, security: 0.9, color: '#22c55e' },
|
||
{ id: 'hek', name: 'Hek', x: 110, y: 60, security: 0.7, color: '#f0a030' },
|
||
{ id: 'rens', name: 'Rens', x: 80, y: 170, security: 0.6, color: '#f0a030' },
|
||
{ id: 'dodixie', name: 'Dodixie', x: 220, y: 200, security: 0.8, color: '#22c55e' },
|
||
{ id: 'u-irtyr', name: 'U-IRTYR', x: 50, y: 110, security: 0.3, color: '#ef4444' },
|
||
{ id: 'pf-346', name: 'PF-346', x: 310, y: 150, security: 0.2, color: '#ef4444' },
|
||
{ id: 'owamw', name: 'O-WAMW', x: 350, y: 50, security: 0.0, color: '#991b1b' },
|
||
{ id: 'yzlql', name: 'YZ-LQL', x: 30, y: 210, security: 0.1, color: '#dc2626' },
|
||
];
|
||
const connections = [
|
||
['sol','amarr'],['sol','hek'],['sol','rens'],
|
||
['amarr','dodixie'],['amarr','pf-346'],['amarr','owamw'],
|
||
['hek','u-irtyr'],['hek','rens'],
|
||
['rens','u-irtyr'],['rens','yzlql'],
|
||
['dodixie','pf-346'],['dodixie','sol'],
|
||
['u-irtyr','yzlql'],['pf-346','owamw'],
|
||
];
|
||
|
||
ctx.fillStyle = '#060a12';
|
||
ctx.fillRect(0, 0, 400, 260);
|
||
|
||
connections.forEach(([a, b]) => {
|
||
const sa = systems.find(s => s.id === a);
|
||
const sb = systems.find(s => s.id === b);
|
||
ctx.beginPath();
|
||
ctx.moveTo(sa.x, sa.y);
|
||
ctx.lineTo(sb.x, sb.y);
|
||
ctx.strokeStyle = 'rgba(28,42,63,0.8)';
|
||
ctx.lineWidth = 0.8;
|
||
ctx.stroke();
|
||
});
|
||
|
||
systems.forEach(s => {
|
||
const isCurrent = s.id === 'sol';
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, isCurrent ? 6 : 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = isCurrent ? '#f0a030' : s.color;
|
||
ctx.fill();
|
||
if (isCurrent) {
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, 10, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(240,160,48,0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
}
|
||
ctx.font = '9px "JetBrains Mono", monospace';
|
||
ctx.fillStyle = isCurrent ? '#f0a030' : 'rgba(148,163,184,0.7)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(s.name, s.x, s.y + (isCurrent ? 18 : 14));
|
||
});
|
||
}, []);
|
||
|
||
return (
|
||
<div className="minimap-overlay">
|
||
<canvas ref={canvasRef} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== TOP BAR ===== */
|
||
function TopBar({ system, ship, credits, serverTime, connected }) {
|
||
const secClass = system.security >= 0.5 ? 'sec-high' : system.security >= 0.1 ? 'sec-low' : 'sec-null';
|
||
const secLabel = system.security >= 0.5 ? 'HIGH SEC' : system.security >= 0.1 ? 'LOW SEC' : 'NULL SEC';
|
||
return (
|
||
<div className="hud-topbar">
|
||
<span className="system-name">{system.name}</span>
|
||
<span className={`sec-status ${secClass}`}>{system.security.toFixed(1)} {secLabel}</span>
|
||
<div className="topbar-sep" />
|
||
<span className="topbar-label">Type</span>
|
||
<span className="topbar-value">{system.type}</span>
|
||
<div className="topbar-sep" />
|
||
<span className="topbar-label">Speed</span>
|
||
<span className="topbar-value" style={{ color: ship.isWarping ? 'var(--cyan)' : 'var(--fg-dim)' }}>
|
||
{ship.isWarping ? ship.warpSpeed : Math.abs(ship.speed)} m/s
|
||
</span>
|
||
<div className="topbar-sep" />
|
||
<span className="topbar-label">Wallet</span>
|
||
<span className="topbar-value credits-value">{credits.toLocaleString()} ISK</span>
|
||
<div className="topbar-sep" />
|
||
<span className="topbar-label">Players</span>
|
||
<span className="topbar-value">1,247</span>
|
||
<div style={{ flex: 1 }} />
|
||
<div className="status-indicator">
|
||
<span className="status-dot" style={connected ? {} : { background: 'var(--red)', boxShadow: 'none' }} />
|
||
<span className="topbar-value" style={{ color: connected ? 'var(--green)' : 'var(--red)' }}>
|
||
{connected ? 'CONNECTED' : 'OFFLINE'}
|
||
</span>
|
||
</div>
|
||
<div className="topbar-sep" />
|
||
<span className="topbar-value" style={{ fontVariantNumeric: 'tabular-nums' }}>{serverTime}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== LEFT PANELS ===== */
|
||
function ShipPanel({ ship }) {
|
||
const warpClass = ship.isWarping ? '' : 'idle';
|
||
const warpText = ship.isWarping ? `WARPING → ${ship.warpDestination || '...'}` : 'SUBLIGHT';
|
||
return (
|
||
<div className="hud-left">
|
||
<div className="hud-panel">
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" />
|
||
<span>Ship Status</span>
|
||
</div>
|
||
<div className="hud-panel-body">
|
||
<div className="ship-info" style={{ marginBottom: 10 }}>
|
||
<div className="ship-name-row">
|
||
<span className="ship-name">{ship.name}</span>
|
||
<span className="ship-class">{ship.className.split(' ')[0]}</span>
|
||
</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-dim)' }}>
|
||
{ship.className} · {ship.system}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="health-bar-group">
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--cyan)' }}>Shield</span>
|
||
<div className="health-track">
|
||
<div className="health-fill shield" style={{ width: `${ship.shields}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ color: 'var(--cyan)' }}>{ship.shields}%</span>
|
||
</div>
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--accent)' }}>Armor</span>
|
||
<div className="health-track">
|
||
<div className="health-fill armor" style={{ width: `${ship.armor}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ color: 'var(--accent)' }}>{ship.armor}%</span>
|
||
</div>
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--green)' }}>Hull</span>
|
||
<div className="health-track">
|
||
<div className="health-fill hull" style={{ width: `${ship.hull}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ color: 'var(--green)' }}>{ship.hull}%</span>
|
||
</div>
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--purple)' }}>Cap</span>
|
||
<div className="health-track">
|
||
<div className="health-fill cap" style={{ width: `${ship.capacitor}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ color: 'var(--purple)' }}>{ship.capacitor}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hud-panel">
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" />
|
||
<span>Propulsion</span>
|
||
</div>
|
||
<div className="hud-panel-body">
|
||
<div className="speed-dial">
|
||
<div className="speed-display">
|
||
{ship.isWarping ? ship.warpSpeed : Math.abs(ship.speed)}
|
||
<span className="speed-unit">{ship.isWarping ? 'AU/s' : 'm/s'}</span>
|
||
</div>
|
||
<div className="speed-controls">
|
||
<button className="speed-btn">−</button>
|
||
<div className="speed-bar-track">
|
||
<div className="speed-bar-fill"
|
||
style={{ width: `${(ship.isWarping ? 100 : (Math.abs(ship.speed) / ship.maxSpeed) * 100)}%` }} />
|
||
</div>
|
||
<button className="speed-btn">+</button>
|
||
<button className="speed-btn" style={{ fontSize: 9, width: 'auto', padding: '0 8px' }}>WARP</button>
|
||
</div>
|
||
</div>
|
||
<div className={`warp-indicator ${warpClass}`} style={{ marginTop: 8 }}>
|
||
{warpText}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== RIGHT PANEL — Overview ===== */
|
||
function OverviewPanel({ entities, selectedId, onSelect }) {
|
||
const typeColor = (typeKey) => {
|
||
switch (typeKey) {
|
||
case 'hostile': return 'var(--red)';
|
||
case 'asteroid': return 'var(--accent)';
|
||
case 'station': return 'var(--green)';
|
||
case 'gate': return 'var(--cyan)';
|
||
case 'player': return 'var(--purple)';
|
||
default: return 'var(--muted)';
|
||
}
|
||
};
|
||
const sorted = [...entities].sort((a, b) => a.dist - b.dist);
|
||
return (
|
||
<div className="hud-right">
|
||
<div className="hud-panel" style={{ flex: 1 }}>
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" />
|
||
<span>Overview</span>
|
||
<span style={{ marginLeft: 'auto', color: 'var(--fg-dim)', fontSize: 10 }}>
|
||
{entities.length} entities
|
||
</span>
|
||
</div>
|
||
<div className="overview-scroll">
|
||
<table className="overview-table">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Name</th>
|
||
<th style={{ textAlign: 'right' }}>Dist</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map(e => (
|
||
<tr key={e.id}
|
||
className={selectedId === e.id ? 'selected' : ''}
|
||
onClick={() => onSelect(e.id)}>
|
||
<td className="entity-icon" style={{ color: typeColor(e.typeKey) }}>{e.icon}</td>
|
||
<td className="entity-name">{e.name}</td>
|
||
<td className="entity-dist" style={{ color: typeColor(e.typeKey) }}>
|
||
{e.dist.toLocaleString()} km
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selected entity detail */}
|
||
{selectedId && (() => {
|
||
const ent = entities.find(e => e.id === selectedId);
|
||
if (!ent) return null;
|
||
const typeColorVal = typeColor(ent.typeKey);
|
||
return (
|
||
<div className="hud-panel">
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" style={{ background: typeColorVal }} />
|
||
<span style={{ color: typeColorVal }}>{ent.name}</span>
|
||
</div>
|
||
<div className="hud-panel-body">
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-dim)', marginBottom: 6 }}>
|
||
{ent.typeKey.toUpperCase()} · {ent.dist.toLocaleString()} km
|
||
</div>
|
||
<div className="action-bar">
|
||
{ent.typeKey === 'asteroid' && (
|
||
<>
|
||
<button className="action-btn primary">Approach</button>
|
||
<button className="action-btn">Mine</button>
|
||
</>
|
||
)}
|
||
{ent.typeKey === 'hostile' && (
|
||
<>
|
||
<button className="action-btn danger">Lock</button>
|
||
<button className="action-btn danger">Orbit 20km</button>
|
||
</>
|
||
)}
|
||
{ent.typeKey === 'station' && (
|
||
<>
|
||
<button className="action-btn primary">Dock</button>
|
||
<button className="action-btn">Approach</button>
|
||
</>
|
||
)}
|
||
{ent.typeKey === 'gate' && (
|
||
<>
|
||
<button className="action-btn primary">Jump</button>
|
||
<button className="action-btn">Approach</button>
|
||
</>
|
||
)}
|
||
{ent.typeKey === 'player' && (
|
||
<>
|
||
<button className="action-btn">Message</button>
|
||
<button className="action-btn">Fleet Invite</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== BOTTOM BAR ===== */
|
||
function BottomBar({ modules, target, cargo, chatState, onToggleModule }) {
|
||
return (
|
||
<div className="hud-bottom">
|
||
{/* Module Rack */}
|
||
<div className="hud-panel module-rack">
|
||
<div className="hud-panel-header" style={{ padding: '4px 0', borderBottom: 'none' }}>
|
||
<span className="panel-dot" />
|
||
<span>Modules</span>
|
||
<span style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--fg-dim)' }}>
|
||
{Object.values(modules).flat().filter(m => m.active).length} active
|
||
</span>
|
||
</div>
|
||
{['high', 'med', 'low'].map(slotType => (
|
||
<div className="module-row" key={slotType}>
|
||
<span className="module-row-label" style={{
|
||
color: slotType === 'high' ? 'var(--red)' : slotType === 'med' ? 'var(--cyan)' : 'var(--green)'
|
||
}}>
|
||
{slotType === 'high' ? 'HIGH' : slotType === 'med' ? 'MED' : 'LOW'}
|
||
</span>
|
||
{modules[slotType].map((mod, i) => (
|
||
mod.id ? (
|
||
<div key={mod.id}
|
||
className={`module-slot${mod.active ? ' active' : ''}`}
|
||
onClick={() => onToggleModule(slotType, i)}
|
||
title={`${mod.name} (${mod.active ? 'Active' : 'Inactive'})`}>
|
||
<span className="mod-icon">{mod.icon}</span>
|
||
<span className="mod-label">{mod.name}</span>
|
||
</div>
|
||
) : (
|
||
<div key={`empty-${i}`} className="module-slot empty">
|
||
<span className="mod-icon" style={{ opacity: 0.3 }}>—</span>
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Target info */}
|
||
<div className="hud-panel bottom-target">
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" style={{ background: 'var(--red)' }} />
|
||
<span>Target</span>
|
||
</div>
|
||
<div className="hud-panel-body">
|
||
{target ? (
|
||
<>
|
||
<div className="bt-name" style={{ color: 'var(--red)' }}>{target.name}</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-dim)', marginBottom: 6 }}>
|
||
{target.type} · {target.locked ? 'LOCKED' : 'LOCKING...'}
|
||
</div>
|
||
<div className="health-bar-group">
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--cyan)', fontSize: 9 }}>SH</span>
|
||
<div className="health-track">
|
||
<div className="health-fill shield" style={{ width: `${target.shields}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ fontSize: 10 }}>{target.shields}%</span>
|
||
</div>
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--accent)', fontSize: 9 }}>AR</span>
|
||
<div className="health-track">
|
||
<div className="health-fill armor" style={{ width: `${target.armor}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ fontSize: 10 }}>{target.armor}%</span>
|
||
</div>
|
||
<div className="health-row">
|
||
<span className="health-label" style={{ color: 'var(--green)', fontSize: 9 }}>HU</span>
|
||
<div className="health-track">
|
||
<div className="health-fill hull" style={{ width: `${target.hull}%` }} />
|
||
</div>
|
||
<span className="health-pct" style={{ fontSize: 10 }}>{target.hull}%</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6, fontFamily: 'var(--font-mono)', fontSize: 10 }}>
|
||
<span style={{ color: 'var(--cyan)' }}>{target.distance.toLocaleString()} km</span>
|
||
<span style={{ color: 'var(--accent)' }}> Bounty: {target.bounty.toLocaleString()} ISK</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', textAlign: 'center', padding: '12px 0' }}>
|
||
No target selected
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cargo */}
|
||
<div className="hud-panel bottom-cargo">
|
||
<div className="hud-panel-header">
|
||
<span className="panel-dot" style={{ background: 'var(--accent)' }} />
|
||
<span>Cargo Hold</span>
|
||
</div>
|
||
<div className="hud-panel-body">
|
||
<div className="cargo-capacity">
|
||
<span className="cap-label">{cargo.used.toLocaleString()} / {cargo.total.toLocaleString()} m³</span>
|
||
<span className="cap-value">{Math.round(cargo.used / cargo.total * 100)}%</span>
|
||
</div>
|
||
<div className="cargo-bar">
|
||
<div className="cargo-bar-fill" style={{ width: `${(cargo.used / cargo.total) * 100}%` }} />
|
||
</div>
|
||
<div className="cargo-items">
|
||
{cargo.items.map((item, i) => (
|
||
<div className="cargo-item" key={i}>
|
||
<span className="item-name">{item.name}</span>
|
||
<span className="item-qty">×{item.qty.toLocaleString()}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat */}
|
||
<div className="hud-panel bottom-chat">
|
||
<div className="chat-tabs">
|
||
{['local', 'corp', 'trade'].map(tab => (
|
||
<button key={tab}
|
||
className={`chat-tab${chatState.activeTab === tab ? ' active' : ''}`}>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="chat-messages">
|
||
{chatState.messages.map((msg, i) => (
|
||
<div className="chat-msg" key={i}>
|
||
<span className="msg-sender">{msg.sender}</span>
|
||
{msg.body}
|
||
<span className="msg-time">{msg.time}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="chat-input-row">
|
||
<input className="chat-input" placeholder="Send message..." />
|
||
<button className="chat-send-btn">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== TOAST NOTIFICATIONS ===== */
|
||
function Toasts() {
|
||
const toasts = [
|
||
{ id: 1, text: '150mm Railgun activated — cycling', type: 'info' },
|
||
];
|
||
return (
|
||
<div className="toast-container">
|
||
{toasts.map(t => (
|
||
<div key={t.id} className={`toast toast-${t.type}`}>{t.text}</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== MAIN APP ===== */
|
||
function GameHUD() {
|
||
const [state, setState, tick] = useGameState();
|
||
const [toasts, setToasts] = useState([]);
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(tick, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [tick]);
|
||
|
||
const handleToggleModule = useCallback((slotType, index) => {
|
||
setState(s => {
|
||
const newModules = { ...s.modules };
|
||
const row = [...newModules[slotType]];
|
||
row[index] = { ...row[index], active: !row[index].active };
|
||
newModules[slotType] = row;
|
||
return { ...s, modules: newModules };
|
||
});
|
||
}, [setState]);
|
||
|
||
const handleOverviewSelect = useCallback((id) => {
|
||
setState(s => ({ ...s, selectedOverview: id }));
|
||
}, [setState]);
|
||
|
||
const handleToggleMinimap = useCallback(() => {
|
||
setState(s => ({ ...s, showMinimap: !s.showMinimap }));
|
||
}, [setState]);
|
||
|
||
return (
|
||
<div className="hud-root">
|
||
<SpaceViewport ship={state.ship} target={state.target} />
|
||
|
||
<div className="hud-overlay">
|
||
<TopBar
|
||
system={state.system}
|
||
ship={state.ship}
|
||
credits={state.credits}
|
||
serverTime={state.serverTime}
|
||
connected={state.connected}
|
||
/>
|
||
|
||
<div className="hud-middle">
|
||
<ShipPanel ship={state.ship} />
|
||
|
||
<div className="hud-center">
|
||
<div className="hud-gridline-v" />
|
||
<div className="hud-gridline-h" />
|
||
<div className="crosshair">
|
||
<div className="crosshair-ring" />
|
||
</div>
|
||
|
||
<MinimapCanvas show={state.showMinimap} />
|
||
<div className="minimap-toggle">
|
||
<button className="minimap-btn" onClick={handleToggleMinimap}>
|
||
{state.showMinimap ? 'Close Map' : 'Star Map'}
|
||
</button>
|
||
</div>
|
||
|
||
<Toasts />
|
||
</div>
|
||
|
||
<OverviewPanel
|
||
entities={state.entities}
|
||
selectedId={state.selectedOverview}
|
||
onSelect={handleOverviewSelect}
|
||
/>
|
||
</div>
|
||
|
||
<BottomBar
|
||
modules={state.modules}
|
||
target={state.target}
|
||
cargo={state.cargo}
|
||
chatState={state.chat}
|
||
onToggleModule={handleToggleModule}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<GameHUD />);
|
||
</script>
|
||
</body>
|
||
</html>
|