orbit/js/combat/Weapons.js

326 lines
14 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)
* 'volley' a WIDE fan of weapon.bolts whose speeds are staggered — the
* outer bolts drift apart over distance (the burst's stagger
* across the spray's fan: a blossoming wall)
* '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)
* 'sweep' a FAN of weapon.bolts centered on the craft's OWN HEADING
* (not the player) — the forward-facing cone: heavy metal
* straight out the nose (the Apex's forward firepower)
* 'spiral' weapon.bolts stepped by weapon.spiralStep rad each, with
* staggered speeds — the group blossoms into a SPIRAL over
* distance (the Mindweave's 1942-boss barrages)
* 'shell' weapon.shells LARGE-RADIUS slow orbs (shellSize/shellSpeed/
* shellDamage/shellLife) — heavy punch, wide hit area, short
* fuse (the Dreamshell's shells)
*
* 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;
// Bolt owner (the player-safety seam): hostiles run 'enemy'; a neutral
// escort's firePattern call passes its weapon with `owner: 'ally'` and
// every bolt it spawns carries the tag — CombatSystem's collide passes
// route 'ally' bolts into pirates (never the player), and the player's
// auto-fire never targets the escort at all (a different roster).
const owner = typeof (wpn && wpn.owner) === 'string' && wpn.owner !== '' ? wpn.owner : 'enemy';
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, owner);
};
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 === 'volley') {
// The VOLLEY: a WIDE fan whose speeds are staggered (the outer bolts
// drift apart over distance, so the arc blossoms into a wall).
const n = Math.max(2, Math.floor(num(wpn.bolts, 7)));
const fan = Math.max(0.05, num(wpn.fan, 1.4));
const stagger = Math.max(0, num(wpn.stagger, 0.35));
const base = Math.max(40, num(wpn.bulletSpeed, 280));
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) * stagger),
});
}
} 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 if (kind === 'sweep') {
// The SWEEP: a fan centered on the craft's OWN HEADING (its nose —
// the forward-facing cone, not an aim at the player).
const n = Math.max(2, Math.floor(num(wpn.bolts, 7)));
const fan = Math.max(0.1, num(wpn.fan, 1.1));
const heading = typeof ship.heading === 'function' ? ship.heading() : toPlayer;
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
spawnBolt(heading - fan / 2 + fan * t + jitter());
}
} else if (kind === 'spiral') {
// The SPIRAL: each bolt one step FURTHER around than the last (the
// 1942-boss spin), with a speed stagger so the arms open in flight.
const n = Math.max(3, Math.floor(num(wpn.bolts, 10)));
const step = Math.max(0.1, num(wpn.spiralStep, 0.55));
const stagger = Math.max(0, num(wpn.stagger, 0.12));
const base = Math.max(60, num(wpn.bulletSpeed, 220));
const a0 = toPlayer + (Math.random() * 2 - 1) * 0.2;
for (let i = 0; i < n; i++) {
spawnBolt(a0 + i * step + jitter(), {
...wpn,
bulletSpeed: base * (1 + (i / n - 0.5) * stagger * 2),
});
}
} else if (kind === 'shell') {
// The SHELLS: one (or a few) LARGE-RADIUS slow orbs — big visual and
// collision area, heavy damage, a short fuse. The override wpn copy
// keeps the weapon's color/muzzle; the shell's numbers own the rest.
const n = Math.max(1, Math.floor(num(wpn.shells, 1)));
for (let i = 0; i < n; i++) {
spawnBolt(toPlayer + jitter() + (n > 1 ? (i - (n - 1) / 2) * 0.16 : 0), {
...wpn,
size: num(wpn.shellSize, 44),
boltLength: num(wpn.shellLength, 46),
boltWidth: num(wpn.shellWidth, 60),
bulletSpeed: num(wpn.shellSpeed, 150),
bulletLife: num(wpn.shellLife, 2.4),
damage: num(wpn.shellDamage, 12),
});
}
} 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;
}
/**
* Which craft a given missile homes on this frame (stepMissiles's owner
* rule).
*/
function missileTargetOf(m, player, ctx) {
const ref = m.targetRef;
if (ref && ref.x != null && ref.dead !== true) return ref;
if (m.owner === 'ally') {
if (typeof ctx.allyMissileTarget === 'function') {
try {
const t = ctx.allyMissileTarget(m);
if (t && t.dead !== true) return t;
} catch { /* no seam in context: fly straight */ }
}
return null; // straight flight, the fuse ends it — never a player threat
}
// THE WAR-ZONE DISPOSITION (the spawner's stamp rides the seeker —
// launchMissiles): a neutral-to-the-player faction's seeker never
// re-homes onto the ship — its intended victim was a rival craft, and
// when that falls the rocket flies true and burns out.
if (m.hostileToPlayer === false) return null;
return player;
}
/**
* 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)),
// Seeker owner (the player-safety seam, see firePattern): 'enemy' by
// default; 'ally' when a neutral escort fires the pattern (the seeker
// then locks the pirate it was aimed at — see stepMissiles).
owner: typeof wpn.owner === 'string' && wpn.owner !== '' ? wpn.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;
const m = new Missile(scene, {
x: ship.x + Math.cos(a) * nose,
y: ship.y + Math.sin(a) * nose,
angle: a,
...opts,
});
m.targetRef = player; // the intended victim (the seeker's home target)
// THE WAR-ZONE DISPOSITION (the shooter's stamp — fireEnemy merges it
// onto the weapon): the seeker's fizzle rule + the no-rehome rule
// above read it off the missile.
m.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true;
// The shooter's FACTION (the crossfire pass's friendly-fire rule —
// a faction's seeker drops the rival faction's craft, never its own
// wing; a classic seeker has none and the pass stands it off).
m.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null;
ctx.missiles.push(m);
}
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];
// Home target (owner rule): an escort's seeker locks its intended
// victim (m.targetRef), then re-homes to the nearest live one via the
// context seam (CombatSystem.allyMissileTarget), else flies straight;
// hostile seekers keep the classic player home (or their own intended
// victim, when a mixed-encounter pirate aimed at an escort).
m.update(delta, missileTargetOf(m, player, ctx));
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 (m.owner === 'ally') continue; // no friendly fire: escort seekers pass by
if (m.hostileToPlayer === false) continue; // the neutral faction's seeker: no player threat
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);
}
}