/** * SKILL EFFECTS — what a skill DOES (the handlers js/combat/CombatSystem.js * dispatches to through data/skills.json → skills[].effect.kind). * * The split stays the project's: JSON owns every number (speed, duration, * overshoot, blast radius, damage multipliers, hull bonus, colors), this file * owns the MECHANIC. Nothing here knows about a specific skill id — a new * active skill with an existing `kind` needs no code at all, and a new kind is * one entry in SKILL_EFFECTS. * * THE PURE HELPERS (dashPoint / blastDamage / radiusHits / hullMax) are the * math the combat system runs on, exposed so dev/skills-combat.test.mjs can * assert the shipped numbers directly — including the "regular damage" the * multipliers ride on, which is data/combat.json → player.weapon.damage. * * A handler's signature is (combat, def, rank) → a small result record; it may * touch the world (move the ship, spend armor, spawn FX). `def` is the normalized * record from js/skills/SkillModel.js (so def.effect is the data's effect block), * `rank` is the points SPENT in that skill (≥1 — rank 0 never reaches a handler). */ import { config } from '../config/Config.js'; /** "Regular damage": the cannon's own number (data/combat.json → player.weapon). */ export function baseWeaponDamage() { const wpn = config.section('combat.player.weapon', {}); return Math.max(0, Number(wpn.damage) || 0); } /** * A blast's damage at a given rank: the data's rank-1 multiple of the cannon's * damage, plus the per-extra-point multiple for every point AFTER the first — * 4x at 1/5, 6x at 2/5 … 12x at 5/5 (data/skills.json → effect.damageMul / * damageMulPerRank). Rank is clamped to ≥1: an inert skill never fires. */ export function blastDamage(effect = {}, rank = 1, base = baseWeaponDamage()) { const mul = Number(effect.damageMul) || 0; const perRank = Number(effect.damageMulPerRank) || 0; const r = Math.max(1, Math.floor(Number(rank) || 1)); const total = mul + perRank * (r - 1); return Math.max(0, base * total); } /** The multiple of "regular damage" a rank deals (4 → 6 → … → 12). */ export function damageMultiple(effect = {}, rank = 1) { const mul = Number(effect.damageMul) || 0; const perRank = Number(effect.damageMulPerRank) || 0; return mul + perRank * (Math.max(1, Math.floor(Number(rank) || 1)) - 1); } /** * The dash's AIM POINT: the target's position AT CAST plus `overshootPx` PAST * it, so the ship ends up through the hostile instead of inside it. With no * target (an empty sky) the charge flies straight ahead from the ship — the * data's overshoot still applies, and the blast simply finds nobody. * @returns {{x: number, y: number, angle: number, distance: number}} */ export function dashPoint(fromX = 0, fromY = 0, target = null, overshootPx = 0) { const ox = Number(overshootPx) || 0; let angle; if (target && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y))) { angle = Math.atan2(Number(target.y) - fromY, Number(target.x) - fromX); } else { angle = 0; // nowhere to charge: hold the line and drift forward } const gap = target ? Math.hypot(Number(target.x) - fromX, Number(target.y) - fromY) : 0; const reach = Math.max(0, gap + ox); return { x: fromX + Math.cos(angle) * reach, y: fromY + Math.sin(angle) * reach, angle, distance: reach, }; } /** The hull pool a rank of a `hullPool` passive leaves you with (base × 1+per×rank). */ export function hullMax(baseMaxHull = 0, effect = {}, rank = 0) { const base = Math.max(0, Number(baseMaxHull) || 0); const per = Number(effect.hullBonusPerRank) || 0; const r = Math.max(0, Math.floor(Number(rank) || 0)); return base * (1 + per * r); } /** The bonus itself (0.25 at 5/5 with the shipped 0.05 — a ratio, not a %. */ export function hullBonus(effect = {}, rank = 0) { const per = Number(effect.hullBonusPerRank) || 0; return per * Math.max(0, Math.floor(Number(rank) || 0)); } /** Every live craft whose hull touches (x, y) within `radius` px (+ its own). */ export function radiusHits(crafts = [], x = 0, y = 0, radius = 0) { const r = Math.max(0, Number(radius) || 0); return crafts.filter((c) => { if (!c || c.dead === true || c._removed === true) return false; const reach = r + (Number(c.hitRadius) || Number(c.radius) || 0); return Math.hypot(Number(c.x) - x, Number(c.y) - y) <= reach; }); } /** * The stun a `boleadoras` set's impact drops at a given rank, in MS (the * combat loop's clock — data/skills.json → effect.disableBaseMs / * disablePerRankMs): 0.5s base + 0.5s per point, so 1000 at 1/5 … 3000 at * 5/5. Rank is clamped to ≥1 (an inert skill never snags anyone). */ export function stunMs(effect = {}, rank = 1) { const r = Math.max(1, Math.floor(Number(rank) || 1)); return Math.max(0, (Number(effect.disableBaseMs) || 0) + (Number(effect.disablePerRankMs) || 0) * r); } /** * The `blink` decoy's stand at a given rank, in MS (the combat loop's clock * — data/skills.json → effect.decoyBaseMs / decoyPerRankMs): 0.4s base + * 0.4s per point, so 800 at 1/5 … 2400 at 5/5. Rank is clamped to ≥1. */ export function decoyMs(effect = {}, rank = 1) { const r = Math.max(1, Math.floor(Number(rank) || 1)); return Math.max(0, (Number(effect.decoyBaseMs) || 0) + (Number(effect.decoyPerRankMs) || 0) * r); } /** * THE NUMBER OF ARCS A `flameArc` CAST SENDS AT A GIVEN RANK (data/skills.json * → effect.targetBase / extraTargetAtRank / extraTargets — INFERNO): the * nearest `targetBase` hostiles, plus `extraTargets` more from * `extraTargetAtRank` onward (the 4th point: 2 → 3). The rank is clamped to * ≥1 (an inert skill arcs no one), and a missing table degrades to zero * arcs (inert), never to a made-up count. * * @param {object} effect a flameArc effect (def.effect off the model) * @param {number} rank points spent (≥1) * @returns {number} the number of hostiles to arc */ export function flameTargetCount(effect = {}, rank = 1) { const r = Math.max(1, Math.floor(Number(rank) || 1)); let n = Math.max(0, Math.floor(Number(effect.targetBase) || 0)); const at = Number(effect.extraTargetAtRank); if (Number.isFinite(at) && r >= at) n += Math.max(0, Math.floor(Number(effect.extraTargets) || 0)); return n; } /** * THE BURN A `flameArc` IMPACT SETS AT A GIVEN RANK, in MS of the combat * loop's clock (data/skills.json → effect.burnBaseMs / burnPerRankMs): * the 0.5 s base plus 0.5 s for every point after the first — 500 at 1/5 … * 2500 at 5/5. Zero or less (inert) burns for nothing. * * @param {object} effect a flameArc effect * @param {number} rank points spent (≥1) * @returns {number} the burn duration in MS */ export function burnDurationMs(effect = {}, rank = 1) { const r = Math.max(1, Math.floor(Number(rank) || 1)); return Math.max(0, (Number(effect.burnBaseMs) || 0) + (Number(effect.burnPerRankMs) || 0) * (r - 1)); } /** * THE DAMAGE MULTIPLE ONE BURN TICK DEALS AT A GIVEN RANK (data/skills.json * → effect.burnDamageMul / burnDamagePerRank): the 1x base plus 0.1 per point * after the first — 1 at 1/5 … 1.4 at 5/5. (The tick's actual damage is this * times the weapon's own damage — see burnTickDamage.) * * @param {object} effect a flameArc effect * @param {number} rank points spent (≥1) * @returns {number} the damage multiple (0 when inert) */ export function burnTickMultiple(effect = {}, rank = 1) { const r = Math.max(1, Math.floor(Number(rank) || 1)); return (Number(effect.burnDamageMul) || 0) + (Number(effect.burnDamagePerRank) || 0) * (r - 1); } /** * THE ACTUAL DAMAGE ONE BURN TICK DEALS AT A GIVEN RANK: burnTickMultiple × * the weapon's own damage (the same `base` blastDamage multiplies on — the * burn's "1x regular damage" is the weapon's own number). * * @param {object} effect a flameArc effect * @param {number} rank points spent (≥1) * @param {number} base the weapon's own damage * @returns {number} the per-tick damage (0 when inert) */ export function burnTickDamage(effect = {}, rank = 1, base = baseWeaponDamage()) { return Math.max(0, base) * burnTickMultiple(effect, rank); } /** * THE HULL A `hullRegen` PASSIVE REGROWS PER TICK (data/skills.json → * effect.regenPercentPerRank — NANOBOTS): the hull's MAX × 1% per point — * 1% at 1/5 … 5% at 5/5 — as a flat hull number. A ratio of the max (like * hullPool's bonus), so the mend scales with the ship's armor. * * @param {number} maxHull the hull's max (after the pool's roll) * @param {object} effect a hullRegen effect * @param {number} rank points spent * @returns {number} the mend per tick (0 when inert) */ export function hullRegenAmount(maxHull = 0, effect = {}, rank = 1) { return Math.max(0, Number(maxHull) || 0) * (Number(effect.regenPercentPerRank) || 0) * Math.max(0, Math.floor(Number(rank) || 0)); } /** * '#ff3a4d' / '0xff3a4d' / 16711501 → the int Graphics + Phaser text want. * The data writes colors as strings (every other sheet does); the FX layer * takes ints, and this is where the two meet. `fb` is the inert fallback. */ export function hexToInt(value, fb = 0xffffff) { if (typeof value === 'number' && Number.isFinite(value)) return value; const s = String(value ?? '').trim().replace(/^#/, ''); if (!/^[0-9a-fA-F]{6}$/.test(s) && !/^[0-9a-fA-F]{3}$/.test(s)) return fb; const hex = s.length === 3 ? s.split('').map((c) => c + c).join('') : s; return parseInt(hex, 16); } // ---------------------------------------------------------------- handlers /** * dashBlast — RAMMING CHARGE's mechanic. The combat system owns the flight * (its update() steps the dash and calls _endDash → skillBlast); this handler * only reports what the charge is aiming at, so `cast()` can hand the scene a * readable result and the test can pin the numbers. Returning the plan rather * than flying keeps the mechanic in ONE place (the system's clock). */ function dashBlast(combat, def, rank) { const plan = combat.beginDash(def, rank); return plan ? { dash: plan } : { failed: 'no ship' }; } /** hullPool — a PASSIVE: nothing fires; the armor re-roll reads it (see * js/combat/CombatSystem.js → refreshSkillPassives). */ function hullPool(combat, def, rank) { combat.refreshSkillPassives(); return { hullBonus: hullBonus(def.effect ?? {}, rank) }; } /** * boleadoras — the Wraith's SNARE. The handler only hands the throw over: the * combat system owns the flight (its update() steps the sets and lands the * snag → skillBolaSnag), so `cast()` can hand the scene a readable plan the * way dashBlast does. The stun each caught craft wears is the data's number * (stunMs above), stamped on the craft, never recomputed in a second place. */ function boleadoras(combat, def, rank) { const plan = combat.launchBoleadoras(def, rank); return plan ? { bolas: plan } : { failed: 'no ship' }; } /** * blink — the Wraith's ESCAPE. One cast, one moment: the system swaps the * hull for its decoy and teleports it, and the result record is the hop * (the from/to points + the decoy's stand) so the scene can read it. */ function blink(combat, def, rank) { const hop = combat.blink(def, rank); return hop ? { blink: hop } : { failed: 'no ship' }; } /** * flameArc — INFERNO (data/skills.json → 'inferno'): the cast hands off to the * combat system, whose loop owns the arcs' flight and the burns' ticks * (js/combat/CombatSystem.js → launchFlames / _stepFlames / _igniteFlames / * _stepBurns) — the same hand-off as dashBlast and boleadoras: the handler * steps no frames itself, it only reports the plan the scene reads. * * @param {object} combat CombatSystem * @param {object} def the model's def (classId filled) * @param {number} rank points spent * @returns {{flames: {count: number, speed: number, burnMs: number, tickMs: number, damage: number}}|{failed: string}} */ function flameArc(combat, def, rank) { const plan = combat.launchFlames(def, rank); return plan ? { flames: plan } : { failed: 'no ship' }; } /** * hullRegen — NANOBOTS (data/skills.json → 'nanobots'): a PASSIVE — nothing * to fire, no cast, no seat: the combat loop's armor pass mends the hull on * the data's own clock (js/combat/CombatSystem.js → hullRegen + update), * reading the ledger every frame, so a rank-up takes effect from the next * tick with no further plumbing. The handler only reports what the ledger * grants, for cast()/console parity. * * @param {object} combat CombatSystem * @param {object} def the model's def (classId filled) * @param {number} rank points spent * @returns {{hullRegen: number}} the fraction of max hull regrown per tick */ function hullRegen(combat, def, rank) { return { hullRegen: (Number(def.effect?.regenPercentPerRank) || 0) * Math.max(1, Math.floor(Number(rank) || 1)) }; } /** kind (data/skills.json → effect.kind) → handler. A kind with no entry here * is refused by cast() with reason 'unhandled' — a typo never silently fires. */ export const SKILL_EFFECTS = { dashBlast, hullPool, boleadoras, blink, flameArc, hullRegen, }; /** Is a kind wired up (the console can grey out an unbuilt effect)? */ export function hasEffect(kind) { return typeof SKILL_EFFECTS[String(kind ?? '')] === 'function'; }