orbit/js/combat/EnemyShip.js

218 lines
8.0 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 { config } from '../config/Config.js';
import { toColor } from '../utils/Color.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. 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.
*/
export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
static TEXTURE_KEY = '__enemy_ship';
/** 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 TEXTURE_KEY. The spritesheet (loaded
* by CombatScene.preload / GameScene.preload) wins; otherwise the
* built-in wedge — a blunt V-fin raider, clearly NOT the player dart.
*/
static ensureTexture(scene) {
if (scene.textures.exists(EnemyShip.TEXTURE_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(EnemyShip.TEXTURE_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)
*/
constructor(scene, x, y, index) {
const hasSheet = scene.textures.exists(EnemyShip.TEXTURE_KEY);
EnemyShip.ensureTexture(scene);
super(scene, x, y, EnemyShip.TEXTURE_KEY, config.get('combat.enemy.frame', 0));
scene.add.existing(this);
scene.physics.add.existing(this);
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, config.get('combat.enemy.size', 54));
const frameWidth = hasSheet
? Math.max(1, config.get('combat.enemy.frameWidth', 128))
: FALLBACK_W;
this.setScale(this.size / frameWidth);
const facing = EnemyShip.FACING_ANGLE[config.get('combat.enemy.artFacing', 'east')] ?? 0;
this.artOffset = hasSheet ? -facing : 0;
// A circle body (world px — data/combat.json → enemy.hitRadius):
// the scene's collision pass uses it for rams + bullets.
this.hitRadius = Math.max(4, config.get('combat.enemy.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, config.get('combat.enemy.hull', 60));
this.maxShields = Math.max(0, config.get('combat.enemy.shields', 0));
this.hull = this.maxHull;
this.shields = this.maxShields;
this.dead = false;
// Chase feel (data/combat.json → enemy).
this.speed = Math.max(0, config.get('combat.enemy.speed', 165));
this.turn = Math.max(0.1, config.get('combat.enemy.turn', 3.2));
this.orbitRadius = Math.max(40, config.get('combat.enemy.orbitRadius', 300));
this.orbitSpeed = config.get('combat.enemy.orbitSpeed', 0.6);
// Lance (data/combat.json → enemy.weapon).
this.wpn = config.section('combat.enemy.weapon', {});
this.nextFireAt = 0; // ms — the scene grants a short grace on spawn
// 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;
if (this.body) {
this.body.velocity.x = 0;
this.body.velocity.y = 0;
}
}
}