189 lines
8.0 KiB
JavaScript
189 lines
8.0 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);
|
||
}
|
||
|
||
/**
|
||
* '#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';
|
||
}
|