orbit/js/combat/Missile.js

165 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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