378 lines
19 KiB
JavaScript
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;
|