211 lines
8.5 KiB
JavaScript
211 lines
8.5 KiB
JavaScript
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);
|
||
}
|
||
}
|