import { config } from '../config/Config.js'; import { EnemyShip } from './EnemyShip.js'; import { Bullet } from './Bullet.js'; import { Fx } from './Fx.js'; import { applyDamage } from './Combat.js'; const TAU = Math.PI * 2; /** * COMBAT AS A SYSTEM — the portable half of the combat sandbox, built to * live INSIDE an existing scene (the main game: js/scenes/GameScene.js) * instead of owning one (the range: js/combat/CombatScene.js, which * inlines the same rules for its fixed arena). * * It owns, per scene: * - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*) * - every tracer (Bullet — one-shot, life-limited) * - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn) * - the player's ENERGY (→ energy: the skills' resource — they spend it later) * - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the * ship fires at the NEAREST one — the mouse steers, * the cannon tracks; the cannon's numbers are → player.weapon) * - the collision pass (swept bullet hits + rams — the main game's * hand-rolled circle-check style, no arcade colliders) * * The scene supplies the player ship (a Ship, or anything with * .x/.y/.radius/.body/.stop()/.setVisible()) and calls * update(time, delta) from its own update loop — the same "actors in * update" contract GameScene already runs on (the engine steps the * arcade world itself). * * Lifecycle: spawnPack(cx, cy) starts the fight (the ?combat boot does * this around the ship when the player starts); active flips on the * first hostile / back off when the pack is wiped and not reforming — * the scene uses onActiveChange to swap the action deck for the * combat bar. */ export class CombatSystem { /** * @param {Phaser.Scene} scene a scene with arcade physics + add/textures * @param {object} [o] * ship the player ship (null = no player to hit/fire for) * onActiveChange (active) => {} — combat live / over (the bar swap) * onPlayerDamage (amount, armor) => {} — each hit on the player * onPlayerDown () => {} — the player's hull broke * onPlayerUp () => {} — the player reformed * onKill (enemy) => {} — a hostile went down */ constructor(scene, o = {}) { this.scene = scene; this.ship = o.ship ?? null; this.onActiveChange = typeof o.onActiveChange === 'function' ? o.onActiveChange : null; this.onPlayerDamage = typeof o.onPlayerDamage === 'function' ? o.onPlayerDamage : null; this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null; this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null; this.onKill = typeof o.onKill === 'function' ? o.onKill : null; this.enemies = []; this.bullets = []; this.kills = 0; // The player's life (playerShip.* — both contexts share it). const p = config.section('combat.playerShip', {}); this.maxHull = Math.max(1, Number(p.hull) || 100); this.maxShields = Math.max(0, Number(p.shields) || 0); this.hull = this.maxHull; this.shields = this.maxShields; this.playerRespawn = p.respawn !== false; this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000; this.playerDown = false; this.respawnAt = 0; this.lastHitAt = -1e9; // ENERGY — the skill resource (→ energy). Skills (keys 1-8, the bar's // middle seats) spend it; for now it just sits full, and the bar // shows it. const en = config.section('combat.energy', {}); this.maxEnergy = Math.max(0, Number(en.max) || 100); this.energy = this.maxEnergy; this.energyRegen = Math.max(0, Number(en.regen) || 0); // Main-game combat (→ game). const g = config.section('combat.game', {}); this.spawnCount = Math.max(0, Number(g.spawnCount) || 3); this.spawnRadius = Math.max(60, Number(g.spawnRadius) || 340); this.autoFire = { enabled: g.autoFire?.enabled !== false, range: Math.max(0, Number(g.autoFire?.range) || 800), }; // The rams' restitution (the range's arena bounce reads as a general // "what a collision keeps of the speed" fraction — combat.arena.bounce). this.restitution = Math.min(1, Math.max(0, Number(config.get('combat.arena.bounce', 0.9)) || 0.9)); this.ramUntil = 0; this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp this.fx = new Fx(scene); this._active = false; } // ------------------------------------------------------------- the pack /** Combat is live when a hostile is up, or one is on its reform clock. */ get active() { return this.enemies.some( (e) => !e.dead || (e._respawnPending && e.respawnAt > 0), ); } /** Fire the state change at the scene (bar swap) when it flips. */ _setActive() { const a = this.active; if (a === this._active) return; this._active = a; if (this.onActiveChange) this.onActiveChange(a); } /** * Spawn the starting pack in a ring around (cx, cy) — the ?combat boot * calls this with the ship's position "when the player starts". * @returns {EnemyShip[]} the fresh hostiles */ spawnPack(cx, cy) { const out = []; for (let i = 0; i < this.spawnCount; i++) { const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6; const r = this.spawnRadius * (0.9 + Math.random() * 0.2); out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i)); } return out; } /** One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its bearing. */ spawnEnemy(x, y, index) { const e = new EnemyShip(this.scene, x, y, index); e.setDepth(10); const px = this.ship ? this.ship.x : x; const py = this.ship ? this.ship.y : y; e.orbitAngle = Math.atan2(y - py, x - px); e.nextFireAt = 1000; // a brief non-shooting grace (ms clock) this.enemies.push(e); this._setActive(); return e; } // ------------------------------------------------------------ the loop /** * Step the combat (call from the scene's update, after the ship). * @param {number} time game clock (ms — the scene's stepped clock) * @param {number} delta frame delta (ms) */ update(time, delta) { const dt = Math.min(delta, 64) / 1000; const player = this.playerDown ? null : this.ship; // 1) The pack: orbit AI + lances (a dead one is its reform clock). for (const e of this.enemies) { if (e.dead) { if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time); continue; } const wantsFire = e.update(time, delta, player); if (wantsFire && player) this.fireEnemy(e, player, time); } // 2) AUTO-FIRE: the nearest live hostile in range (game.autoFire). // The mouse steers the ship; the cannon tracks on its own. if ( player && this.autoFire.enabled && this.autoFire.range > 0 && time >= this.nextFireAt ) { const target = this.nearestEnemy(player.x, player.y); if (target && Math.hypot(target.x - player.x, target.y - player.y) <= this.autoFire.range) { this.firePlayerAt(player, target, time); } } // 3) Tracers: age + cull (no walls here — the arena is the tether range). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || !b.active) { this.bullets.splice(i, 1); continue; } b.update(delta); if (b.dead) this.bullets.splice(i, 1); } // 4) Collisions: your tracers × the pack, their lances × you, rams. this.collide(delta, player); // 5) The player's armor bookkeeping: shield + energy regen, respawn. if (!this.playerDown) { const p = config.section('combat.playerShip', {}); if (this.shields < this.maxShields && this.maxShields > 0) { const grace = (Number(p.shieldRegenDelay) || 4) * 1000; const rate = Math.max(0, Number(p.shieldRegenRate) || 14); if (time - this.lastHitAt >= grace) { this.shields = Math.min(this.maxShields, this.shields + rate * dt); } } if (this.energy < this.maxEnergy) { this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt); } } else if (this.playerRespawn && this.respawnAt > 0 && time >= this.respawnAt) { this.revivePlayer(time); } this.fx.update(delta); } /** The nearest live hostile to (x, y), or null. */ nearestEnemy(x, y) { let best = null; let bestD = Infinity; for (const e of this.enemies) { if (e.dead) continue; const d = Math.hypot(e.x - x, e.y - y); if (d < bestD) { bestD = d; best = e; } } return best; } // --------------------------------------------------------------- firing /** * The ship's auto-fire at a hostile (the sandbox's manual cannon, minus * the key): one tracer from the nose, toward the target, spread-jittered. * The cooldown lives here (nextFireAt) — the weapon's numbers are * combat.player.weapon (shared with the range). */ firePlayerAt(player, target, time) { const wpn = config.section('combat.player.weapon', {}); this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000; const a = Math.atan2(target.y - player.y, target.x - player.x) + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0)); const nose = (Number(player.radius) || 12) + 8; const x = player.x + Math.cos(a) * nose; const y = player.y + Math.sin(a) * nose; this.spawnBullet(x, y, a, wpn, 'player'); this.fx.flash(x, y, wpn.color ?? '#00e5ff', 26, 110); } /** A hostile's lance at the player (aimed at the ship, spread-jittered). */ fireEnemy(e, player, time) { const wpn = e.wpn ?? {}; const toPlayer = Math.atan2(player.y - e.y, player.x - e.x); const a = toPlayer + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0)); const nose = e.hitRadius + 10; this.spawnBullet( e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, a, wpn, 'enemy', ); this.fx.flash(e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, wpn.color ?? '#ff2d6f', 28, 100); } spawnBullet(x, y, angle, wpn, owner) { const b = new Bullet(this.scene, { x, y, angle, speed: Number(wpn.bulletSpeed) || 600, damage: Number(wpn.damage) || 8, life: Number(wpn.bulletLife) || 1.2, color: wpn.color ?? '#ffffff', size: Number(wpn.size) || 16, owner, }); b.setDepth(20); this.bullets.push(b); return b; } // ------------------------------------------------------------- collisions /** * Swept hit check (the sandbox's anti-tunneling rule): tracers run * ~700 px/s — at 30 fps that's ~23 px/frame, past a ~25 px combined * radius, so a point check can tunnel. Sample the segment the bullet * flew this frame (end, middle, start) against the target circle. */ hitsSwept(b, other, otherRadius, dtMs) { const r = b.radius + otherRadius; const r2 = r * r; const vx = b.body ? b.body.velocity.x : 0; const vy = b.body ? b.body.velocity.y : 0; const dt = Math.min(dtMs, 64) / 1000; for (let f = 1; f >= 0; f -= 0.5) { const dx = other.x - (b.x - vx * dt * f); const dy = other.y - (b.y - vy * dt * f); if (dx * dx + dy * dy < r2) return true; } return false; } /** The collision pass: your tracers × the pack, their lances × you, rams. */ collide(delta, player) { // Your tracers × hostiles. if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'player') continue; for (const e of this.enemies) { if (e.dead) continue; if (this.hitsSwept(b, e, e.hitRadius, delta)) { const killed = e.damage(b.damage); this.fx.flash(b.x, b.y, '#ff9db4', 34, 130); if (killed) this.killEnemy(e, this._now()); if (b.consume()) this.bullets.splice(i, 1); break; } } } } // Their lances × you. if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'enemy') continue; if (this.hitsSwept(b, player, Number(player.radius) || 12, delta)) { this.damagePlayer(b.damage); this.fx.flash(b.x, b.y, '#9db4ff', 30, 130); if (b.consume()) this.bullets.splice(i, 1); } } } // Rams (you × the pack) — push apart, bounce, damage both (grace-limited). if (player) { for (const e of this.enemies) { if (!e.dead) this.ramPair(player, e); } } } /** The scene's clock (ms) — used for the kill bookkeeping's timestamps. */ _now() { return this.scene.time?.now ?? Date.now(); } /** * A ship-ship collision: push apart, exchange normal velocity (bounce), * and deal ram damage to BOTH (grace-limited — enemy.ram*). */ ramPair(a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy); const gap = (Number(a.radius) || 12) + b.hitRadius; if (d >= gap || d <= 0) return; const nx = dx / d; const ny = dy / d; const push = (gap - d) / 2; a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push; if (a.body && b.body) { const avx = a.body.velocity.x; const avy = a.body.velocity.y; const rvx = b.body.velocity.x - avx; const rvy = b.body.velocity.y - avy; const vn = rvx * nx + rvy * ny; if (vn < 0) { const j = (-(1 + this.restitution) * vn) / 2; // equal-mass impulse a.body.velocity.x = avx - j * nx; a.body.velocity.y = avy - j * ny; b.body.velocity.x += j * nx; b.body.velocity.y += j * ny; } } const t = this._now(); if (t >= this.ramUntil) { this.ramUntil = t + (Number(config.get('combat.enemy.ramGrace', 0.8)) || 0.8) * 1000; const midX = (a.x + b.x) / 2; const midY = (a.y + b.y) / 2; this.fx.flash(midX, midY, '#ffffff', 46, 160); this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0); if (killed) this.killEnemy(b, t); } } // ----------------------------------------------------- damage / lifecycle /** Hit the player's armor (shields → hull). Kills when the hull breaks. */ damagePlayer(n) { if (this.playerDown) return; this.lastHitAt = this._now(); const armor = { hull: this.hull, shields: this.shields }; const { dead } = applyDamage(armor, n); this.hull = armor.hull; this.shields = armor.shields; if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#ffffff', 60, 140); if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields }); if (dead) this.killPlayer(); } /** The player's hull broke: down (and reformed on the clock, if armed). */ killPlayer() { this.playerDown = true; this.hull = 0; if (this.ship) { this.fx.explode(this.ship.x, this.ship.y, '#7ce8ff'); this.ship.stop(); this.ship.setVisible(false); } if (this.onPlayerDown) this.onPlayerDown(); if (this.playerRespawn) { this.respawnAt = this._now() + this.playerRespawnDelay; } } /** The clock ran out — the player is back, full armor, in the same seat. */ revivePlayer(time) { this.playerDown = false; this.hull = this.maxHull; this.shields = this.maxShields; this.lastHitAt = 0; this.respawnAt = 0; if (this.ship) this.ship.setVisible(true); if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#7ce8ff', 80, 300); if (this.onPlayerUp) this.onPlayerUp(); } /** * A hostile went down: the kill FX + bookkeeping, and (when armed) the * reform clock. Idempotent via _killFx — damage() flips e.dead first, * so the dead flag can't be the "already counted" guard. */ killEnemy(e, time) { if (e._killFx) return; e._killFx = true; this.kills += 1; this.fx.explode(e.x, e.y, '#ff5d6f'); e.setVisible(false); if (e.body) { e.body.velocity.x = 0; e.body.velocity.y = 0; } const respawn = config.get('combat.enemy.respawn', true) !== false; e._respawnPending = respawn; e.respawnAt = time + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000; if (this.onKill) this.onKill(e); this._setActive(); } /** The reform clock ran out — the craft is back on a ring edge. */ reviveEnemy(e, time) { e._killFx = false; e._respawnPending = false; e.respawnAt = 0; const a = Math.random() * TAU; const r = Math.max(120, e.orbitRadius * (0.8 + Math.random() * 0.5)); const px = this.ship ? this.ship.x : e.x; const py = this.ship ? this.ship.y : e.y; e.x = px + Math.cos(a) * r; e.y = py + Math.sin(a) * r; e.orbitAngle = a; e.revive(); e.nextFireAt = time + 900; e.setVisible(true); this._setActive(); } // ------------------------------------------------------------- the bar /** * A skill slot press (keys 1-8 / the bar's middle seats). The skills * land later — for now the seats are empty: the bar calls this, and * this is where one will spend energy and do the thing. * @param {number} index 0-7 (slot 1-8's index) */ cast(index) { // (the skill registry lands with the real combat port) void index; } /** A snapshot for the combat bar (shields/hull left, energy right). */ stats() { return { hull: this.hull, maxHull: this.maxHull, shields: this.shields, maxShields: this.maxShields, energy: this.energy, maxEnergy: this.maxEnergy, playerDown: this.playerDown, }; } /** Tear the combat down (scene destroy). */ destroy() { for (const e of this.enemies) e.destroy(); for (const b of this.bullets) if (!b.dead) b.destroy(); this.enemies = []; this.bullets = []; this.fx.clear(); this.ship = null; this.onActiveChange = this.onPlayerDamage = this.onPlayerDown = this.onPlayerUp = this.onKill = null; } }