- 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
1045 lines
50 KiB
JavaScript
1045 lines
50 KiB
JavaScript
window.GDD = window.GDD || {};
|
||
|
||
const { useState, useEffect, useRef, useCallback } = React;
|
||
const TH = window.GDD.THREE;
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
Combat System — FTL-style Reactor Power Management
|
||
Immersive Game HUD — full 3D viewport with diegetic overlays
|
||
═══════════════════════════════════════════════════════════════════ */
|
||
|
||
const MAX_POWER = 8;
|
||
const TICK_MS = 50;
|
||
|
||
// ── 3D 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, tx, ty, tz, color };
|
||
} 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;
|
||
},
|
||
get all() { return pool; },
|
||
};
|
||
}
|
||
|
||
// ── Impact flash pool ──
|
||
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);
|
||
}
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
function CombatDemo() {
|
||
const containerRef = useRef(null);
|
||
const sceneRef = useRef(null);
|
||
const animIdRef = useRef(null);
|
||
const tickRef = useRef(0);
|
||
const projectilePoolRef = useRef(null);
|
||
const impactPoolRef = useRef(null);
|
||
|
||
// Ship 3D refs
|
||
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, strafeTimer: 0, speed: 0 });
|
||
const enemyPosRef = useRef({ orbitAngle: Math.PI });
|
||
|
||
// ── Player State ──
|
||
const [player, setPlayer] = useState({
|
||
shields: 100, armor: 100, hull: 100, energy: 100, maxEnergy: 100,
|
||
speed: 0, maxSpeed: 420, name: 'USS ENTERPRISE', class: 'VENTURE-CLASS',
|
||
});
|
||
const playerRef = useRef(player);
|
||
useEffect(() => { playerRef.current = player; }, [player]);
|
||
|
||
// ── Power Allocation ──
|
||
const [power, setPower] = useState({ weapons: 3, shields: 2, engines: 2, aux: 1 });
|
||
const powerRef = useRef(power);
|
||
useEffect(() => { powerRef.current = power; }, [power]);
|
||
|
||
// ── Ship Modules ──
|
||
const [modules, setModules] = useState([
|
||
{ id: 'q', key: '1', name: 'Railgun', icon: '⊕', type: 'weapon', cd: 0, maxCd: 3, cost: 12, damage: 18, desc: 'Kinetic turret', color: '#ef4444' },
|
||
{ id: 'w', key: '2', name: 'Shield Bst', icon: '◎', type: 'shield', cd: 0, maxCd: 8, cost: 20, damage: 0, desc: 'Burst recharge', color: '#22d3ee' },
|
||
{ id: 'e', key: '3', name: 'EM Pulse', icon: '⟐', type: 'ewar', cd: 0, maxCd: 12, cost: 30, damage: 0, desc: 'Disrupt systems', color: '#a78bfa' },
|
||
{ id: 'r', key: '4', name: 'Overload', icon: '⚡', type: 'reactor', cd: 0, maxCd: 30, cost: 45, damage: 0, desc: 'Push reactor', color: '#f0a030' },
|
||
{ id: 'd', key: '5', name: 'Afterburn', icon: '»', type: 'engine', cd: 0, maxCd: 6, cost: 10, damage: 0, desc: 'Emergency thrust', color: '#22c55e' },
|
||
{ id: 'f', key: '6', name: 'Hull Patch', icon: '✚', type: 'repair', cd: 0, maxCd: 15, cost: 25, damage: 0, desc: 'Nanite repair', color: '#fb923c' },
|
||
]);
|
||
const modulesRef = useRef(modules);
|
||
useEffect(() => { modulesRef.current = modules; }, [modules]);
|
||
|
||
const [playerBuffs, setPlayerBuffs] = useState([{ id: 'b1', name: 'Dmg Ctrl', icon: '↯', duration: -1, color: '#22c55e' }]);
|
||
const [enemyBuffs, setEnemyBuffs] = useState([]);
|
||
const [target, setTarget] = useState(null);
|
||
const [subsystem, setSubsystem] = useState('hull');
|
||
const [enemy, setEnemy] = useState({
|
||
name: 'Guristas Pirata', class: 'Frigate', shields: 100, armor: 100, hull: 100,
|
||
weapons: 100, engines: 100, locked: false, lockTimer: 0, lockTime: 3,
|
||
});
|
||
const enemyRef = useRef(enemy);
|
||
useEffect(() => { enemyRef.current = enemy; }, [enemy]);
|
||
const targetRef = useRef(target);
|
||
useEffect(() => { targetRef.current = target; }, [target]);
|
||
const subsystemRef = useRef(subsystem);
|
||
useEffect(() => { subsystemRef.current = subsystem; }, [subsystem]);
|
||
const [overloaded, setOverloaded] = useState(false);
|
||
const overloadRef = useRef(false);
|
||
const [combatLog, setCombatLog] = useState([]);
|
||
const logRef = useRef([]);
|
||
const logScrollRef = useRef(null);
|
||
const addLog = useCallback((msg, color) => {
|
||
const time = new Date().toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
logRef.current = [...logRef.current.slice(-50), { time, msg, color }];
|
||
setCombatLog(logRef.current);
|
||
}, []);
|
||
const autoFireTimerRef = useRef(0);
|
||
const enemyFireTimerRef = useRef(0);
|
||
|
||
// ── Derived stats ──
|
||
const totalPower = power.weapons + power.shields + power.engines + power.aux;
|
||
const weaponMult = 1 + power.weapons * 0.20;
|
||
const shieldRegen = power.shields * 1.2;
|
||
const shieldAbsorb = 0.4 + power.shields * 0.08;
|
||
const dodgeChance = power.engines * 4;
|
||
const speedMult = 1 + power.engines * 0.25;
|
||
const cdReduction = power.aux * 6;
|
||
const energyRegen = 2 + power.aux * 2;
|
||
|
||
// ── Build 3D scene ──
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.fog = new THREE.FogExp2(0x040810, 0.0008);
|
||
|
||
const camera = new THREE.PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 3000);
|
||
camera.position.set(0, 35, 60);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
const renderer = TH.createRenderer(container, { clearColor: 0x040810 });
|
||
TH.handleResize(renderer, camera, container);
|
||
|
||
const stars = TH.createStarField(3000, 2000);
|
||
scene.add(stars);
|
||
TH.addNebula(scene, 0x22d3ee, [-100, 40, -200], 200);
|
||
TH.addNebula(scene, 0xa78bfa, [80, -30, -150], 150);
|
||
|
||
const grid = new THREE.GridHelper(300, 15, 0x0d1520, 0x0d1520);
|
||
grid.material.transparent = true;
|
||
grid.material.opacity = 0.12;
|
||
scene.add(grid);
|
||
TH.setupSpaceLighting(scene);
|
||
|
||
// Player ship
|
||
const 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 ship
|
||
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;
|
||
|
||
// Targeting line
|
||
const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), 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;
|
||
|
||
const projPool = createProjectilePool3D(scene);
|
||
projectilePoolRef.current = projPool;
|
||
const impPool = createImpactPool3D(scene);
|
||
impactPoolRef.current = impPool;
|
||
|
||
sceneRef.current = { scene, camera, renderer, stars, playerGroup, enemyGroup, pEngine, eEngine, lockBrackets };
|
||
|
||
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 engineScale = eng.engines / 100;
|
||
ep.orbitAngle += 0.01 * engineScale;
|
||
const er = 14 * engineScale;
|
||
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 * engineScale;
|
||
eEngine.intensity = 1.5 * engineScale;
|
||
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 pPos = playerGroup.position;
|
||
const ePos = enemyGroup.position;
|
||
const positions = targetLine.geometry.attributes.position;
|
||
positions.setXYZ(0, pPos.x, pPos.y, pPos.z);
|
||
positions.setXYZ(1, ePos.x, ePos.y, ePos.z);
|
||
positions.needsUpdate = true;
|
||
targetLine.computeLineDistances();
|
||
}
|
||
|
||
stars.rotation.y = t * 0.003;
|
||
projPool.tick();
|
||
impPool.tick();
|
||
renderer.render(scene, camera);
|
||
};
|
||
animate();
|
||
|
||
const onResize = () => TH.handleResize(renderer, camera, container);
|
||
window.addEventListener('resize', onResize);
|
||
|
||
return () => {
|
||
if (animIdRef.current) cancelAnimationFrame(animIdRef.current);
|
||
window.removeEventListener('resize', onResize);
|
||
projPool.clear();
|
||
};
|
||
}, []);
|
||
|
||
// Auto-scroll log
|
||
useEffect(() => {
|
||
if (logScrollRef.current) logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
|
||
}, [combatLog]);
|
||
|
||
// ── Lock Target ──
|
||
const lockTarget = useCallback(() => {
|
||
addLog('Initiating target lock...', '#f0a030');
|
||
setEnemy(prev => ({ ...prev, lockTimer: 0, locked: false }));
|
||
setTarget('Guristas Pirata');
|
||
if (targetLineRef.current) targetLineRef.current.visible = true;
|
||
}, [addLog]);
|
||
|
||
// ── Adjust Power ──
|
||
const adjustPower = useCallback((system, delta) => {
|
||
setPower(prev => {
|
||
const 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 Ability ──
|
||
const castModule = useCallback((abilityId) => {
|
||
const ab = modulesRef.current.find(a => a.id === abilityId);
|
||
if (!ab) return;
|
||
if (!targetRef.current) { addLog('No target locked.', '#ef4444'); return; }
|
||
if (!enemyRef.current.locked) { addLog('Target not locked yet.', '#ef4444'); return; }
|
||
if (ab.cd > 0) { addLog(`${ab.name} recharging (${ab.cd.toFixed(1)}s).`, '#ef4444'); return; }
|
||
const pwr = powerRef.current;
|
||
const pl = playerRef.current;
|
||
if (pl.energy < ab.cost) { addLog('Insufficient energy.', '#ef4444'); return; }
|
||
|
||
setPlayer(prev => ({ ...prev, energy: prev.energy - ab.cost }));
|
||
const actualCd = ab.maxCd * (1 - pwr.aux * 0.06);
|
||
setModules(prev => prev.map(a => a.id === abilityId ? { ...a, cd: Math.max(0.5, actualCd) } : a));
|
||
|
||
const pPos = playerShipRef.current?.position || { x: -15, y: 0, z: 0 };
|
||
const ePos = enemyShipRef.current?.position || { x: 15, y: 0, z: 0 };
|
||
const proj = 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()} (×${weaponMult.toFixed(1)})`, '#ef4444');
|
||
}
|
||
if (ab.id === 'w') {
|
||
const restore = 15 + pwr.shields * 6;
|
||
setPlayer(prev => ({ ...prev, shields: Math.min(100, prev.shields + restore) }));
|
||
imp.spawn(pPos.x, pPos.y, pPos.z, 0x22d3ee, 5);
|
||
addLog(`Shield Boost: +${Math.round(restore)}%`, '#22d3ee');
|
||
}
|
||
if (ab.id === 'e') {
|
||
const dur = 3 + pwr.aux * 0.5;
|
||
const mid = { x: (pPos.x + ePos.x) / 2, y: (pPos.y + ePos.y) / 2, z: (pPos.z + ePos.z) / 2 };
|
||
proj.spawn(mid.x, mid.y, mid.z, ePos.x, ePos.y, ePos.z, 0xa78bfa, 0, 'pulse', 'none');
|
||
setEnemyBuffs([{ id: 'emp', name: 'EM Disrupted', icon: '⟐', duration: Math.round(dur), color: '#a78bfa' }]);
|
||
setEnemy(prev => ({ ...prev, weapons: Math.max(0, prev.weapons - 15 - pwr.aux * 3), engines: Math.max(0, prev.engines - 10 - pwr.aux * 2) }));
|
||
addLog(`EM Pulse! Enemy disrupted ${dur.toFixed(1)}s`, '#a78bfa');
|
||
setTimeout(() => { setEnemyBuffs([]); addLog('Enemy systems restored.', '#5a6b82'); }, dur * 1000);
|
||
}
|
||
if (ab.id === 'r') {
|
||
overloadRef.current = true;
|
||
setOverloaded(true);
|
||
setPlayerBuffs(prev => [...prev, { id: 'overload', name: 'Overload', icon: '⚡', duration: 8, color: '#f0a030' }]);
|
||
addLog('⚡ OVERLOAD — double fire rate 8s!', '#f0a030');
|
||
setTimeout(() => { overloadRef.current = false; setOverloaded(false); setPlayerBuffs(prev => prev.filter(b => b.id !== 'overload')); addLog('Overload ended.', '#5a6b82'); }, 8000);
|
||
}
|
||
if (ab.id === 'd') {
|
||
playerPosRef.current.speed = 300 * speedMult;
|
||
addLog(`Afterburners! Speed ×${speedMult.toFixed(1)}`, '#22c55e');
|
||
setTimeout(() => { playerPosRef.current.speed = 0; }, 2500);
|
||
}
|
||
if (ab.id === 'f') {
|
||
const repair = 12 + pwr.aux * 4;
|
||
setPlayer(prev => ({ ...prev, hull: Math.min(100, prev.hull + repair) }));
|
||
imp.spawn(pPos.x, pPos.y, pPos.z, 0xfb923c, 6);
|
||
addLog(`Hull Patch: +${Math.round(repair)}%`, '#fb923c');
|
||
}
|
||
}, [addLog, weaponMult, speedMult]);
|
||
|
||
// ── Keyboard ──
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
const key = e.key.toLowerCase();
|
||
if (['1', '2', '3', '4', '5', '6'].includes(key)) { e.preventDefault(); castModule(key); }
|
||
if (key === ' ' && !targetRef.current) { e.preventDefault(); lockTarget(); }
|
||
};
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, [castModule, lockTarget]);
|
||
|
||
// ── Combat Tick ──
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
tickRef.current++;
|
||
const pwr = powerRef.current;
|
||
const eng = enemyRef.current;
|
||
const sub = subsystemRef.current;
|
||
const tgt = targetRef.current;
|
||
const tps = 1000 / TICK_MS;
|
||
|
||
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 => {
|
||
if (prev.shields < 100) return { ...prev, shields: Math.min(100, prev.shields + (pwr.shields * 1.2) / tps) };
|
||
return prev;
|
||
});
|
||
|
||
const proj = projectilePoolRef.current;
|
||
const imp = impactPoolRef.current;
|
||
const pPos = playerShipRef.current?.position;
|
||
const ePos = enemyShipRef.current?.position;
|
||
if (!pPos || !ePos) return;
|
||
|
||
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');
|
||
}
|
||
}
|
||
}
|
||
|
||
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 };
|
||
});
|
||
}
|
||
|
||
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]);
|
||
|
||
/* ═══ RENDER — Immersive Game HUD ═══ */
|
||
const lockPct = target && !enemy.locked ? Math.min(100, (enemy.lockTimer / enemy.lockTime) * 100) : 0;
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
minHeight: '620px',
|
||
overflow: 'hidden',
|
||
background: '#040810',
|
||
fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace",
|
||
fontSize: '11px',
|
||
color: '#d4dce8',
|
||
cursor: 'crosshair',
|
||
}}>
|
||
{/* ── 3D VIEWPORT ── */}
|
||
<div ref={containerRef} style={{ position: 'absolute', inset: 0, zIndex: 0 }} />
|
||
|
||
{/* ═══ HUD OVERLAY LAYER ═══ */}
|
||
<div style={{
|
||
position: 'absolute', inset: 0, zIndex: 1,
|
||
display: 'grid',
|
||
gridTemplateColumns: '200px 1fr 220px',
|
||
gridTemplateRows: '38px 1fr auto',
|
||
gridTemplateAreas: `"top top top" "left center right" "bottom bottom bottom"`,
|
||
pointerEvents: 'none',
|
||
}}>
|
||
|
||
{/* ── TOP BAR — System info strip ── */}
|
||
<div style={{
|
||
gridArea: 'top',
|
||
display: 'flex', alignItems: 'center',
|
||
padding: '0 14px', gap: '14px',
|
||
background: 'linear-gradient(180deg, rgba(4,8,16,0.92) 0%, rgba(4,8,16,0.5) 80%, transparent 100%)',
|
||
pointerEvents: 'auto',
|
||
}}>
|
||
<button onClick={() => window.location.hash = 'overview'} style={{
|
||
background: 'rgba(255,255,255,0.06)',
|
||
border: '1px solid rgba(255,255,255,0.1)',
|
||
borderRadius: '4px',
|
||
color: '#94a3b8',
|
||
fontSize: '9px',
|
||
padding: '3px 8px',
|
||
cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', gap: '4px',
|
||
fontFamily: 'inherit',
|
||
letterSpacing: '0.04em',
|
||
transition: 'background 0.15s, color 0.15s',
|
||
}}
|
||
onMouseEnter={e => { e.target.style.background = 'rgba(255,255,255,0.12)'; e.target.style.color = '#e2e8f0'; }}
|
||
onMouseLeave={e => { e.target.style.background = 'rgba(255,255,255,0.06)'; e.target.style.color = '#94a3b8'; }}
|
||
>
|
||
<span style={{ fontSize: '11px' }}>←</span> DOCS
|
||
</button>
|
||
<div style={{ width: 1, height: 16, background: 'rgba(28,42,63,0.6)' }} />
|
||
<span style={{ fontSize: '10px', fontWeight: 600, color: '#f0a030', letterSpacing: '0.04em' }}>JITA</span>
|
||
<span style={{ fontSize: '9px', padding: '1px 6px', borderRadius: '999px', fontWeight: 600, background: 'rgba(34,197,94,0.1)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.25)' }}>1.0 SEC</span>
|
||
<div style={{ width: 1, height: 16, background: 'rgba(28,42,63,0.6)' }} />
|
||
<span style={{ fontSize: '12px', fontWeight: 600, color: '#f1f5f9', letterSpacing: '-0.01em', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||
{player.name}
|
||
</span>
|
||
<span style={{ fontSize: '9px', color: '#5a6b82' }}>{player.class}</span>
|
||
<div style={{ width: 1, height: 16, background: 'rgba(28,42,63,0.6)' }} />
|
||
<span style={{ fontSize: '9px', color: '#5a6b82' }}>SPD</span>
|
||
<span style={{ fontSize: '10px', color: '#94a3b8', fontVariantNumeric: 'tabular-nums' }}>{player.speed.toFixed(0)} m/s</span>
|
||
<div style={{ flex: 1 }} />
|
||
{overloaded && <span style={{ color: '#f0a030', fontWeight: 700, fontSize: '10px', animation: 'pulse 0.8s infinite' }}>⚡ OVERLOAD</span>}
|
||
{target && enemy.locked && (
|
||
<span style={{ fontSize: '10px', fontWeight: 600, color: '#ef4444', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||
<span style={{ width: 5, height: 5, borderRadius: '50%', background: '#ef4444', boxShadow: '0 0 6px #ef4444' }} />
|
||
TARGET LOCKED
|
||
</span>
|
||
)}
|
||
<div style={{ width: 1, height: 16, background: 'rgba(28,42,63,0.6)' }} />
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: '#22c55e', boxShadow: '0 0 4px #22c55e' }} />
|
||
<span style={{ fontSize: '9px', color: '#22c55e' }}>ONLINE</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* ── LEFT — Ship Systems ── */}
|
||
<div style={{
|
||
gridArea: 'left',
|
||
display: 'flex', flexDirection: 'column', gap: '5px',
|
||
padding: '5px',
|
||
pointerEvents: 'auto',
|
||
}}>
|
||
{/* Ship status */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Ship Systems
|
||
</div>
|
||
<div style={{ padding: '7px 9px' }}>
|
||
{[
|
||
{ label: 'SH', value: player.shields, color: '#22d3ee', grad: 'linear-gradient(90deg, #0891b2, #22d3ee)' },
|
||
{ label: 'AR', value: player.armor, color: '#f0a030', grad: 'linear-gradient(90deg, #b47818, #f0a030)' },
|
||
{ label: 'HU', value: player.hull, color: '#22c55e', grad: 'linear-gradient(90deg, #16a34a, #22c55e)' },
|
||
{ label: 'NRG', value: player.energy, color: player.energy > 25 ? '#a78bfa' : '#ef4444', grad: player.energy > 25 ? 'linear-gradient(90deg, #6366f1, #a78bfa)' : 'linear-gradient(90deg, #dc2626, #ef4444)' },
|
||
].map(bar => (
|
||
<div key={bar.label} style={{ display: 'flex', alignItems: 'center', gap: '5px', marginBottom: '5px' }}>
|
||
<span style={{ fontSize: '8px', color: bar.color, width: 22, textTransform: 'uppercase', letterSpacing: '0.06em', flexShrink: 0 }}>{bar.label}</span>
|
||
<div style={{ flex: 1, height: 4, background: 'rgba(255,255,255,0.04)', borderRadius: '999px', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${bar.value}%`, background: bar.grad, borderRadius: '999px', transition: 'width 0.4s cubic-bezier(0.23,1,0.32,1)' }} />
|
||
</div>
|
||
<span style={{ fontSize: '9px', color: bar.color, fontVariantNumeric: 'tabular-nums', width: 28, textAlign: 'right', flexShrink: 0 }}>
|
||
{bar.label === 'NRG' ? Math.round(bar.value) : bar.value.toFixed(0)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reactor Power */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
flex: 1,
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Reactor
|
||
<span style={{ marginLeft: 'auto', fontSize: '7px', color: totalPower < MAX_POWER ? '#22c55e' : '#94a3b8' }}>{totalPower}/{MAX_POWER}</span>
|
||
</div>
|
||
<div style={{ padding: '5px 9px' }}>
|
||
{[
|
||
{ key: 'weapons', label: 'WPN', color: '#ef4444' },
|
||
{ key: 'shields', label: 'SHD', color: '#22d3ee' },
|
||
{ key: 'engines', label: 'ENG', color: '#22c55e' },
|
||
{ key: 'aux', label: 'AUX', color: '#a78bfa' },
|
||
].map(sys => (
|
||
<div key={sys.key} style={{ display: 'flex', alignItems: 'center', gap: '3px', marginBottom: '3px' }}>
|
||
<span style={{ fontSize: '7px', color: sys.color, width: 18, textTransform: 'uppercase', letterSpacing: '0.08em', flexShrink: 0, textAlign: 'center' }}>{sys.label}</span>
|
||
<button onClick={() => adjustPower(sys.key, -1)} style={{
|
||
width: 13, height: 13, borderRadius: '2px', border: '1px solid rgba(28,42,63,0.7)',
|
||
background: 'rgba(22,32,50,0.8)', color: '#94a3b8', fontSize: '9px',
|
||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
||
flexShrink: 0, padding: 0,
|
||
}}>−</button>
|
||
<div style={{ display: 'flex', gap: 1, flex: 1 }}>
|
||
{Array.from({ length: MAX_POWER }, (_, i) => (
|
||
<div key={i} style={{ flex: 1, height: 6, borderRadius: '1px', background: i < power[sys.key] ? sys.color : 'rgba(255,255,255,0.04)', transition: 'background 0.15s' }} />
|
||
))}
|
||
</div>
|
||
<button onClick={() => adjustPower(sys.key, 1)} style={{
|
||
width: 13, height: 13, borderRadius: '2px', border: '1px solid rgba(28,42,63,0.7)',
|
||
background: 'rgba(22,32,50,0.8)', color: '#94a3b8', fontSize: '9px',
|
||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
||
flexShrink: 0, padding: 0,
|
||
}}>+</button>
|
||
</div>
|
||
))}
|
||
<div style={{ fontSize: '7px', color: '#5a6b82', marginTop: '3px', lineHeight: 1.5 }}>
|
||
<span style={{ color: '#ef4444' }}>WPN</span> ×{weaponMult.toFixed(1)} dmg
|
||
{' · '}
|
||
<span style={{ color: '#22d3ee' }}>SHD</span> {shieldRegen.toFixed(0)}/s
|
||
{' · '}
|
||
<span style={{ color: '#22c55e' }}>ENG</span> {dodgeChance}% dodge
|
||
{' · '}
|
||
<span style={{ color: '#a78bfa' }}>AUX</span> {energyRegen.toFixed(0)} NRG/s
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Buffs */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Status
|
||
</div>
|
||
<div style={{ padding: '4px 7px', display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
|
||
{playerBuffs.map(b => (
|
||
<span key={b.id} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||
padding: '1px 5px', borderRadius: '2px', fontSize: '8px',
|
||
background: `${b.color}12`, border: `1px solid ${b.color}30`, color: b.color,
|
||
}}>
|
||
{b.icon} {b.name}{b.duration > 0 ? ` ${b.duration}s` : ''}
|
||
</span>
|
||
))}
|
||
{enemyBuffs.map(b => (
|
||
<span key={b.id} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||
padding: '1px 5px', borderRadius: '2px', fontSize: '8px',
|
||
background: `${b.color}12`, border: `1px solid ${b.color}30`, color: b.color,
|
||
}}>
|
||
{b.icon} {b.name} {b.duration > 0 ? `${b.duration}s` : ''}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── CENTER — Crosshair + Lock ── */}
|
||
<div style={{
|
||
gridArea: 'center',
|
||
pointerEvents: 'none',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
position: 'relative',
|
||
}}>
|
||
{/* Crosshair */}
|
||
<div style={{ width: 44, height: 44, position: 'relative', opacity: 0.3 }}>
|
||
<div style={{ position: 'absolute', width: 1, height: 14, left: '50%', top: 0, transform: 'translateX(-50%)', background: '#94a3b8' }} />
|
||
<div style={{ position: 'absolute', width: 1, height: 14, left: '50%', bottom: 0, transform: 'translateX(-50%)', background: '#94a3b8' }} />
|
||
<div style={{ position: 'absolute', height: 1, width: 14, top: '50%', left: 0, transform: 'translateY(-50%)', background: '#94a3b8' }} />
|
||
<div style={{ position: 'absolute', height: 1, width: 14, top: '50%', right: 0, transform: 'translateY(-50%)', background: '#94a3b8' }} />
|
||
<div style={{
|
||
width: 30, height: 30, border: '1px solid rgba(212,220,232,0.2)', borderRadius: '50%',
|
||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||
}} />
|
||
</div>
|
||
|
||
{/* Lock-in-progress ring */}
|
||
{target && !enemy.locked && (
|
||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', textAlign: 'center' }}>
|
||
<div style={{ width: 100, height: 100, border: '2px solid rgba(240,160,48,0.25)', borderRadius: '50%', margin: '0 auto 6px', position: 'relative' }}>
|
||
<svg width="100" height="100" style={{ position: 'absolute', top: -2, left: -2, transform: 'rotate(-90deg)' }}>
|
||
<circle cx="50" cy="50" r="48" fill="none" stroke="#f0a030" strokeWidth="2"
|
||
strokeDasharray={`${(lockPct / 100) * 301} 301`}
|
||
style={{ transition: 'stroke-dasharray 0.1s' }} />
|
||
</svg>
|
||
</div>
|
||
<div style={{ fontSize: '9px', color: '#f0a030', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||
LOCKING {lockPct.toFixed(0)}%
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Engage prompt */}
|
||
{!target && (
|
||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', textAlign: 'center', pointerEvents: 'auto' }}>
|
||
<p style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '10px' }}>No hostiles detected</p>
|
||
<button onClick={lockTarget} style={{
|
||
padding: '7px 20px', borderRadius: '4px',
|
||
border: '1.5px solid rgba(240,160,48,0.25)',
|
||
background: 'rgba(240,160,48,0.06)', color: '#f0a030',
|
||
fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace",
|
||
fontSize: '10px', fontWeight: 600,
|
||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||
cursor: 'pointer', transition: 'all 0.15s',
|
||
}}>
|
||
ENGAGE TARGET [SPACE]
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── RIGHT — Target Intel ── */}
|
||
<div style={{
|
||
gridArea: 'right',
|
||
display: 'flex', flexDirection: 'column', gap: '5px',
|
||
padding: '5px',
|
||
pointerEvents: 'auto',
|
||
}}>
|
||
{/* Target panel */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#ef4444' }} />
|
||
<span style={{ color: target ? '#f1f5f9' : '#5a6b82' }}>{target || 'NO TARGET'}</span>
|
||
{target && <span style={{ marginLeft: 'auto', fontSize: '7px', color: enemy.locked ? '#22c55e' : '#f0a030' }}>{enemy.locked ? 'LOCKED' : 'LOCKING'}</span>}
|
||
</div>
|
||
<div style={{ padding: '7px 9px' }}>
|
||
{target && (
|
||
<>
|
||
{[
|
||
{ label: 'SH', value: enemy.shields, color: '#22d3ee' },
|
||
{ label: 'AR', value: enemy.armor, color: '#f0a030' },
|
||
{ label: 'HU', value: enemy.hull, color: '#22c55e' },
|
||
].map(bar => (
|
||
<div key={bar.label} style={{ display: 'flex', alignItems: 'center', gap: '5px', marginBottom: '4px' }}>
|
||
<span style={{ fontSize: '8px', color: bar.color, width: 14, textTransform: 'uppercase', letterSpacing: '0.06em', flexShrink: 0 }}>{bar.label}</span>
|
||
<div style={{ flex: 1, height: 4, background: 'rgba(255,255,255,0.04)', borderRadius: '999px', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${bar.value}%`, background: `linear-gradient(90deg, ${bar.color}88, ${bar.color})`, borderRadius: '999px', transition: 'width 0.3s' }} />
|
||
</div>
|
||
<span style={{ fontSize: '9px', color: bar.color, fontVariantNumeric: 'tabular-nums', width: 26, textAlign: 'right', flexShrink: 0 }}>{bar.value.toFixed(0)}</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Subsystem targeting */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
flex: 1,
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Subsystem
|
||
</div>
|
||
<div style={{ padding: '5px 7px' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px' }}>
|
||
{[
|
||
{ key: 'hull', label: 'Hull', color: '#f0a030', hp: enemy.hull },
|
||
{ key: 'shields', label: 'Shields', color: '#22d3ee', hp: enemy.shields },
|
||
{ key: 'weapons', label: 'Weapons', color: '#ef4444', hp: enemy.weapons },
|
||
{ key: 'engines', label: 'Engines', color: '#22c55e', hp: enemy.engines },
|
||
].map(sys => (
|
||
<button key={sys.key} onClick={() => { setSubsystem(sys.key); addLog(`Retargeting: ${sys.key.toUpperCase()}`, '#a78bfa'); }} style={{
|
||
padding: '4px 5px', borderRadius: '3px',
|
||
border: `1px solid ${subsystem === sys.key ? sys.color : 'rgba(28,42,63,0.6)'}`,
|
||
background: subsystem === sys.key ? `${sys.color}10` : 'rgba(15,22,35,0.7)',
|
||
cursor: 'pointer', textAlign: 'left', transition: 'all 0.12s',
|
||
}}>
|
||
<span style={{ fontSize: '7px', color: sys.color, textTransform: 'uppercase', letterSpacing: '0.06em', display: 'block', marginBottom: '2px' }}>
|
||
{sys.label} {subsystem === sys.key ? '◄' : ''}
|
||
</span>
|
||
<div style={{ height: 2, background: 'rgba(255,255,255,0.04)', borderRadius: '999px', overflow: 'hidden' }}>
|
||
<div style={{ height: '100%', width: `${sys.hp}%`, background: sys.color, borderRadius: '999px', transition: 'width 0.3s' }} />
|
||
</div>
|
||
<span style={{ fontSize: '7px', color: '#94a3b8', display: 'block', marginTop: '1px' }}>{sys.hp.toFixed(0)}%</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Power coupling readout */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.85)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
padding: '5px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Power Readout
|
||
</div>
|
||
<div style={{ padding: '5px 9px', fontSize: '8px', color: '#94a3b8', lineHeight: 1.6 }}>
|
||
<div><span style={{ color: '#ef4444' }}>WPN {power.weapons}</span> → ×{weaponMult.toFixed(1)} dmg, {Math.max(1, Math.floor(power.weapons / 2))} rounds</div>
|
||
<div><span style={{ color: '#22d3ee' }}>SHD {power.shields}</span> → {shieldRegen.toFixed(0)}/s, {(shieldAbsorb * 100).toFixed(0)}% abs</div>
|
||
<div><span style={{ color: '#22c55e' }}>ENG {power.engines}</span> → {dodgeChance}% dodge, ×{speedMult.toFixed(1)}</div>
|
||
<div><span style={{ color: '#a78bfa' }}>AUX {power.aux}</span> → {energyRegen.toFixed(0)} NRG/s, −{cdReduction}% CD</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── BOTTOM — Module Bar + Combat Log ── */}
|
||
<div style={{
|
||
gridArea: 'bottom',
|
||
display: 'flex', gap: '5px',
|
||
padding: '0 5px 5px',
|
||
pointerEvents: 'auto',
|
||
}}>
|
||
{/* Module bar */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.88)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
flex: 2,
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
padding: '3px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Modules
|
||
<span style={{ marginLeft: 'auto', fontSize: '7px', color: '#5a6b82' }}>1–6 or click</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '3px', alignItems: 'flex-end', padding: '6px 8px' }}>
|
||
{/* Reactor gauge */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '42px', flexShrink: 0, marginRight: '2px' }}>
|
||
<span style={{ fontSize: '7px', color: '#a78bfa', marginBottom: '2px' }}>REACTOR</span>
|
||
<div style={{ width: '100%', height: '36px', background: 'rgba(255,255,255,0.04)', borderRadius: '3px', overflow: 'hidden', position: 'relative' }}>
|
||
<div style={{
|
||
position: 'absolute', bottom: 0, width: '100%', height: `${player.energy}%`,
|
||
background: player.energy > 25 ? 'linear-gradient(0deg, #4f46e5, #8b5cf6)' : 'linear-gradient(0deg, #dc2626, #ef4444)',
|
||
transition: 'height 0.15s',
|
||
}} />
|
||
<span style={{
|
||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: '10px', fontWeight: 700, color: '#f1f5f9',
|
||
}}>{Math.round(player.energy)}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ width: 1, height: 36, background: 'rgba(28,42,63,0.6)', flexShrink: 0 }} />
|
||
|
||
{/* Module buttons */}
|
||
{modules.map(ab => {
|
||
const onCd = ab.cd > 0;
|
||
const noNRG = player.energy < ab.cost;
|
||
const canCast = target && enemy.locked && !onCd && !noNRG;
|
||
return (
|
||
<button key={ab.id} onClick={() => castModule(ab.id)} title={`${ab.name} — ${ab.desc} (${ab.cost} NRG)`} style={{
|
||
width: '52px', height: '42px', borderRadius: '4px',
|
||
border: `1.5px solid ${canCast ? 'rgba(240,160,48,0.25)' : 'rgba(28,42,63,0.6)'}`,
|
||
background: canCast ? 'rgba(240,160,48,0.04)' : 'rgba(15,22,35,0.7)',
|
||
cursor: canCast ? 'pointer' : 'default',
|
||
position: 'relative', overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
gap: '1px', transition: 'all 0.1s', padding: 0,
|
||
}}>
|
||
{onCd && <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${(ab.cd / (ab.maxCd * (1 - power.aux * 0.06))) * 100}%`, background: 'rgba(0,0,0,0.55)', transition: 'height 0.1s' }} />}
|
||
{noNRG && !onCd && <div style={{ position: 'absolute', inset: 0, background: 'rgba(80,15,15,0.25)' }} />}
|
||
<span style={{ fontSize: '12px', position: 'relative', zIndex: 1, opacity: canCast ? 1 : 0.4 }}>{ab.icon}</span>
|
||
<span style={{ fontSize: '8px', color: canCast ? '#f0a030' : '#5a6b82', position: 'relative', zIndex: 1 }}>
|
||
{onCd ? ab.cd.toFixed(1) : ab.key}
|
||
</span>
|
||
<span style={{ position: 'absolute', bottom: '1px', right: '2px', fontSize: '7px', color: noNRG && !onCd ? '#ef4444' : '#5a6b82', zIndex: 1 }}>{ab.cost}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Combat log */}
|
||
<div style={{
|
||
background: 'rgba(12,18,30,0.88)',
|
||
border: '1px solid rgba(28,42,63,0.6)',
|
||
borderRadius: '6px',
|
||
backdropFilter: 'blur(8px)',
|
||
flex: 1,
|
||
overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column',
|
||
}}>
|
||
<div style={{
|
||
padding: '3px 9px',
|
||
borderBottom: '1px solid rgba(28,42,63,0.6)',
|
||
fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#5a6b82',
|
||
display: 'flex', alignItems: 'center', gap: '5px',
|
||
}}>
|
||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: '#f0a030' }} />
|
||
Log
|
||
</div>
|
||
<div ref={logScrollRef} style={{
|
||
flex: 1, overflowY: 'auto', maxHeight: '80px', padding: '3px 7px',
|
||
fontSize: '9px',
|
||
}}>
|
||
{combatLog.length === 0 && <div style={{ color: '#5a6b82', padding: '2px 0' }}>SPACE to engage, then 1–6 for modules.</div>}
|
||
{combatLog.map((entry, i) => (
|
||
<div key={i} style={{ display: 'flex', gap: '5px', marginBottom: '1px', lineHeight: 1.3 }}>
|
||
<span style={{ color: '#5a6b82', flexShrink: 0, fontSize: '8px' }}>{entry.time}</span>
|
||
<span style={{ color: entry.color, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{entry.msg}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
window.GDD.CombatDemo = CombatDemo;
|