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