orbit/js/combat/CombatSystem.js

518 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}