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