165 lines
6.2 KiB
JavaScript
165 lines
6.2 KiB
JavaScript
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;
|
||
}
|
||
}
|