326 lines
14 KiB
JavaScript
326 lines
14 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)
|
||
* '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);
|
||
}
|
||
}
|