orbit/js/combat/EnemyShip.js

259 lines
10 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 { toColor } from '../utils/Color.js';
import { assignWorld } from '../visuals/UiCameras.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { settleCraftTint } from './Fx.js';
import { applyDamage, headingOf, orbitStep } from './Combat.js';
const FALLBACK_W = 76; // the procedural wedge (it faces east)
const FALLBACK_H = 46;
/**
* A sandbox hostile (data/combat.json → enemy): an arcade-physics sprite
* that keeps a standoff ring around the player, circles it (direction
* alternates per craft), and lances when in range and roughly aimed.
* The AI is deliberately dumb — the sandbox (js/combat/CombatScene.js)
* is where it gets tuned before combat ships in the real game.
*
* Art: assets/images/ships-enemy.png — frameWidth×frameHeight frames,
* `frame` picks which one (frame 0 = the red triple-boost fighter, art
* facing north, filling its frame). The art's facing works exactly like
* the player's (js/entities/Ship.js): a constant render offset
* (this.artOffset) so heading math stays world-up = -π/2. A type may ride
* the same sheet on a different `frame`, or bring its own `texture`
* (EnemyTypes.enemySheetList is the preload seam; one texture key per
* source file). If the sheet isn't configured or didn't load, the
* built-in wedge is drawn instead (it faces east, offset 0).
*
* Armor: { shields, hull } per js/combat/Combat.js (shields absorb
* first). damage() applies a hit; the scene owns the FX + respawn.
*
* Config: the constructor takes the craft's FULL config (an
* EnemyTypes.enemyTypeConfig merge — the base `combat.enemy` craft, or a
* named type's overrides merged onto it). Null = the base type. The
* per-type numbers (armor, chase feel, weapon, ram, respawn) all read
* off THAT object — the wave runner's ships and the sandbox's pack share
* one code path, only the config differs.
*/
export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
/** The texture key a source file lands under (the no-texture wedge's key is src-less). */
static FALLBACK_KEY = '__enemy_ship_fallback';
static textureKey(src) {
if (typeof src !== 'string' || src === '') return EnemyShip.FALLBACK_KEY;
const name = src.split('/').pop().replace(/\.[^.]+$/, ''); // 'ships-enemy'
return `__enemy_ship_${name}`;
}
/** World angle (rad) for each direction the sheet art may face. */
static FACING_ANGLE = {
north: -Math.PI / 2,
east: 0,
south: Math.PI / 2,
west: Math.PI,
};
/**
* Ensure a texture exists under the key for `src`. The spritesheet
* (loaded by CombatScene.preload / GameScene.preload from
* EnemyTypes.enemySheetList) wins; otherwise the built-in wedge — a
* blunt V-fin raider, clearly NOT the player dart.
*/
static ensureTexture(scene, src) {
const key = EnemyShip.textureKey(src);
if (scene.textures.exists(key)) return;
const w = FALLBACK_W;
const h = FALLBACK_H;
const hull = toColor('#c8465c', 0xc8465c);
const trim = toColor('#f0b7c2', 0xf0b7c2);
const g = scene.make.graphics({ add: false });
g.fillStyle(hull, 1);
g.beginPath();
g.moveTo(w - 4, h / 2); // nose (east)
g.lineTo(10, 2); // upper fin
g.lineTo(22, h / 2); // tail notch
g.lineTo(10, h - 2); // lower fin
g.closePath();
g.fillPath();
g.fillStyle(trim, 1);
g.fillCircle(Math.round(w - 20), h / 2, 4); // cockpit
g.generateTexture(key, w, h);
g.destroy();
}
/**
* @param {object} scene the combat scene
* @param {number} x spawn
* @param {number} y spawn
* @param {number} index the craft's slot in the pack (orbit direction alternates)
* @param {object} [cfg] the craft's FULL enemy config (EnemyTypes.enemyTypeConfig's
* merge — the base `combat.enemy` craft or a named type's). Null = the base.
*/
constructor(scene, x, y, index, cfg = null) {
const c = cfg && typeof cfg === 'object' ? cfg : enemyTypeConfig();
const src = typeof c.texture === 'string' ? c.texture : '';
const key = EnemyShip.textureKey(src);
const hasSheet = src !== '' && scene.textures.exists(key);
if (!hasSheet) EnemyShip.ensureTexture(scene, src);
super(scene, x, y, key, Number(c.frame) || 0);
scene.add.existing(this);
scene.physics.add.existing(this);
assignWorld(scene, this); // world pass (the UI pass must not double-draw)
this.index = index ?? 0;
this.orbitDir = this.index % 2 === 0 ? 1 : -1;
this.orbitAngle = Math.random() * Math.PI * 2; // the scene re-anchors it on spawn
// World size + render scale (the same contract as Ship: size × the
// art's footprint = the hull's world length).
this.size = Math.max(4, Number(c.size) || 54);
const frameWidth = hasSheet ? Math.max(1, Number(c.frameWidth) || 128) : FALLBACK_W;
this.setScale(this.size / frameWidth);
const facing = EnemyShip.FACING_ANGLE[c.artFacing ?? 'east'] ?? 0;
this.artOffset = hasSheet ? -facing : 0;
// A circle body (world px — the type's hitRadius): the scene's
// collision pass uses it for rams + bullets.
this.hitRadius = Math.max(4, Number(c.hitRadius) || 20);
const body = this.body;
body.setCircle(this.hitRadius / this.scale); // texture-space radius (auto-scales)
body.setAllowGravity(false);
// Armor (js/combat/Combat.js rule: shields first, then hull).
this.maxHull = Math.max(1, Number(c.hull) || 60);
this.maxShields = Math.max(0, Number(c.shields) || 0);
this.hull = this.maxHull;
this.shields = this.maxShields;
this.dead = false;
// XP — what a kill of this craft is worth (the type's own `xp`, or
// the base enemy.xp when a type omits it; js/combat/XpManager.js's
// bank, the curve in data/progression.json).
this.xp = Math.max(0, Number(c.xp) || 10);
// Chase feel (the type's numbers).
this.speed = Math.max(0, Number(c.speed) ?? 165);
this.turn = Math.max(0.1, Number(c.turn) ?? 3.2);
this.orbitRadius = Math.max(40, Number(c.orbitRadius) ?? 300);
this.orbitSpeed = Number(c.orbitSpeed) ?? 0.6;
// Lance (the type's weapon.*).
this.wpn = c.weapon && typeof c.weapon === 'object' ? c.weapon : {};
this.nextFireAt = 0; // ms — the scene grants a short grace on spawn
// Ram + respawn (per-type — CombatSystem reads them off the ship;
// the wave runner's ships never take the respawn clock).
this.ramDamage = Number(c.ramDamage) ?? 10;
this.ramDamageSelf = Number(c.ramDamageSelf) ?? 22;
this.ramGraceMs = (Number(c.ramGrace) ?? 0.8) * 1000;
this.respawn = c.respawn !== false;
this.respawnDelayMs = (Number(c.respawnDelay) ?? 2.5) * 1000;
// The last player position (orbiting it while the player is down).
this.lastPx = x;
this.lastPy = y;
}
/** @returns {number} the render heading in world radians */
heading() {
return headingOf(this);
}
/** Rotate the render toward `angle` at the configured turn rate. */
rotateToward(angle, dt) {
let delta = Phaser.Math.Angle.Wrap(angle - this.rotation);
const step = this.turn * dt;
if (Math.abs(delta) <= step) this.rotation = angle;
else this.rotation += Math.sign(delta) * step;
}
/**
* Per-frame AI. Steers the standoff ring around the player, faces it
* when close enough to aim, and asks to fire when lined up.
*
* @param {number} time game time (ms)
* @param {number} delta frame delta (ms)
* @param {object|null} player the player ship, or null when the player is down
* @returns {boolean} true when this craft wants to fire THIS frame
*/
update(time, delta, player) {
if (this.dead) return false;
const dt = Math.min(delta, 64) / 1000;
const px = player ? player.x : this.lastPx;
const py = player ? player.y : this.lastPy;
if (player) {
this.lastPx = px;
this.lastPy = py;
}
// The standoff ring: circle the player at orbitRadius.
const t = orbitStep(px, py, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, this.orbitRadius);
this.orbitAngle = t.angle;
const dx = t.x - this.x;
const dy = t.y - this.y;
const d = Math.hypot(dx, dy) || 1;
const dist = Math.hypot(px - this.x, py - this.y);
// Chase speed: full when far off the ring, half when crowding in.
const sp = d > this.orbitRadius * 0.9 ? this.speed : this.speed * 0.55;
// Exponential velocity approach (smooth turns, no overshoot).
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;
}
// Face the player when close (aiming), else face the motion.
const toPlayer = Math.atan2(py - this.y, px - this.x);
const want = dist < this.orbitRadius * 1.5
? toPlayer
: (body && body.velocity.length() > 4 ? Math.atan2(body.velocity.y, body.velocity.x) : toPlayer);
this.rotateToward(want + this.artOffset, dt);
// Lance? In range, roughly aimed, off cooldown — and only at a live player.
const wpn = this.wpn ?? {};
const range = Number(wpn.range) || 0;
if (player && range > 0 && dist < range && time >= this.nextFireAt) {
const lead = Math.abs(Phaser.Math.Angle.Wrap(toPlayer - this.heading()));
if (lead < 0.6) {
this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4);
return true;
}
}
return false;
}
/**
* Take a hit: shields first, then hull (js/combat/Combat.js).
* @param {number} n damage
* @returns {boolean} true when this shot killed the craft
*/
damage(n) {
if (this.dead) return false;
const { dead } = applyDamage(this, n);
if (dead) this.dead = true;
return dead;
}
/** Bring the craft back to full (the scene re-positions + re-arms it). */
revive() {
this.hull = this.maxHull;
this.shields = this.maxShields;
this.dead = false;
// THE REUSE SEAM: this is the SAME sprite that died (the range and
// the main game both revive in place). If a white hit-flash was
// still pending when it went down, settle the tint here too — a
// revived craft must never come back hot white.
settleCraftTint(this);
if (this.body) {
this.body.velocity.x = 0;
this.body.velocity.y = 0;
}
}
}