orbit/js/combat/NeutralShip.js

210 lines
8.7 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 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;
}
}
}