Files
Space-Game/js/demos/fitting.js
2026-05-25 13:00:20 -04:00

378 lines
19 KiB
JavaScript

window.GDD = window.GDD || {};
const { useState, useEffect, useCallback, useMemo } = React;
function FittingDemo() {
const [ship, setShip] = useState(null);
const [ships, setShips] = useState([]);
const [availableModules, setAvailableModules] = useState([]);
const [fittedModules, setFittedModules] = useState({ high: [], med: [], low: [] });
const [selectedModule, setSelectedModule] = useState(null);
const [filterSlot, setFilterSlot] = useState('all');
const [notifications, setNotifications] = useState([]);
useEffect(() => {
window.GDD.api.getPlayerShips().then(s => {
setShips(s);
if (s.length > 0) setShip(s[0]);
});
window.GDD.api.getAvailableModules().then(m => setAvailableModules(m));
}, []);
useEffect(() => {
if (!ship) return;
window.GDD.api.getShipFittings(ship.id).then(fitted => {
const slots = { high: [], med: [], low: [] };
fitted.forEach(m => {
if (m.slot === 'high') slots.high.push(m);
else if (m.slot === 'med') slots.med.push(m);
else if (m.slot === 'low') slots.low.push(m);
});
setFittedModules(slots);
});
}, [ship]);
const addNotif = useCallback((msg, color) => {
const id = Date.now();
setNotifications(prev => [...prev, { id, msg, color }]);
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3000);
}, []);
const cpuUsage = useMemo(() => {
let total = 0;
Object.values(fittedModules).flat().forEach(m => total += m.cpu);
return total;
}, [fittedModules]);
const gridUsage = useMemo(() => {
let total = 0;
Object.values(fittedModules).flat().forEach(m => total += m.power);
return total;
}, [fittedModules]);
const cpuMax = ship ? ship.cpu : 0;
const gridMax = ship ? ship.powerGrid : 0;
const cpuOver = cpuUsage > cpuMax;
const gridOver = gridUsage > gridMax;
const handleFit = useCallback((mod) => {
if (!ship) return;
const slot = mod.slot;
const maxSlots = slot === 'high' ? ship.highSlots : slot === 'med' ? ship.medSlots : ship.lowSlots;
const currentCount = fittedModules[slot].length;
if (currentCount >= maxSlots) {
addNotif(`No empty ${slot} slots available.`, 'var(--red)');
return;
}
const newCpu = cpuUsage + mod.cpu;
const newGrid = gridUsage + mod.power;
if (newCpu > cpuMax) {
addNotif(`CPU exceeded: ${newCpu}/${cpuMax}. Remove a module first.`, 'var(--red)');
return;
}
if (newGrid > gridMax) {
addNotif(`Power Grid exceeded: ${newGrid}/${gridMax}. Remove a module first.`, 'var(--red)');
return;
}
setFittedModules(prev => ({
...prev,
[slot]: [...prev[slot], { ...mod, uid: Date.now() + Math.random() }],
}));
addNotif(`${mod.name} fitted to ${slot} slot.`, 'var(--green)');
}, [ship, fittedModules, cpuUsage, gridUsage, cpuMax, gridMax, addNotif]);
const handleUnfit = useCallback((slot, index) => {
const mod = fittedModules[slot][index];
setFittedModules(prev => ({
...prev,
[slot]: prev[slot].filter((_, i) => i !== index),
}));
addNotif(`${mod.name} removed from ${slot} slot.`, 'var(--muted)');
}, [fittedModules, addNotif]);
const filteredModules = filterSlot === 'all'
? availableModules
: availableModules.filter(m => m.slot === filterSlot);
const slotConfig = [
{ key: 'high', label: 'High Slots', color: 'var(--red)', icon: '◆', max: ship?.highSlots || 0 },
{ key: 'med', label: 'Medium Slots', color: 'var(--cyan)', icon: '◇', max: ship?.medSlots || 0 },
{ key: 'low', label: 'Low Slots', color: 'var(--green)', icon: '○', max: ship?.lowSlots || 0 },
];
const moduleTypeIcon = (type) => {
switch(type) {
case 'weapon': return '⊕';
case 'shield': return '◎';
case 'mining': return '⛏';
case 'propulsion': return '»';
case 'ewar': return '◎';
case 'armor': return '◼';
case 'damage_mod': return '↯';
case 'cargo': return '□';
default: return '•';
}
};
if (!ship) return <div className="content-inner"><p>Loading ship data...</p></div>;
return (
<div className="content-inner">
<a href="#overview" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', color: 'var(--muted)', textDecoration: 'none', marginBottom: 'var(--sp-3)', transition: 'color 0.15s' }} onMouseEnter={e => e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}> Back to Docs</a>
<h1 style={{ marginBottom: '8px' }}>Ship Fitting Demo</h1>
<p style={{ color: 'var(--fg-dim)', fontSize: '0.9rem' }}>
Drag modules into slot bays. CPU and Power Grid are hard limits overfitting is blocked.
Select a ship and build your loadout.
</p>
{/* Notifications */}
<div style={{ position: 'fixed', top: 'var(--sp-4)', right: 'var(--sp-4)', zIndex: 1000, display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)' }}>
{notifications.map(n => (
<div key={n.id} style={{
background: 'var(--surface)', border: `1px solid ${n.color}40`,
borderRadius: 'var(--radius-md)', padding: 'var(--sp-3) var(--sp-4)',
fontSize: '0.8rem', color: n.color,
boxShadow: 'var(--shadow-md)',
}}>
{n.msg}
</div>
))}
</div>
{/* HUD-style fitting strip */}
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--sp-4)',
padding: 'var(--sp-3) var(--sp-4)', marginTop: 'var(--sp-4)', marginBottom: 'var(--sp-3)',
background: 'rgba(15,22,35,0.88)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(8px)',
fontFamily: 'var(--font-mono)', fontSize: '0.75rem',
}}>
<span style={{ color: 'var(--fg-bright)', fontWeight: 600, fontSize: '0.8rem' }}>{ship?.name || 'Loading...'}</span>
{ship && <span style={{ color: 'var(--muted)', fontSize: '0.7rem' }}>{ship.class}</span>}
{ship && <div style={{ width: 1, height: 18, background: 'var(--border-light)' }} />}
{ship && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: cpuOver ? '#ef4444' : 'var(--cyan)', fontSize: '0.65rem' }}>CPU</span>
<div style={{ width: '80px', height: '5px', background: 'rgba(255,255,255,0.04)', borderRadius: 'var(--radius-pill)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${Math.min(100, (cpuUsage / cpuMax) * 100)}%`, background: cpuOver ? '#ef4444' : cpuUsage / cpuMax > 0.8 ? '#f0a030' : '#22d3ee', borderRadius: 'var(--radius-pill)' }} />
</div>
<span style={{ color: cpuOver ? '#ef4444' : 'var(--fg-dim)', fontSize: '0.7rem' }}>{cpuUsage}/{cpuMax}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: gridOver ? '#ef4444' : 'var(--green)', fontSize: '0.65rem' }}>PWR</span>
<div style={{ width: '80px', height: '5px', background: 'rgba(255,255,255,0.04)', borderRadius: 'var(--radius-pill)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${Math.min(100, (gridUsage / gridMax) * 100)}%`, background: gridOver ? '#ef4444' : gridUsage / gridMax > 0.8 ? '#f0a030' : '#22c55e', borderRadius: 'var(--radius-pill)' }} />
</div>
<span style={{ color: gridOver ? '#ef4444' : 'var(--fg-dim)', fontSize: '0.7rem' }}>{gridUsage}/{gridMax}</span>
</div>
</>
)}
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: '0.7rem' }}>{ship?.system || ''} · {ship?.status === 'docked' ? '● DOCKED' : '○ IN SPACE'}</span>
</div>
{/* Ship selector */}
<div style={{ display: 'flex', gap: 'var(--sp-3)', marginBottom: 'var(--sp-5)', flexWrap: 'wrap' }}>
{ships.map(s => (
<button key={s.id} className={`btn btn-sm${ship?.id === s.id ? ' btn-primary' : ''}`}
onClick={() => setShip(s)}>
{s.name} <span style={{ color: ship?.id === s.id ? 'var(--bg)' : 'var(--muted)', marginLeft: 'var(--sp-2)' }}>{s.class}</span>
</button>
))}
</div>
{/* Ship stats + resource bars */}
<div className="demo-container">
<div className="demo-toolbar">
<span className="demo-title">Fitting Console</span>
<span style={{ color: 'var(--muted)' }}>|</span>
<span style={{ color: 'var(--fg-dim)', fontSize: '0.8rem' }}>{ship.name} · {ship.class}-class</span>
<span style={{ color: 'var(--muted)', marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: '0.7rem' }}>
{ship.system} · {ship.status === 'docked' ? '● DOCKED' : '○ IN SPACE'}
</span>
</div>
<div style={{ display: 'flex', minHeight: '420px' }}>
{/* Module browser */}
<div style={{ width: '280px', borderRight: '1px solid var(--border)', background: 'var(--surface-raised)' }}>
<div style={{ padding: 'var(--sp-3) var(--sp-4)', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--accent)', textTransform: 'uppercase', marginBottom: 'var(--sp-2)' }}>
Module Browser
</div>
<div style={{ display: 'flex', gap: 'var(--sp-1)' }}>
{['all', 'high', 'med', 'low'].map(f => (
<button key={f} className={`btn btn-sm${filterSlot === f ? ' btn-primary' : ''}`}
style={{ padding: '2px 8px', fontSize: '0.65rem' }}
onClick={() => setFilterSlot(f)}>
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
<div style={{ overflowY: 'auto', maxHeight: '360px' }}>
{filteredModules.map(mod => (
<div key={mod.id} style={{
padding: 'var(--sp-2) var(--sp-4)',
borderBottom: '1px solid var(--border)',
cursor: 'pointer',
background: selectedModule?.id === mod.id ? 'var(--accent-bg)' : 'transparent',
transition: 'background 0.1s',
}}
onClick={() => setSelectedModule(mod)}
onDoubleClick={() => handleFit(mod)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-2)' }}>
<span style={{
color: mod.slot === 'high' ? 'var(--red)' : mod.slot === 'med' ? 'var(--cyan)' : 'var(--green)',
fontSize: '0.75rem',
}}>
{moduleTypeIcon(mod.type)}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.75rem', color: 'var(--fg-bright)' }}>{mod.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.6rem', color: 'var(--muted)' }}>
{mod.cpu} CPU · {mod.power} PG · {mod.slot}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Fitting area */}
<div style={{ flex: 1, padding: 'var(--sp-5)' }}>
{/* CPU / Grid bars */}
<div style={{ marginBottom: 'var(--sp-5)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: cpuOver ? 'var(--red)' : 'var(--cyan)' }}>CPU</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: cpuOver ? 'var(--red)' : 'var(--fg-dim)' }}>
{cpuUsage} / {cpuMax} tf{cpuOver ? ' ⚠ OVER' : ''}
</span>
</div>
<div className="progress-bar" style={{ height: '8px', marginBottom: 'var(--sp-3)' }}>
<div className="fill" style={{
width: `${Math.min(100, (cpuUsage / cpuMax) * 100)}%`,
background: cpuOver ? 'var(--red)' : cpuUsage / cpuMax > 0.8 ? 'var(--accent)' : 'var(--cyan)',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: gridOver ? 'var(--red)' : 'var(--green)' }}>POWER GRID</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: gridOver ? 'var(--red)' : 'var(--fg-dim)' }}>
{gridUsage} / {gridMax} MW{gridOver ? ' ⚠ OVER' : ''}
</span>
</div>
<div className="progress-bar" style={{ height: '8px' }}>
<div className="fill" style={{
width: `${Math.min(100, (gridUsage / gridMax) * 100)}%`,
background: gridOver ? 'var(--red)' : gridUsage / gridMax > 0.8 ? 'var(--accent)' : 'var(--green)',
}} />
</div>
</div>
{/* Slot bays */}
{slotConfig.map(slot => (
<div key={slot.key} style={{ marginBottom: 'var(--sp-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-2)', marginBottom: 'var(--sp-3)' }}>
<span style={{ color: slot.color, fontSize: '1rem' }}>{slot.icon}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: slot.color }}>
{slot.label}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)', marginLeft: 'auto' }}>
{fittedModules[slot.key].length} / {slot.max}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${slot.max}, 1fr)`, gap: 'var(--sp-2)' }}>
{Array.from({ length: slot.max }).map((_, i) => {
const mod = fittedModules[slot.key][i];
return (
<div key={i} style={{
minHeight: '64px',
background: mod ? 'var(--surface-raised)' : 'var(--bg)',
border: `1px solid ${mod ? slot.color + '60' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--sp-2)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
cursor: mod ? 'pointer' : (selectedModule?.slot === slot.key ? 'pointer' : 'default'),
transition: 'all 0.15s',
opacity: mod ? 1 : 0.5,
}}
onClick={() => {
if (mod) handleUnfit(slot.key, i);
else if (selectedModule?.slot === slot.key) handleFit(selectedModule);
}}
>
{mod ? (
<>
<span style={{ fontSize: '0.9rem', color: slot.color, lineHeight: 1 }}>{moduleTypeIcon(mod.type)}</span>
<span style={{ fontSize: '0.6rem', color: 'var(--fg-dim)', textAlign: 'center', marginTop: '2px', lineHeight: 1.2 }}>
{mod.name.replace(' I', '').replace(' II', '')}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.55rem', color: 'var(--muted)' }}>
{mod.cpu}/{mod.power}
</span>
</>
) : (
<span style={{ fontSize: '0.6rem', color: 'var(--muted)' }}>Empty</span>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
{/* Selected module detail */}
{selectedModule && (
<div className="card" style={{ marginTop: 'var(--sp-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-4)' }}>
<div style={{
width: '48px', height: '48px', borderRadius: 'var(--radius-md)',
background: 'var(--surface-raised)', border: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.4rem',
color: selectedModule.slot === 'high' ? 'var(--red)' : selectedModule.slot === 'med' ? 'var(--cyan)' : 'var(--green)',
}}>
{moduleTypeIcon(selectedModule.type)}
</div>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 var(--sp-1) 0' }}>{selectedModule.name}</h4>
<div style={{ display: 'flex', gap: 'var(--sp-4)', fontSize: '0.8rem' }}>
<span style={{ color: 'var(--muted)' }}>Slot: <span style={{ color: 'var(--fg-dim)' }}>{selectedModule.slot}</span></span>
<span style={{ color: 'var(--muted)' }}>Type: <span style={{ color: 'var(--fg-dim)' }}>{selectedModule.type}</span></span>
<span style={{ color: 'var(--muted)' }}>CPU: <span style={{ color: 'var(--cyan)' }}>{selectedModule.cpu} tf</span></span>
<span style={{ color: 'var(--muted)' }}>Grid: <span style={{ color: 'var(--green)' }}>{selectedModule.power} MW</span></span>
{selectedModule.damage && <span style={{ color: 'var(--muted)' }}>Damage: <span style={{ color: 'var(--red)' }}>{selectedModule.damage}</span></span>}
{selectedModule.cycle > 0 && <span style={{ color: 'var(--muted)' }}>Cycle: <span style={{ color: 'var(--fg-dim)' }}>{selectedModule.cycle}s</span></span>}
</div>
</div>
<button className="btn btn-primary" onClick={() => handleFit(selectedModule)}>
Fit Module
</button>
</div>
</div>
)}
{/* Controls hint */}
<div className="callout callout-info" style={{ marginTop: 'var(--sp-4)' }}>
<strong>How to use:</strong> Select a module from the browser (left panel), then click an empty slot bay to fit it.
Double-click a module in the browser to quick-fit. Click a fitted module to remove it.
CPU and Power Grid are enforced overspending is blocked with a warning.
</div>
</div>
);
}
window.GDD.FittingDemo = FittingDemo;