210 lines
8.7 KiB
JavaScript
210 lines
8.7 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
||
import { inView, orbitStep } from './Combat.js';
|
||
import { EnemyShip } from './EnemyShip.js';
|
||
import { neutralTypeConfig } from './EnemyTypes.js';
|
||
|
||
/**
|
||
* A NEUTRAL ESCORT (data/combat.json → neutral / neutralTypes — frames
|
||
* 20–27 of the enemy sheet, the player-side roster): the same craft
|
||
* body as the hostiles (EnemyShip's art/armor/steering — the config
|
||
* just says `side: 'player'`), but the AI is a small state machine
|
||
* instead of the chase:
|
||
*
|
||
* approach → (pirates? engage : escort)
|
||
* engage → orbit the NEAREST pirate and lance it (Weapons.js's
|
||
* patterns — the same dispatch the hostiles run); the
|
||
* moment the last pirate is down it folds into escort
|
||
* escort → hold the escort ring around the player for this ship's
|
||
* window (neutral.escortMin..escortMax seconds — the
|
||
* spawner rolls it per ship); a pirate up while escorting
|
||
* flips it back into engage
|
||
* break → peel off and fly off-screen (no fire, no U-turn);
|
||
* CombatSystem despawns it the frame it is fully out
|
||
*
|
||
* PLAYER-SAFE BY CONSTRUCTION, not by promise: the only path that sets
|
||
* wantsFire is engage-at-pirate; the player never enters the target
|
||
* pool (js/combat/CombatSystem.js — auto-fire picks from the enemies
|
||
* roster, and this is a different one); the collide passes never route
|
||
* an 'ally' bolt/seeker into the player (Weapons.js's
|
||
* missilesHitPlayer skips them), and the player's own bolts never touch
|
||
* this roster (they hit `enemies`, not `allies`).
|
||
*
|
||
* `update(time, delta, player, pirates)` returns `{ wantsFire, target }`
|
||
* (target = the pirate being fired at, or null) — CombatSystem does the
|
||
* actual firing, so the on-screen gate + shared pattern dispatch stay
|
||
* in one place, exactly like the pack's.
|
||
*/
|
||
export class NeutralShip extends EnemyShip {
|
||
/**
|
||
* @param {object} scene the combat scene
|
||
* @param {number} x spawn
|
||
* @param {number} y spawn
|
||
* @param {number} index slot (orbit direction alternates)
|
||
* @param {object} [c] the craft's FULL neutral config (merged — the
|
||
* EnemyTypes.neutralTypeConfig contract)
|
||
* @param {object} [o]
|
||
* @param {number} [o.escortMs] this ship's escort window (ms) — rolled
|
||
* per ship by the spawner (neutral.escortMin..escortMax) or the
|
||
* director
|
||
* @param {Function} [o.viewFn] () => the camera's visible world rect
|
||
* (or null) — the break-off cull surface
|
||
*/
|
||
constructor(scene, x, y, index, c = null, o = {}) {
|
||
const cfg = c && typeof c === 'object' ? c : neutralTypeConfig();
|
||
super(scene, x, y, index, cfg);
|
||
|
||
this.side = 'player'; // the roster contract: never hostile to the player
|
||
this.state = 'approach';
|
||
this.escortMs = Math.max(0, Number(o.escortMs) || 0);
|
||
this.escortStart = null;
|
||
this.viewFn = typeof o.viewFn === 'function' ? o.viewFn : null;
|
||
this.target = null; // the pirate engaged (null while escort/break)
|
||
this.gone = false; // fully off-screen after the break-off
|
||
this.escortRadius = Math.max(24, Number(cfg.escortRadius) || 110);
|
||
}
|
||
|
||
/**
|
||
* Ally AI step (see the class doc).
|
||
* @param {number} time scene time (ms)
|
||
* @param {number} delta frame delta (ms)
|
||
* @param {object|null} player the player's craft (steering/fire anchor)
|
||
* @param {object[]} [pirates] the system's live enemy roster
|
||
* @returns {{wantsFire: boolean, target: (object|null)}}
|
||
*/
|
||
update(time, delta, player, pirates = []) {
|
||
const out = { wantsFire: false, target: null };
|
||
if (this.dead) return out;
|
||
const dt = Math.min(delta, 64) / 1000;
|
||
|
||
const px = player ? player.x : (this.lastPx ?? this.x);
|
||
const py = player ? player.y : (this.lastPy ?? this.y);
|
||
if (player) {
|
||
this.lastPx = px;
|
||
this.lastPy = py;
|
||
}
|
||
|
||
// Cloaked pirates are UNTARGETABLE (the same rule as pickTarget — the
|
||
// player's guns can't lock them either), so they don't count as live
|
||
// threats and never hold an engage state.
|
||
const live = (pirates || []).filter((p) => p && !p.dead && !p.cloaked);
|
||
|
||
// ---- the state machine: escort ↔ engage on the pirate count (the
|
||
// escort clock is stamped once per escort spell and keeps
|
||
// running through interruptions); window expiry is the one-way
|
||
// break-off.
|
||
if (this.state === 'engage' && live.length === 0) {
|
||
this.state = 'escort';
|
||
if (this.escortStart == null) this.escortStart = time;
|
||
} else if (this.state === 'escort' && live.length > 0) {
|
||
this.state = 'engage';
|
||
}
|
||
|
||
// ---- break: peel off and fly OUT (no fire, no U-turn)
|
||
if (this.state === 'break') {
|
||
const dx = this.x - px;
|
||
const dy = this.y - py;
|
||
const d = Math.hypot(dx, dy);
|
||
const dirx = d > 1 ? dx / d : 1;
|
||
const diry = d > 1 ? dy / d : 0;
|
||
this._steer(px + dirx * 900, py + diry * 900, this.speed, dt);
|
||
const body = this.body;
|
||
const want = body && body.velocity.length() > 4
|
||
? Math.atan2(body.velocity.y, body.velocity.x)
|
||
: Math.atan2(diry, dirx);
|
||
this.rotateToward(want + this.artOffset, dt);
|
||
// Fully off the camera's visible rect → the encounter's end.
|
||
const view = this.viewFn ? this.viewFn() : null;
|
||
if (view && !inView(this.x, this.y, Math.max(this.hitRadius, (this.size || 0) / 2), view)) {
|
||
this.gone = true; // CombatSystem despawns it this frame
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const wpn = this.wpn ?? {};
|
||
const range = Number(wpn.range) || 0;
|
||
|
||
// ---- approach: stream in from the spawn bearing onto the escort ring
|
||
if (this.state === 'approach') {
|
||
this._orbit(px, py, this.escortRadius, dt);
|
||
if (Math.hypot(px - this.x, py - this.y) < this.escortRadius * 1.6) {
|
||
this.state = live.length > 0 ? 'engage' : 'escort';
|
||
if (this.state === 'escort' && this.escortStart == null) this.escortStart = time;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- engage: orbit the NEAREST pirate and lance it
|
||
if (this.state === 'engage') {
|
||
let best = null;
|
||
let bestD = Infinity;
|
||
for (const p of live) {
|
||
const d2 = (p.x - this.x) ** 2 + (p.y - this.y) ** 2;
|
||
if (d2 < bestD) { bestD = d2; best = p; }
|
||
}
|
||
this.target = best;
|
||
if (best) {
|
||
this._orbit(best.x, best.y, this.orbitRadius, dt);
|
||
// Fire gate (engage only — the player is NEVER a target): in
|
||
// range, roughly aimed, off cooldown — the same honesty as the
|
||
// hostiles.
|
||
if (range > 0) {
|
||
const distT = Math.hypot(best.x - this.x, best.y - this.y);
|
||
if (distT < range && time >= this.nextFireAt) {
|
||
const toT = Math.atan2(best.y - this.y, best.x - this.x);
|
||
if (Math.abs(Phaser.Math.Angle.Wrap(toT - this.heading())) < 0.6) {
|
||
this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4);
|
||
out.wantsFire = true;
|
||
out.target = best;
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
// The last pirate went down mid-frame → fold into escort.
|
||
this.state = 'escort';
|
||
if (this.escortStart == null) this.escortStart = time;
|
||
}
|
||
|
||
// ---- escort: hold the ring around the player until the window lapses
|
||
this.target = null;
|
||
this._orbit(px, py, this.escortRadius, dt);
|
||
if (this.escortStart == null) this.escortStart = time;
|
||
if (time - this.escortStart >= this.escortMs) this.state = 'break';
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Steer to a point on the orbit ring around (cx, cy) — the same
|
||
* exponential approach the hostiles run (EnemyShip.update); the
|
||
* facing rule is the same too: face the motion while closing, face
|
||
* the center (aiming) on the ring.
|
||
* @private
|
||
*/
|
||
_orbit(cx, cy, radius, dt) {
|
||
const t = orbitStep(cx, cy, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, radius);
|
||
this.orbitAngle = t.angle;
|
||
const sp = Math.hypot(t.x - this.x, t.y - this.y) > radius * 0.9 ? this.speed : this.speed * 0.55;
|
||
this._steer(t.x, t.y, sp, dt);
|
||
const distC = Math.hypot(cx - this.x, cy - this.y);
|
||
const toC = Math.atan2(cy - this.y, cx - this.x);
|
||
const body = this.body;
|
||
const want = distC < radius * 1.5
|
||
? toC
|
||
: body && body.velocity.length() > 4 ? Math.atan2(body.velocity.y, body.velocity.x) : toC;
|
||
this.rotateToward(want + this.artOffset, dt);
|
||
}
|
||
|
||
/** Exponential approach toward (tx, ty) at speed sp (dt in seconds). @private */
|
||
_steer(tx, ty, sp, dt) {
|
||
const dx = tx - this.x;
|
||
const dy = ty - this.y;
|
||
const d = Math.hypot(dx, dy) || 1;
|
||
const k = 1 - Math.exp(-this.turn * dt);
|
||
const body = this.body;
|
||
if (body) {
|
||
body.velocity.x += ((dx / d) * sp - body.velocity.x) * k;
|
||
body.velocity.y += ((dy / d) * sp - body.velocity.y) * k;
|
||
}
|
||
}
|
||
}
|