534 lines
19 KiB
JavaScript
534 lines
19 KiB
JavaScript
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';
|
||
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.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);
|
||
combatSfx(this.scene, 'fire');
|
||
}
|
||
|
||
/** 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);
|
||
combatSfx(this.scene, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
|
||
}
|
||
|
||
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 preShields = e.shields;
|
||
const killed = e.damage(b.damage);
|
||
this.fx.flash(b.x, b.y, '#ff9db4', 34, 130);
|
||
combatSfx(this.scene, 'impact');
|
||
combatSfxEnemyHit(this.scene, preShields, b.damage);
|
||
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);
|
||
const preShields = b.shields;
|
||
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0;
|
||
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull
|
||
combatSfxEnemyHit(this.scene, preShields, ramSelf);
|
||
const killed = b.damage(ramSelf);
|
||
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, shieldHit, hullHit } = 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);
|
||
// The player-hit voices (one place: lances AND rams funnel here).
|
||
combatSfx(this.scene, 'impact');
|
||
if (shieldHit > 0) combatSfx(this.scene, 'shield');
|
||
if (hullHit > 0) combatSfx(this.scene, 'hull');
|
||
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);
|
||
}
|
||
combatSfx(this.scene, 'down');
|
||
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);
|
||
combatSfx(this.scene, 'respawn');
|
||
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');
|
||
combatSfx(this.scene, 'kill');
|
||
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;
|
||
}
|
||
}
|