304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
/**
|
||
* 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';
|
||
}
|