/** * 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); } /** * '#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' }; } /** 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, }; /** Is a kind wired up (the console can grey out an unbuilt effect)? */ export function hasEffect(kind) { return typeof SKILL_EFFECTS[String(kind ?? '')] === 'function'; }