orbit/js/skills/SkillEffects.js

304 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
}