218 lines
8.0 KiB
JavaScript
218 lines
8.0 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
}
|