import Phaser from '../vendor/phaser.js'; import { toColor } from '../utils/Color.js'; import { assignWorld } from '../visuals/UiCameras.js'; import { canvasTexture } from '../utils/Textures.js'; import { homingStep } from './Combat.js'; const TEX_KEY = '__combat_missile'; const TEX_W = 64; const TEX_H = 32; const BODY_LEN = 53; // the missile's drawn extent in texture px (x ≈ 10 → 63) — the world length = scale × BODY_LEN /** * The missile's art (white alpha, so the sprite's tint reads as the * weapon's color): an exhaust bloom at the tail, twin fins, a slim * capsule body, and a pointed nose with a hot core dot. Laid along +x, * nose to the right (x = TEX_W is the tip) — the sprite rotates with * the flight heading, exactly like the bolt. */ function drawMissile(ctx, w, h) { const c = h / 2; // 1) The exhaust bloom — a soft flame at the tail (left). let g = ctx.createRadialGradient(9, c, 0, 9, c, 15); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.45, 'rgba(255,255,255,0.5)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 26, h); // 2) Twin fins at the tail. ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.moveTo(22, c - 3); ctx.lineTo(9, c - 11); ctx.lineTo(28, c - 3); ctx.moveTo(22, c + 3); ctx.lineTo(9, c + 11); ctx.lineTo(28, c + 3); ctx.fill(); // 3) The body capsule (bright core, soft edges top/bottom). g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0.0, 'rgba(255,255,255,0.30)'); g.addColorStop(0.5, 'rgba(255,255,255,0.95)'); g.addColorStop(1.0, 'rgba(255,255,255,0.30)'); ctx.fillStyle = g; ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(16, c - 5, 38, 10, 5); else ctx.rect(16, c - 5, 38, 10); ctx.fill(); // 4) The nose cone. ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.beginPath(); ctx.moveTo(54, c - 5); ctx.lineTo(63, c); ctx.lineTo(54, c + 5); ctx.closePath(); ctx.fill(); // 5) The hot core dot at the nose. g = ctx.createRadialGradient(57, c, 0, 57, c, 5); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(52, c - 5, 12, 10); } /** * A HEAT-SEEKING missile (data/combat.json → enemy.weapon, the * `missile` / `combo` patterns — the `hunter` and `warden` hostiles): * a slow-ish projectile that homes on the player with a CAPPED * turn-rate (`turnRate` rad/s, from weapon.missileTurn). The cap is the * DOGE RULE — the player's sustained turn (combat.player.rotSpeed) is * comfortably above the missile's, so a hard pull out-turns the shot. * The homing math is Combat.js → homingStep (pure, tested); this class * only carries it as a sprite. * * Like the bolt (js/combat/Bullet.js) it is scene-stepped: the owning * context (js/combat/CombatScene.js / CombatSystem.js) pushes it into * its `missiles` roster and runs js/combat/Weapons.js's step + hit * passes (the scene decides what it hits — the missile just reports its * world radius, `this.radius`, and its flight heading, `this.heading`). */ export class Missile extends Phaser.Physics.Arcade.Sprite { static TEXTURE_KEY = TEX_KEY; /** Ensure the missile texture exists (canvas-generated). */ static ensureTexture(scene) { if (scene.textures.exists(TEX_KEY)) return TEX_KEY; if (typeof document === 'undefined') return TEX_KEY; // headless (the tests) canvasTexture(scene, TEX_KEY, TEX_W, TEX_H, (ctx, w, h) => drawMissile(ctx, w, h)); return TEX_KEY; } /** * @param {object} scene the combat scene * @param {object} o { x, y, angle, speed, turnRate, damage, life, color, size, owner } * angle rad (initial flight heading), speed px/s (constant), * turnRate rad/s (the homing CAP — the dodge rule), * size px (the missile's world length). */ constructor(scene, o) { Missile.ensureTexture(scene); super(scene, o.x, o.y, TEX_KEY); scene.add.existing(this); scene.physics.add.existing(this); assignWorld(scene, this); // world pass (the UI pass must not double-draw) this.owner = o.owner ?? 'enemy'; this.damage = Math.max(0, Number(o.damage) || 14); this.life = Math.max(0.1, Number(o.life) || 4); this.dead = false; // consumed (a hit or the end of life) — consume() is one-shot // The flight state (Weapons.js / the scene steps it each frame). this.heading = Number(o.angle) || 0; this.speed = Math.max(10, Number(o.speed) || 200); this.turnRate = Math.max(0, Number(o.turnRate) || 2.2); // rad/s cap this.color = o.color ?? '#ff9d3d'; // World length = `size` px (the same contract as the ship's: length × // the art's footprint). The body fills BODY_LEN of TEX_W texture px. const size = Math.max(8, Number(o.size) || 18); this.setScale(size / BODY_LEN); this.tint = toColor(o.color, 0xffb36b); this.setDepth(20); this.rotation = this.heading; // The world-px radius for the context's circle checks. this.radius = Math.max(4, size * 0.4); const body = this.body; body.setCircle(TEX_H / 2); // texture-space radius (auto-scales with the sprite) body.setAllowGravity(false); body.setVelocity(Math.cos(this.heading) * this.speed, Math.sin(this.heading) * this.speed); } /** * One step (ms): age the fuse, and home on `target` (the player — or * null when the player is down: the shot flies true on its last * heading). The turn is CAPPED by turnRate×dt (the dodge rule). */ update(deltaMs, target = null) { if (this.dead) return; const dt = Math.min(deltaMs, 64) / 1000; this.life -= dt; if (this.life <= 0) { this.consume(); return; } if (target && target.x != null) { this.heading = homingStep(this.heading, this.x, this.y, target.x, target.y, this.turnRate, dt); const body = this.body; if (body) body.velocity.set(Math.cos(this.heading) * this.speed, Math.sin(this.heading) * this.speed); this.rotation = this.heading; } } /** * Consume the missile (a hit, or the end of its fuse). One-shot: only * the FIRST caller destroys it. * @returns {boolean} true when THIS call consumed it */ consume() { if (this.dead) return false; this.dead = true; this.destroy(); return true; } }