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