orbit/js/combat/Weapons.js

211 lines
8.5 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 { toColor } from '../utils/Color.js';
import { fanAngles, ringAngles } from './Combat.js';
import { Missile } from './Missile.js';
import { combatSfx } from './CombatSfx.js';
/**
* The hostile WEAPON PATTERNS — the shared dispatch behind the seven new
* ship types (data/combat.json → enemyTypes, weapon.pattern). One code
* path for BOTH combat contexts (the range: js/combat/CombatScene.js,
* the main game: js/combat/CombatSystem.js): each context's fireEnemy()
* hands its hostile over here, and its update()/collide() run the
* missile step + hit passes (stepMissiles / missilesHitPlayer) — so the
* two contexts can never drift.
*
* PATTERNS (weapon.pattern — 'lance' is the default, the base behavior):
* 'lance' one aimed bolt (the base craft's single shot)
* 'rapid' one bolt on a SHORT cooldown (the config's rate — a gun)
* 'spray' a FAN of weapon.bolts spread over weapon.fan rad
* 'burst' a TIGHT weapon.bolts-shot stinger — staggered speeds make
* the group fan out over distance (the close-range snap)
* 'ring' weapon.bolts fired RADIAL around the craft (random phase
* per volley — break the orbit)
* 'missile' weapon.missiles HEAT-SEEKERS — the Missile (js/combat/
* Missile.js) homes with a CAPPED turn-rate (weapon.missileTurn)
* so the player can out-turn them (the dodge rule)
* 'combo' a heavy lance PLUS a missile volley (the elite)
*
* The `ctx` host (either context) provides: .scene (the Phaser scene —
* or IS it), .fx (the Fx layer), .spawnBullet(x, y, a, wpn, owner),
* .hitsSwept(b, other, r, dtMs), .damagePlayer(dmg, o), and it owns the
* live-missile roster on .missiles (this module creates it on demand).
*/
/** The pattern a weapon config declares ('lance' when unset). */
export function patternOf(wpn) {
const p = wpn && typeof wpn === 'object' ? wpn.pattern : null;
return typeof p === 'string' && p !== '' ? p : 'lance';
}
const sceneOf = (ctx) => (ctx && ctx.textures ? ctx : ctx.scene || ctx); // CombatSystem wraps the scene; CombatScene IS it (its .scene is Phaser's SceneManager — not a scene)
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb);
/**
* Fire a hostile's weapon pattern at the player.
*
* @param {object} ctx the owning context (CombatScene | CombatSystem)
* @param {object} ship the hostile (an EnemyShip — .x/.y/.hitRadius)
* @param {object} player the player ship (.x/.y)
* @param {object} wpn the hostile's weapon config (enemy.weapon deep-merged)
* @returns {string} the pattern fired (for the dev log / tests)
*/
export function firePattern(ctx, ship, player, wpn) {
const kind = patternOf(wpn);
const toPlayer = Math.atan2(player.y - ship.y, player.x - ship.x);
const spread = Math.max(0, num(wpn.spread, 0));
const nose = Math.max(8, num(ship.hitRadius, 20)) + 10;
const jitter = () => (Math.random() * 2 - 1) * spread;
const spawnBolt = (a, wp = wpn) => {
const mx = ship.x + Math.cos(a) * nose;
const my = ship.y + Math.sin(a) * nose;
ctx.spawnBullet(mx, my, a, wp, 'enemy');
};
if (kind === 'spray') {
// The FAN: weapon.bolts across weapon.fan rad, centered on the aim.
const angles = fanAngles(toPlayer, num(wpn.bolts, 5), num(wpn.fan, 0.9));
for (const a of angles) spawnBolt(a + jitter());
} else if (kind === 'burst') {
// The STINGER: a tight group whose speeds are staggered (the outer
// shots drift apart over distance — a snap that opens into a spread).
const n = Math.max(2, Math.floor(num(wpn.bolts, 3)));
const fan = Math.max(0, num(wpn.burstFan ?? wpn.fan, 0.08));
const base = num(wpn.bulletSpeed, 420);
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
spawnBolt(toPlayer - fan / 2 + fan * t + jitter(), {
...wpn,
bulletSpeed: base * (1 + (t - 0.5) * 0.18),
});
}
} else if (kind === 'ring') {
// The RADIAL ring: evenly around 2π, a fresh random phase per volley.
const angles = ringAngles(num(wpn.bolts, 8), Math.random() * Math.PI * 2);
for (const a of angles) spawnBolt(a + jitter());
} else if (kind === 'missile') {
launchMissiles(ctx, ship, player, wpn);
} else if (kind === 'combo') {
// The elite: the heavy lance AND the missile volley on one trigger.
spawnBolt(toPlayer + jitter());
launchMissiles(ctx, ship, player, wpn);
} else {
// 'lance' (default) and 'rapid' — one aimed bolt (rapid's config just
// fires it on a shorter cooldown; the pattern is the same single shot).
spawnBolt(toPlayer + jitter());
}
// The muzzle beat — one per volley (per-bolt would rattle like a gun).
const nx = ship.x + Math.cos(toPlayer) * nose;
const ny = ship.y + Math.sin(toPlayer) * nose;
ctx.fx.muzzle(nx, ny, toPlayer, wpn.color ?? '#ff2d6f', num(wpn.muzzle, 50));
if (kind !== 'missile' && kind !== 'combo') combatSfx(sceneOf(ctx), 'fire');
return kind;
}
/**
* Launch the hostile's HEAT-SEEKING volley (the `missile`/`combo`
* patterns): weapon.missiles shots from the nose, fanned over
* weapon.missileSpread (default 0.35 rad when there's more than one).
* They home with the CAPPED turn-rate from weapon.missileTurn — the
* dodge rule (the player's combat.player.rotSpeed out-turns them).
*
* @returns {number} the missiles launched
*/
export function launchMissiles(ctx, ship, player, wpn) {
const scene = sceneOf(ctx);
const n = Math.max(1, Math.floor(num(wpn.missiles, 1)));
const toPlayer = Math.atan2(player.y - ship.y, player.x - ship.x);
const spread = n > 1 ? Math.max(0, num(wpn.missileSpread, 0.35)) : 0;
const nose = Math.max(8, num(ship.hitRadius, 20)) + 12;
const opts = {
speed: Math.max(30, num(wpn.missileSpeed, 200)),
turnRate: Math.max(0.2, num(wpn.missileTurn, 2.2)), // the cap (dodge rule)
damage: Math.max(1, num(wpn.missileDamage, 14)),
life: Math.max(0.5, num(wpn.missileLife, 4)),
color: wpn.missileColor ?? wpn.color ?? '#ff9d3d',
size: Math.max(10, num(wpn.missileSize, 18)),
owner: 'enemy',
};
if (!Array.isArray(ctx.missiles)) ctx.missiles = [];
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
const a = toPlayer - spread / 2 + spread * t;
ctx.missiles.push(
new Missile(scene, {
x: ship.x + Math.cos(a) * nose,
y: ship.y + Math.sin(a) * nose,
angle: a,
...opts,
}),
);
}
combatSfx(scene, 'missile_fire'); // the rocket voice (distinct from the laser)
return n;
}
/**
* Step the live missiles (age the fuses + home on the player — or fly
* true when the player is down, `player` = null). The range clips them
* on the arena walls (bounds = { inset, w, h }); the main game passes
* null (no walls there). Consumed missiles are culled from ctx.missiles.
*/
export function stepMissiles(ctx, delta, player, bounds = null) {
const ms = ctx.missiles;
if (!ms || ms.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
m.update(delta, player);
if (m.dead) {
ms.splice(i, 1);
fizzleFx(ctx, m); // the fuse ran out — a small bloom
continue;
}
if (
bounds &&
(m.x < bounds.inset ||
m.x > bounds.w - bounds.inset ||
m.y < bounds.inset ||
m.y > bounds.h - bounds.inset)
) {
if (m.consume()) {
ms.splice(i, 1);
fizzleFx(ctx, m); // the wall eats the rocket (like the tracers)
}
}
}
}
/**
* The missile hit pass: live missiles × the player (the context's swept
* rule, same as the tracers). A hit deals the missile's damage from its
* incoming bearing, blooms, and consumes it. No player (down) = the
* shots keep flying (their fuses end them).
*/
export function missilesHitPlayer(ctx, player, playerRadius, delta) {
const ms = ctx.missiles;
if (!ms || ms.length === 0 || !player) return;
const r = Math.max(4, num(playerRadius, 12));
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) {
ms.splice(i, 1);
continue;
}
if (ctx.hitsSwept(m, player, r, delta)) {
// The shield is struck from the missile's incoming bearing.
ctx.damagePlayer(m.damage, { x: m.x, y: m.y, bearing: m.heading + Math.PI });
if (ctx.fx && typeof ctx.fx.explode === 'function') {
ctx.fx.explode(m.x, m.y, toColor(m.color, 0xffb36b));
}
if (m.consume()) ms.splice(i, 1);
}
}
}
/** The small death bloom (fuse expiry / the wall) — a soft flash, no boom. */
function fizzleFx(ctx, m) {
if (ctx.fx && typeof ctx.fx.flash === 'function') {
ctx.fx.flash(m.x, m.y, toColor(m.color, 0xffb36b), 16, 180);
}
}