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