259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
}
|