Add Wraith skills: Boleadoras snare and Blink decoy escape

- Define both skills in data/skills.json with energy, cooldown, ranks, seats,
  and per-rank stun/decoy durations
- Implement projectile flight, target intercept, capture radius, and stun in
  CombatSystem (launchBoleadoras / _stepBoleadoras / _snagBola)
- Implement Blink: spawn a decoy sprite at the old position, teleport the
  hull to a random visible-screen point, and let the decoy absorb aggro and
  enemy bolts until its clock expires
- Add bola orb/string visuals and snag readout in Fx.js (bolaStart/Step/Stop,
  snag)
- Register new combat SFX events (bola_snag, blink) in CombatSfx.js
- Extend SkillEffects with stunMs/decoyMs helpers and boleadoras/blink handlers
- Update tests: Wraith model checks, Boleadoras/Blink combat behavior, decoy
  aggro/fizzle, and SFX event roster
This commit is contained in:
Brian Fertig 2026-09-27 15:44:13 -06:00
parent 602eaf0c9e
commit 12d6867eae
26 changed files with 687 additions and 38 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Binary file not shown.

View File

@ -4,7 +4,7 @@
"_timeUnit": "cooldown is SECONDS (recharge time after a cast); duration/durationMs inside an effect are MILLISECONDS so they match the combat loop's clock.",
"icons": {
"_comment": "The skill-insignia sheet — assets/images/skills.png is 1024×512 = eight 128×128 columns × four rows = thirty-two frames, numbered row-major (frame 0 = top-left, frame 1 = the cell right of it). Frame 0 = Ramming Charge (the ram's head), frame 1 = Reinforced Plating (the armour plate); frames 2..31 are unassigned seams waiting for the next installs. The tree, the tooltip and the combat bar's seats all read a skill's `frame` off this ONE sheet.",
"_comment": "The skill-insignia sheet — assets/images/skills.png is 1024×512 = eight 128×128 columns × four rows = thirty-two frames, numbered row-major (frame 0 = top-left, frame 1 = the cell right of it). Frame 0 = Ramming Charge (the ram's head), frame 1 = Reinforced Plating (the armour plate), frame 8 = Boleadoras (the two snaring orbs), frame 9 = Blink (the watching eye); frames 2..7 and 10..31 are unassigned seams waiting for the next installs. The tree, the tooltip and the combat bar's seats all read a skill's `frame` off this ONE sheet.",
"texture": "assets/images/skills.png",
"frameWidth": 128,
"frameHeight": 128
@ -117,8 +117,70 @@
},
"wraith": {
"_comment": "THE WRAITH TREE — authored with the next install. The console opens on it and says so (window.emptyNote) instead of pretending there is a tree.",
"skills": []
"_comment": "THE WRAITH TREE — install 2 holds the class's two tricks: Boleadoras (the SNARE — the trap the targeting computer exists to lead) and Blink (the ESCAPE — the reason the trickery is survivable). Both are ROOTS: no `requires`, so a Wraith that just locked in can spend into either immediately. Both spend the right orb (60 / 70 off a 100) and sit in seats 1 / 2 on a fresh run, the same as the Vanguard's defaults.",
"skills": [
{
"id": "boleadoras",
"label": "Boleadoras",
"_comment": "THE SNARE. Type Utility (the type other skills reference), 60 energy off the right orb, a 6 second recharge, five points to spend. Rank 0 = inert; rank ≥1 = castable. `seat: 1` is the default binding on a fresh run. THE MECHANIC lives in js/combat/CombatSystem.js → launchBoleadoras / _stepBoleadoras / _snagBola: one set per nearest hostile (targetCount of them), each hunting its assigned craft on the ADVANCED TARGETING's intercept solve (js/combat/Aiming.js, capped by perks.targetingComputer.maxLead when the ledger carries the Wraith's computer). The STUN the impact drops (disableBaseMs + disablePerRankMs × rank, stamped on each caught craft's `stunUntil`) is what the pack AI's gate reads — a strung craft flies nowhere and fires nothing.",
"type": "Utility",
"frame": 8,
"energy": 60,
"cooldown": 6,
"maxPoints": 5,
"requires": [],
"seat": 1,
"node": { "col": 1, "row": 0 },
"description": "Hurl a spinning set of digital Boleadoras (two orbs on a string) at each of the nearest 3 enemies. On impact the enemies caught are disabled — unable to fly or shoot — for a period of 0.5 seconds, with an additional 0.5 seconds of disable time per skill point.",
"shortDescription": "String up to 3 enemies: no flight, no fire, 0.5s +0.5s per point.",
"effect": {
"_comment": "kind = the handler in js/skills/SkillEffects.js. targetCount = the number of NEAREST hostiles that each get their own set; speed = the sets' flight (px/s); captureRadiusPx = on arrival, every live hostile whose hull touches this radius is strung (the set's own target plus any it sweeps in). disableBaseMs + disablePerRankMs × rank = the stun a strung craft wears, in MS (the combat loop's clock): 0.5s base + 0.5s per point — 1.0s at 1/5 … 3.0s at 5/5. fx skins the sets (ballColor = the orbs, stringColor = the line between them, ballSize/stringRadius in px); sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played when a set lands.",
"kind": "boleadoras",
"targetCount": 3,
"speed": 700,
"captureRadiusPx": 56,
"disableBaseMs": 500,
"disablePerRankMs": 500,
"fx": {
"ballColor": "#b489ff",
"stringColor": "#9fd8ff",
"ballSize": 14,
"stringRadius": 16,
"sfxEvent": "bola_snag"
}
}
},
{
"id": "blink",
"label": "Blink",
"_comment": "THE ESCAPE. Type Utility, 70 energy off the right orb, a 15 second recharge, five points to spend. Rank 0 = inert; rank ≥1 = castable. `seat: 2` is the default binding on a fresh run. THE MECHANIC lives in js/combat/CombatSystem.js → blink / _blinkDestination / _stepDecoys: on activation the hull is SWAPPED for a decoy copy of the player's own ship (decoyFrame of the player's own sheet) left at the old position, and the Wraith stands at a random point of the visible screen. While it stands (decoyBaseMs + decoyPerRankMs × rank), every pirate tracks and lances the decoy instead (pickEnemyTarget's first rule) and enemy bolts that reach it fizzle on the shell (collide()'s decoy pass).",
"type": "Utility",
"frame": 9,
"energy": 70,
"cooldown": 15,
"maxPoints": 5,
"requires": [],
"seat": 2,
"node": { "col": 3, "row": 0 },
"description": "Leave a decoy copy of your ship at your old position — it draws all enemy aggro while it stands — and teleport to a random location on the visible screen. The decoy lasts 0.4 seconds, with an additional 0.4 seconds per skill point.",
"shortDescription": "Decoy behind, hop across the screen. 0.4s +0.4s per point.",
"effect": {
"_comment": "kind = the handler in js/skills/SkillEffects.js. decoyFrame = the decoy's frame of the PLAYER'S OWN sheet (assets/images/ships-player.png — frame 1, the frame right of the starter ship). decoyBaseMs + decoyPerRankMs × rank = the decoy's stand, in MS (the combat loop's clock): 0.4s base + 0.4s per point — 0.8s at 1/5 … 2.4s at 5/5. insetFraction = how far the destination stays off the visible screen's edges (a fraction of the shorter side); minDistancePx = the hop's minimum distance from the old position (a blink that lands in place is not a blink); fallbackRadiusPx = the hop's radius when the scene has no camera (the headless range/tests). fx.decoyColor tints the shell, fx.color the teleport beats; sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js).",
"kind": "blink",
"decoyFrame": 1,
"decoyBaseMs": 400,
"decoyPerRankMs": 400,
"insetFraction": 0.16,
"minDistancePx": 180,
"fallbackRadiusPx": 340,
"fx": {
"decoyColor": "#b489ff",
"color": "#a764ff",
"sfxEvent": "blink"
}
}
}
]
}
},

View File

@ -618,9 +618,9 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
};
const sfxNames = config.get('sfx') ?? {};
check('sfx: the eleven combat events, each mapped',
check('sfx: the thirteen combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'cannon_fire,cannon_hit,down,fire,hull,impact,kill,levelup,missile_fire,respawn,shield');
'blink,bola_snag,cannon_fire,cannon_hit,down,fire,hull,impact,kill,levelup,missile_fire,respawn,shield');
check('sfx: every mapped voice exists in data/sfx.json',
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 KiB

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

After

Width:  |  Height:  |  Size: 459 KiB

View File

@ -26,11 +26,11 @@
"damageMul": 4,
"damageMulPerRank": 2,
"fx": {
"_comment": "The charge's own dressing (js/skills/SkillEffects.js reads these; every key has a fallback): the streak drawn along the dash path, and the arrival shock ring. sfxEvent names a COMBAT_SFX EVENT (js/combat/CombatSfx.js) — 'hull' is the heavy armor-crack voice, the same one a hull hit plays.",
"_comment": "The charge's own dressing (js/combat/Fx.js reads these; every key has a fallback): trailColor = the comet head's shed embers AND the arrival's ember ring, ringColor = the DAMAGE-RADIUS ring + the shock ripples, ringLifeMs = how long that radius ring lingers, sfxEvent = a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played with the thunder boom ('kill') under the impact — 'hull' is the heavy armor-crack voice. The comet head itself, the lingering ~1 s ember trail and the fireball are Fx's fixed craft; these colors skin them.",
"trailColor": "#ff3a4d",
"trailAlpha": 0.55,
"ringColor": "#ffd84d",
"ringLifeMs": 320,
"ringLifeMs": 480,
"sfxEvent": "hull"
}
}
@ -282,8 +282,8 @@
"dashing": false,
"down": false,
"ship": {
"x": 5909.759469383378,
"y": -988.8723592216014
"x": -4855.862479765997,
"y": 613.7055489697374
},
"ledgerSeats": [
"ramming_charge",
@ -329,8 +329,8 @@
"maxPoints": 5,
"energy": 60,
"cooldown": 6,
"cooldownLeft": 5.733383333333334,
"cooldownFrac": 0.04443611111111101,
"cooldownLeft": 5.68,
"cooldownFrac": 0.053333333333333344,
"affordable": false,
"ready": false,
"inert": false
@ -526,16 +526,16 @@
"secs": ""
}
],
"energy": 41.59970000000001,
"energy": 41.92,
"maxEnergy": 100,
"hull": 6000,
"maxHull": 6000,
"level": 0,
"dashing": true,
"dashing": false,
"down": false,
"ship": {
"x": 5972.397742948351,
"y": -675.1068097485484
"x": -4855.902379850913,
"y": 613.3899237503013
},
"ledgerSeats": [
"ramming_charge",
@ -780,14 +780,14 @@
],
"energy": 59.384,
"maxEnergy": 100,
"hull": 101.896,
"hull": 129,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 5967.269013350007,
"y": -697.4064976194823
"x": -4855.902379850913,
"y": 613.3899237503013
},
"ledgerSeats": [
"ramming_charge",
@ -1032,14 +1032,14 @@
],
"energy": 68.21600000000001,
"maxEnergy": 100,
"hull": 71.896,
"hull": 104.584,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 5967.269013350007,
"y": -697.4064976194823
"x": -4855.902379850913,
"y": 613.3899237503013
},
"ledgerSeats": [
null,
@ -1284,14 +1284,14 @@
],
"energy": 71.28800000000001,
"maxEnergy": 100,
"hull": 61.896,
"hull": 94.584,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 5967.269013350007,
"y": -697.4064976194823
"x": -4855.902379850913,
"y": 613.3899237503013
},
"ledgerSeats": [
null,
@ -1536,14 +1536,14 @@
],
"energy": 73.97600000000001,
"maxEnergy": 100,
"hull": 61.896,
"hull": 94.584,
"maxHull": 129,
"level": 1,
"dashing": false,
"down": false,
"ship": {
"x": 5967.269013350007,
"y": -697.4064976194823
"x": -4855.902379850913,
"y": 613.3899237503013
},
"ledgerSeats": [
"ramming_charge",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

@ -26,11 +26,11 @@
"damageMul": 4,
"damageMulPerRank": 2,
"fx": {
"_comment": "The charge's own dressing (js/skills/SkillEffects.js reads these; every key has a fallback): the streak drawn along the dash path, and the arrival shock ring. sfxEvent names a COMBAT_SFX EVENT (js/combat/CombatSfx.js) — 'hull' is the heavy armor-crack voice, the same one a hull hit plays.",
"_comment": "The charge's own dressing (js/combat/Fx.js reads these; every key has a fallback): trailColor = the comet head's shed embers AND the arrival's ember ring, ringColor = the DAMAGE-RADIUS ring + the shock ripples, ringLifeMs = how long that radius ring lingers, sfxEvent = a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played with the thunder boom ('kill') under the impact — 'hull' is the heavy armor-crack voice. The comet head itself, the lingering ~1 s ember trail and the fireball are Fx's fixed craft; these colors skin them.",
"trailColor": "#ff3a4d",
"trailAlpha": 0.55,
"ringColor": "#ffd84d",
"ringLifeMs": 320,
"ringLifeMs": 480,
"sfxEvent": "hull"
}
}
@ -167,11 +167,11 @@
"damageMul": 4,
"damageMulPerRank": 2,
"fx": {
"_comment": "The charge's own dressing (js/skills/SkillEffects.js reads these; every key has a fallback): the streak drawn along the dash path, and the arrival shock ring. sfxEvent names a COMBAT_SFX EVENT (js/combat/CombatSfx.js) — 'hull' is the heavy armor-crack voice, the same one a hull hit plays.",
"_comment": "The charge's own dressing (js/combat/Fx.js reads these; every key has a fallback): trailColor = the comet head's shed embers AND the arrival's ember ring, ringColor = the DAMAGE-RADIUS ring + the shock ripples, ringLifeMs = how long that radius ring lingers, sfxEvent = a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played with the thunder boom ('kill') under the impact — 'hull' is the heavy armor-crack voice. The comet head itself, the lingering ~1 s ember trail and the fireball are Fx's fixed craft; these colors skin them.",
"trailColor": "#ff3a4d",
"trailAlpha": 0.55,
"ringColor": "#ffd84d",
"ringLifeMs": 320,
"ringLifeMs": 480,
"sfxEvent": "hull"
}
},

View File

@ -67,6 +67,7 @@ function makeBody() {
const PhaserStub = {
Physics: { Arcade: { Sprite } },
GameObjects: { Sprite },
Math: {
Angle: {
Wrap: wrap,
@ -181,6 +182,10 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const RAM = model.skillDef('ramming_charge', 'vanguard');
const PLATE = model.skillDef('reinforced_plating', 'vanguard');
const EFF = RAM.effect;
const BOLA = model.skillDef('boleadoras', 'wraith');
const HOP = model.skillDef('blink', 'wraith');
const BEFF = BOLA.effect;
const HEFF = HOP.effect;
const BASE_DAMAGE = effects.baseWeaponDamage(); // data/combat.json → player.weapon.damage
const MAX_ENERGY = Number(config.get('combat.energy.max', 0));
const REGEN = Number(config.get('combat.energy.regen', 0));
@ -213,6 +218,7 @@ function makeFight({ points = 99, ranks = {}, classId = 'vanguard' } = {}) {
// blast's number is the blast's number.
combat.autoFire = { ...combat.autoFire, enabled: false };
combat.skillState = skills;
combat.classState = cls;
combat.refreshSkillPassives();
return { scene, ship, cls, skills, combat, kills };
}
@ -466,6 +472,111 @@ function dummy(combat, x, y) {
check('plating: no ledger means the base armor is exactly the data\'s', plain.maxHull === BASE_HULL && plain.hullBonus === 0);
}
// ========================================================= Boleadoras (the Wraith)
{
// The stun formula, straight off the data (0.5s base + 0.5s per point).
check(`bola: the stun is the data's (${BEFF.disableBaseMs} + ${BEFF.disablePerRankMs} × rank)`,
effects.stunMs(BEFF, 1) === BEFF.disableBaseMs + BEFF.disablePerRankMs &&
effects.stunMs(BEFF, BOLA.maxPoints) === BEFF.disableBaseMs + BEFF.disablePerRankMs * BOLA.maxPoints);
const f = makeFight({ classId: 'wraith', ranks: { boleadoras: 1 } });
// The dummy's own lances are not what these tests measure.
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const foe = dummy(f.combat, 700, 360);
const cast = f.combat.cast(0); // seat 1 — the Wraith's data default binding
check(`bola: the cast pays ${BOLA.energy} energy and starts the ${BOLA.cooldown}s recharge`, cast.ok === true && cast.cost === BOLA.energy && f.combat.skillCooldowns.get(BOLA.id) > BOLA.cooldown - 0.1);
check('bola: one set launches per nearest hostile (one hostile here), at the data speed', cast.bolas.count === 1 && f.combat.bolas.length === 1 && f.combat.bolas[0].speed === BEFF.speed);
// Fly the set out (a few hundred px at the data speed) and note the frame it lands on.
let t = 1000;
for (let spent = 0; spent < 4000 && f.combat.bolas.length > 0; spent += 16) { t += 16; f.combat.update(t, 16); }
check('bola: the set lands (the flight roster is spent)', f.combat.bolas.length === 0);
const tLand = t; // the frame the snag landed on
const stun = effects.stunMs(BEFF, 1);
check(`bola: the snag stamps the data's stun on the caught hostile (${stun}ms at 1/5)`, foe.stunUntil === tLand + stun);
// The stun's EFFECT: the strung craft does not fly and does not shoot.
// (The headless stub has no physics world, so "flies" is read off the
// craft's own velocity — the combat gate zeroes it every strung frame.)
const bolts = () => f.combat.bullets.filter((b) => b.owner === 'enemy').length;
const b0 = bolts();
f.combat.update(tLand + 16, 16);
check('stun: the strung craft does not fly (velocity held at zero)', foe.body.velocity.length() === 0);
check('stun: the strung craft does not shoot', bolts() === b0);
// …and it is released exactly when the data's clock runs out.
f.combat.update(tLand + stun - 16, 16);
check('stun: still strung one frame before the data clock runs out', foe.body.velocity.length() === 0);
f.combat.update(tLand + stun + 32, 16);
check('stun: released once the data clock runs out (the AI runs again)', foe.body.velocity.length() > 0);
}
{
// THREE sets, at the three NEAREST hostiles — the furthest one is left out.
const f = makeFight({ classId: 'wraith', ranks: { boleadoras: 3 } });
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const near1 = dummy(f.combat, 420, 360);
const near2 = dummy(f.combat, 300, 180);
const near3 = dummy(f.combat, 180, 360);
const far = dummy(f.combat, -320, 360);
const cast = f.combat.cast(0);
check(`bola: exactly the data's ${BEFF.targetCount} sets launch (the nearest ${BEFF.targetCount} of 4 hostiles)`, cast.bolas.count === BEFF.targetCount && f.combat.bolas.length === BEFF.targetCount);
let t = 1000;
for (let spent = 0; spent < 4000 && f.combat.bolas.length > 0; spent += 16) { t += 16; f.combat.update(t, 16); }
check('bola: all three nearest are strung', [near1, near2, near3].every((e) => (e.stunUntil || 0) > t));
check('bola: the furthest hostile (no set assigned) is not strung', (far.stunUntil || 0) <= t);
// The stun is the data's at THIS rank — the caught crafts all wear the same number.
const stun = effects.stunMs(BEFF, 3);
check(`bola: the strung wear rank 3/5's stun (${stun}ms)`, [near1, near2, near3].every((e) => e.stunUntil <= t + stun && e.stunUntil >= 1016 + stun));
}
// ============================================================== Blink (the Wraith)
{
check(`blink: the decoy's stand is the data's (${HEFF.decoyBaseMs} + ${HEFF.decoyPerRankMs} × rank)`,
effects.decoyMs(HEFF, 1) === HEFF.decoyBaseMs + HEFF.decoyPerRankMs &&
effects.decoyMs(HEFF, HOP.maxPoints) === HEFF.decoyBaseMs + HEFF.decoyPerRankMs * HOP.maxPoints);
const f = makeFight({ classId: 'wraith', ranks: { blink: 1 } });
f.combat.maxHull = 1e6; f.combat.hull = 1e6; f.combat.maxShields = 1e6; f.combat.shields = 1e6;
const foe = dummy(f.combat, 700, 360);
const from = { x: f.ship.x, y: f.ship.y };
const cast = f.combat.cast(1); // seat 2 — the Wraith's data default binding
check(`blink: the cast pays ${HOP.energy} energy and starts the ${HOP.cooldown}s recharge`, cast.ok === true && cast.cost === HOP.energy && f.combat.skillCooldowns.get(HOP.id) > HOP.cooldown - 0.1);
check(`blink: the hull hops at least the data's ${HEFF.minDistancePx}px from the old position`, Math.hypot(f.ship.x - from.x, f.ship.y - from.y) >= HEFF.minDistancePx);
check('blink: the decoy stands at the OLD position (a copy of the player hull)',
f.combat.decoys.length === 1 && f.combat.decoys[0].x === from.x && f.combat.decoys[0].y === from.y &&
f.combat.decoys[0].sprite.key === '__ship' && f.combat.decoys[0].sprite.frame === HEFF.decoyFrame - 1);
const decoy = f.combat.decoys[0];
check(`blink: the decoy's stand is the data's clock (${effects.decoyMs(HEFF, 1)}ms at 1/5)`, decoy.until === effects.decoyMs(HEFF, 1));
// THE AGGRO: the pack aims at the decoy, not at the hull.
check("aggro: a pirate's target is the decoy while it stands", f.combat.pickEnemyTarget(foe, f.ship) === decoy.sprite);
// Their lances × the decoy: the bolt that reaches it fizzles on the shell.
const hp = f.combat.hull;
const bolt = {
dead: false, owner: 'enemy', radius: 4, angle0: 0,
x: decoy.x - 12, y: decoy.y,
body: { velocity: { x: 600, y: 0 } },
consume: () => true,
};
f.combat.bullets.push(bolt);
f.combat.collide(16, f.ship);
check('aggro: an enemy bolt that reaches the decoy fizzles (it is consumed, the hull takes none)',
!f.combat.bullets.includes(bolt) && f.combat.hull === hp);
// The stand ends on the data's clock — the decoy is collected, the aggro is back.
let t = 1000;
f.combat.update(t, 16); // the decoy's 0.8s stand is over long before the scene clock catches it
check('blink: the decoy is gone when its stand runs out', f.combat.decoys.length === 0 && decoy.dead === true && decoy.sprite.destroyed === true);
check('blink: the aggro returns to the hull once the decoy is gone', f.combat.pickEnemyTarget(foe, f.ship) === f.ship);
// A fresh rank reads the data again — 5/5 stands five times as long as 1/5.
const maxed = makeFight({ classId: 'wraith', ranks: { blink: HOP.maxPoints } });
const hop5 = maxed.combat.blink(HOP, HOP.maxPoints);
check(`blink: the 5/5 stand is the data's (${hop5.decoyMs}ms)`, hop5.decoyMs === effects.decoyMs(HEFF, HOP.maxPoints));
}
// ================================================================= the bar's row
{
const f = makeFight({ ranks: { ramming_charge: RAM.maxPoints } });

View File

@ -62,8 +62,8 @@ check(v.hasClass && v.classId === 'vanguard', `?skill filed a tree-owning class
check(v.points === capacity, `?skill banked what the tree can absorb (${v.points} of ${capacity} from data)`);
check(Object.keys(v.ranks).length === 0, 'nothing was spent — the points stay in the bank to spend by hand');
// A named class files itself only when the data gives it rows: wraith's tree is an
// empty branch, so the door falls back to the first class that owns one.
// A named class files itself only when the data gives it rows — the LAST
// tree-owning class in the data (wraith now owns one: boleadoras + blink).
const treeOwners = Object.entries(skills.classes)
.filter(([, t]) => Array.isArray(t?.skills) && t.skills.length > 0)
.map(([id]) => id);

View File

@ -282,7 +282,19 @@ function vanguardRun(points = 5) {
const list = model.skillList('vanguard');
check('model: the Vanguard tree lists both skills in data order', list.map((s) => s.id).join() === 'ramming_charge,reinforced_plating');
check('model: an unknown class has no tree', model.skillList('nobody').length === 0 && model.hasTree('wraith') === false);
check('model: an unknown class has no tree', model.skillList('nobody').length === 0 && model.hasTree('nobody') === false);
// The WRAITH tree (data/skills.json → classes.wraith): Boleadoras (the
// snare) + Blink (the escape), both Utility roots, both castable.
const wraithList = model.skillList('wraith');
check('model: the Wraith tree lists both skills in data order', wraithList.map((s) => s.id).join() === 'boleadoras,blink' && model.hasTree('wraith') === true);
const bolas = model.skillDef('boleadoras', 'wraith');
check('model: Boleadoras carries the spec numbers (60 energy, 6s, 5 points, the snaring-orbs insignia)', bolas.energy === 60 && bolas.cooldown === 6 && bolas.maxPoints === 5 && bolas.frame === 8 && bolas.seatDefault === 1);
check('model: Boleadoras is a castable Utility root', bolas.type.id === 'Utility' && bolas.castable === true && bolas.passive === false && Array.isArray(bolas.requires) && bolas.requires.length === 0);
check('model: the snare numbers ride the record untouched', bolas.effect.kind === 'boleadoras' && bolas.effect.targetCount === 3 && bolas.effect.disableBaseMs === 500 && bolas.effect.disablePerRankMs === 500 && bolas.effect.captureRadiusPx === 56 && bolas.effect.speed === 700);
const hop = model.skillDef('blink', 'wraith');
check('model: Blink carries the spec numbers (70 energy, 15s, 5 points, the watching-eye insignia)', hop.energy === 70 && hop.cooldown === 15 && hop.maxPoints === 5 && hop.frame === 9 && hop.seatDefault === 2);
check('model: the escape numbers ride the record untouched', hop.effect.kind === 'blink' && hop.effect.decoyFrame === 1 && hop.effect.decoyBaseMs === 400 && hop.effect.decoyPerRankMs === 400 && hop.effect.minDistancePx === 180);
const ram = model.skillDef('ramming_charge', 'vanguard');
check('model: the record carries the spec numbers', ram.energy === 60 && ram.cooldown === 6 && ram.maxPoints === 5 && ram.frame === 0);
@ -355,12 +367,17 @@ function vanguardRun(points = 5) {
const bare = new SkillState(new ClassState());
check('ledger: no class on file means no tree to spend in', bare.rankUp('ramming_charge').reason === 'noClass');
// A skill that exists but belongs to ANOTHER class's tree is not spendable
// here (the same id could legally exist in two trees).
// here (the same id could legally exist in two trees). The Wraith's own
// Boleadoras is the cleanest witness: it is on file, but it is not the
// Vanguard's to spend into.
check('ledger: a skill owned by another class is refused (unknown here)', rich.skills.rankUp('boleadoras').reason === 'unknown');
// Same rule on a skill that is on NO tree at all (a hand-edit, caught by
// the ledger, not by the model).
const wraithTree = config.get('skills.classes.wraith.skills', []);
wraithTree.push({ id: 'wraith_only', label: 'Wraith Only', type: 'Offense', maxPoints: 2 });
model.invalidateSkillCache();
check('ledger: a skill owned by another class is refused (unknown here)', rich.skills.rankUp('wraith_only').reason === 'unknown');
wraithTree.length = 0;
check('ledger: a foreign skill id is refused (unknown here)', rich.skills.rankUp('wraith_only').reason === 'unknown');
wraithTree.pop();
model.invalidateSkillCache();
// ---- seats --------------------------------------------------------

View File

@ -33,6 +33,10 @@ import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
* levelup solar_flare_01 the combat level landing (the charge-rise
* voice — js/combat/XpManager.js's 'levelup'
* event, subscribed by CombatSystem)
* bola_snag solar_buzz the Wraith's Boleadoras landing on a craft
* (the energy shimmer of the snare catching)
* blink discovery the Wraith's BLINK (the decoy dropping in —
* the "new object in view" voice)
*
* Playback goes through the shared Sfx.js guards (master switch, audio
* manager, cache) — a headless scene with no manager simply doesn't
@ -50,6 +54,8 @@ export const COMBAT_SFX = {
down: ['deconstruct'],
respawn: ['discovery'],
levelup: ['solar_flare_01'],
bola_snag: ['scan'],
blink: ['construct'],
};
// ---------------------------------------------------------------- dev log

View File

@ -8,13 +8,15 @@ import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
import { barrels as barrelLine, intercept } from './Aiming.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, dashPoint, hexToInt, radiusHits } from '../skills/SkillEffects.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, dashPoint, decoyMs, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js';
import { iconSheet, seatCount, skillsEnabled } from '../skills/SkillModel.js';
import { perkDef } from '../player/ClassModel.js';
import { XpManager } from './XpManager.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { combatShake } from './CamShake.js';
import { normalizeWaves, WaveRunner } from './Waves.js';
import { assignWorld } from '../visuals/UiCameras.js';
import Phaser from '../vendor/phaser.js';
const TAU = Math.PI * 2;
@ -207,6 +209,18 @@ export class CombatSystem {
// data bag. The handle lives here, released in _endDash.
this._dashComet = null;
// THE WRAITH'S SNARE (data/skills.json → effect.kind 'boleadoras'): the
// bola sets in flight — each one a pair of orbs on a string hunting its
// assigned craft on the advanced targeting's intercept solve, and
// dropping the stun (effect.disableBaseMs + disablePerRankMs × rank) on
// whatever it catches when it lands. Stepped in update()'s step 0b.
this.bolas = [];
// THE WRAITH'S ESCAPE (data/skills.json → effect.kind 'blink'): the
// live decoys — a copy of the player's own hull left at the old position,
// carrying every pirate's aggro while it stands (effect.decoyBaseMs +
// decoyPerRankMs × rank). Stepped in update()'s step 0b, gone in destroy().
this.decoys = [];
this.fx = new Fx(scene);
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
@ -700,6 +714,12 @@ export class CombatSystem {
// got to this frame.
this._stepDash(dt);
// 0b) THE WRAITH'S SKILLS (data/skills.json → 'boleadoras' / 'blink'):
// the bola sets in flight (each one's hunt + snag) and the decoys'
// stand (their shimmer + their expiry), both on the combat clock.
this._stepBoleadoras(dt, time);
this._stepDecoys(time);
// 1) The pack: orbit AI + lances (a dead one is its reform clock).
// The ON-SCREEN RULE: the AI may WANT to fire, but the lance only
// leaves the barrel while the craft is (at least partly) on screen —
@ -709,6 +729,16 @@ export class CombatSystem {
if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time);
continue;
}
// THE SNARE (data/skills.json → 'boleadoras'): a strung hostile is
// DISABLED until its stun clock runs out — no orbit AI, no lances (the
// craft stands on the line where the cords caught it).
if (time < (e.stunUntil || 0)) {
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
continue;
}
// THE TARGET (pickEnemyTarget): the player — or, in a mixed
// encounter, the nearest in-weapon-range escort when the player
// isn't (the escort draws aggro; a pirates-only fight resolves to
@ -887,6 +917,18 @@ export class CombatSystem {
*/
pickEnemyTarget(e, player) {
if (!player) return null;
// THE WRAITH'S DECOY (data/skills.json → 'blink') carries ALL the aggro
// while it stands: the nearest live decoy is what every pirate orbits
// and lances (bolts that reach it fizzle — collide's decoy pass), and
// the hull that actually owns the run is never aimed at.
let decoy = null;
let decoyD = Infinity;
for (const d of this.decoys) {
if (d.dead) continue;
const d2 = (d.sprite.x - e.x) ** 2 + (d.sprite.y - e.y) ** 2;
if (d2 < decoyD) { decoyD = d2; decoy = d; }
}
if (decoy) return decoy.sprite;
const range = Number((e.wpn && e.wpn.range) || 0);
const inRange = (o) =>
range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true;
@ -1110,6 +1152,23 @@ export class CombatSystem {
}
}
}
// Their lances × the WRAITH'S DECOYS (data/skills.json → 'blink'): the
// decoy is what drew the fire — a bolt that reaches it fizzles on the
// shell (the hologram has no hull to break, only the pop).
if (this.decoys.length > 0) {
for (const d of this.decoys) {
if (d.dead) continue;
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
if (this.hitsSwept(b, d.sprite, d.hitRadius, delta)) {
this.fx.flash(d.sprite.x, d.sprite.y, 0xffffff, 30, 150);
this.fx.sparks(d.sprite.x, d.sprite.y, hexToInt(d.fx.decoyColor, 0xb489ff), 7, 220, 420);
if (b.consume()) this.bullets.splice(i, 1);
}
}
}
}
// Their lances × you.
if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
@ -1448,6 +1507,7 @@ export class CombatSystem {
// voice — the on-screen roll's edge resets with it).
e._commedAttack = false;
e._wasOnScreen = false;
e.stunUntil = 0; // a new life stands unstrung (the snare's clock resets with it)
this._setActive();
}
@ -1822,6 +1882,224 @@ export class CombatSystem {
return { hits: hit.length, kills, damage };
}
/**
* THE WRAITH'S BOLAAS (data/skills.json → effect.kind 'boleadoras'):
* one set per NEAREST hostile (effect.targetCount of them), each one
* hunting its assigned craft on the advanced targeting's solve. The
* handler's seam — call cast(), not this.
* @returns {{count: number, stunMs: number, speed: number}|null}
*/
launchBoleadoras(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'boleadoras') return null;
const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0);
const count = Math.max(0, Math.floor(Number(eff.targetCount) || 0));
// The N NEAREST live hostiles (no on-screen filter — the throw is cast
// from the hull, the way the dash aims: at whatever's nearest).
const nearest = this.enemies
.filter((e) => !e.dead && !e._removed)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count);
const stun = stunMs(eff, rank);
for (const t of nearest) {
const b = {
x: ship.x,
y: ship.y,
target: t,
speed,
stun,
capture: Math.max(0, Number(eff.captureRadiusPx) || 0),
fx: eff.fx ?? {},
toX: t.x,
toY: t.y,
};
// THE SET (js/combat/Fx.js → bolaStart): the two orbs on their string.
b.handle = this.fx.bolaStart(b.x, b.y, b.fx);
this.bolas.push(b);
}
if (nearest.length > 0) this.fx.flash(ship.x, ship.y, hexToInt(eff.fx?.ballColor, 0xb489ff), 34, 160);
return { count: nearest.length, stunMs: stun, speed };
}
/**
* The bola sets' frame step (update()'s step 0b). Each set re-solves its
* craft's intercept EVERY frame (the Wraith's advanced targeting —
* js/combat/Aiming.js's intercept, capped by the ledger's
* perks.targetingComputer.maxLead when it carries the computer) and flies
* at effect.speed toward the solve. The set ALWAYS lands — the solve
* converges (the sets fly faster than the pack) — and on landing the snag
* drops the stun on whatever is caught inside effect.captureRadiusPx. A
* craft that dies mid-flight leaves its set on its last sight (a fixed
* point the set still reaches and snags at).
*/
_stepBoleadoras(dt, time) {
if (this.bolas.length === 0) return;
const keep = [];
const lead = this.classState?.hasPerk?.('targetingComputer')
? Number(perkDef('targetingComputer')?.maxLead) || 0
: 0;
for (const b of this.bolas) {
// THE WRAITH'S SOLVE (every frame, js/combat/Aiming.js's intercept):
// the set flies where the craft WILL be, not where it is.
const t = b.target;
if (t && !t.dead && !t._removed) {
const hit = intercept(b.x, b.y, t.x, t.y, t.body?.velocity?.x ?? 0, t.body?.velocity?.y ?? 0, b.speed, lead);
b.toX = hit.x;
b.toY = hit.y;
}
const dx = b.toX - b.x;
const dy = b.toY - b.y;
const dist = Math.hypot(dx, dy);
const step = b.speed * dt;
if (dist > 0.001 && step < dist) {
b.x += (dx / dist) * step;
b.y += (dy / dist) * step;
this.fx.bolaStep(b.handle, dt, b.x, b.y, Math.atan2(dy, dx));
keep.push(b);
} else {
// THE SET LANDS — whatever is caught inside the capture radius is caught.
this._snagBola(b, time);
this.fx.bolaStop(b.handle);
}
}
this.bolas = keep;
}
/**
* THE SNAG (a bola set lands): every live hostile whose hull touches the
* capture radius is strung — flight and fire held until `stunUntil` (the
* data's base + per-rank ms on the combat clock) — and the read is the
* data's (Fx.snag's ring + sparks + flash, the data's sfxEvent voice).
*/
_snagBola(b, time) {
const caught = radiusHits(this.enemies, b.x, b.y, b.capture);
for (const e of caught) {
e.stunUntil = Math.max(e.stunUntil || 0, time + b.stun);
this.fx.snag(e, hexToInt(b.fx.ballColor, 0xb489ff));
}
if (caught.length > 0) {
combatSfx(this.scene, String(b.fx.sfxEvent || 'bola_snag'));
} else {
this.fx.flash(b.x, b.y, hexToInt(b.fx.ballColor, 0xb489ff), 26, 160); // an empty catch still pops
}
}
/**
* THE WRAITH'S BLINK (data/skills.json → effect.kind 'blink'): the hull
* is swapped for its DECOY — a copy of the player's own ship (effect.
* decoyFrame of the player's own sheet) left at the old position — and
* the Wraith stands at a random point of the visible screen. While the
* decoy stands (effect.decoyBaseMs + decoyPerRankMs × rank) it carries
* every pirate's aggro (pickEnemyTarget's first rule) and their bolts
* that reach it fizzle (collide's decoy pass).
* @returns {{fromX: number, fromY: number, toX: number, toY: number, decoyMs: number}|null}
*/
blink(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'blink') return null;
const eff = def.effect;
const dur = Math.max(1, decoyMs(eff, rank));
const fromX = ship.x;
const fromY = ship.y;
const to = this._blinkDestination(eff);
// THE DECOY — the player's OWN hull copied (its texture, the data's
// decoy frame, its scale) in the data's hologram tint.
const key = ship.texture?.key ?? '__ship';
const frameTotal = Math.max(1, Math.floor(Number(ship.texture?.frameTotal) || 1));
const frame = Math.min(Math.max(0, Math.floor(Number(eff.decoyFrame) || 0)), frameTotal - 1);
const sprite = new Phaser.GameObjects.Sprite(this.scene, fromX, fromY, key, frame);
sprite.setDepth(10);
sprite.setScale(ship.scale ?? 1);
try { sprite.setTint(hexToInt(eff.fx?.decoyColor, 0xb489ff)); } catch { /* the stub scene has no tint — the copy stands plain */ }
if (typeof this.scene?.add?.existing === 'function') this.scene.add.existing(sprite);
assignWorld(this.scene, sprite);
const decoy = {
sprite,
x: fromX,
y: fromY,
hitRadius: Math.max(12, Number(ship.radius) || 14),
until: this._now() + dur,
fx: eff.fx ?? {},
dead: false,
};
this.decoys.push(decoy);
// THE HOP — the hull stands somewhere else on the visible screen, and
// the steering is released (a teleport, not a dash: the click-to-fly
// target the player had set is not queued up to drag the ship back).
ship.x = to.x;
ship.y = to.y;
if (ship.body) {
ship.body.velocity.set(0, 0);
ship.body.acceleration?.set?.(0, 0);
}
this._releaseSteering();
// THE READ — the old position's dissolve (the decoy inherits it), the
// new position's arrival, and the voice.
const col = hexToInt(eff.fx?.color, 0xb489ff);
this.fx.ring(fromX, fromY, col, 130, 380);
this.fx.flash(fromX, fromY, 0xffffff, 36, 200);
this.fx.ring(to.x, to.y, col, 130, 380);
this.fx.sparks(to.x, to.y, col, 12, 260, 520);
combatSfx(this.scene, String(eff.fx?.sfxEvent || 'blink'));
return { fromX, fromY, toX: to.x, toY: to.y, decoyMs: dur };
}
/**
* The blink's destination: a random point of the visible screen inset by
* effect.insetFraction of the shorter side (the destination is ON the
* screen — the data's rule), at least effect.minDistancePx from the old
* position (a blink that lands in place is not a blink). No camera (the
* headless range) → a random bearing at the data's fallback radius.
*/
_blinkDestination(eff = {}) {
const ship = this.ship;
const minGap = Math.max(0, Number(eff.minDistancePx) || 0);
const v = this.visibleView();
if (v) {
const inset = Math.min(0.5, Math.max(0, Number(eff.insetFraction) || 0)) * Math.min(v.width, v.height);
const w = Math.max(1, v.width - 2 * inset);
const h = Math.max(1, v.height - 2 * inset);
for (let i = 0; i < 8; i++) {
const x = v.x + inset + Math.random() * w;
const y = v.y + inset + Math.random() * h;
if (!ship || Math.hypot(x - ship.x, y - ship.y) >= minGap) return { x, y };
}
return { x: v.x + v.width / 2, y: v.y + v.height / 2 }; // the screen is too small for the gap — the centre stands
}
const a = Math.random() * TAU;
const r = Math.max(minGap, Number(eff.fallbackRadiusPx) || 0);
return { x: (ship?.x ?? 0) + Math.cos(a) * r, y: (ship?.y ?? 0) + Math.sin(a) * r };
}
/**
* The decoys' frame step (update()'s step 0b): while its clock holds the
* shell shimmers (a hologram is not a hull — it flickers), and in its
* last 250 ms it fades out; at expiry it is collected with a small pop.
*/
_stepDecoys(time) {
if (this.decoys.length === 0) return;
const keep = [];
for (const d of this.decoys) {
if (time >= d.until) {
d.dead = true;
const col = hexToInt(d.fx.decoyColor, 0xb489ff);
this.fx.flash(d.sprite.x, d.sprite.y, col, 44, 180);
this.fx.sparks(d.sprite.x, d.sprite.y, col, 8, 200, 420);
if (d.sprite && !d.sprite.destroyed) d.sprite.destroy();
continue;
}
const left = d.until - time;
const spr = d.sprite;
if (spr && typeof spr.setAlpha === 'function') {
const fade = Math.min(1, left / 250);
spr.setAlpha(fade * (0.85 + 0.15 * Math.sin(time * 0.02)));
}
keep.push(d);
}
this.decoys = keep;
}
/** The per-skill recharges (update()'s step 5b). */
_tickSkillCooldowns(dt) {
if (this.skillCooldowns.size === 0) return;
@ -1855,6 +2133,10 @@ export class CombatSystem {
destroy() {
this.dash = null; // a charge in flight dies with the fight (no blast)
this.skillCooldowns.clear(); // recharges are per-fight: a new fight starts ready
for (const b of this.bolas) this.fx.bolaStop(b.handle); // the Wraith's sets die with the fight
this.bolas = [];
for (const d of this.decoys) if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); // and its decoys
this.decoys = [];
this.waveRun = null;
if (this._levelUnsub) {
this._levelUnsub();

View File

@ -12,6 +12,7 @@ const COMET_KEY = '__combat_comet';
const BLAST_KEY = '__combat_blast';
const BLAST_RING_KEY = '__combat_blast_ring';
const BLAST_RING_R = 114; // the rim's radius in the blast-ring texture (256 px)
const BOLA_BAR_KEY = '__combat_bola_bar';
// _RAM_BLEND — why the ramming-charge images (comet head, ember trail,
// blast fireball, blast ring) use SCREEN instead of ADD. In this Phaser
@ -387,6 +388,28 @@ export class Fx {
return ARC_KEY;
}
/** The bolas' STRING texture (a soft bar along +x, white alpha — tinted at
* use): the line the two orbs of a `boleadoras` set spin on. @returns {string} */
static ensureBar(scene) {
if (scene.textures.exists(BOLA_BAR_KEY)) return BOLA_BAR_KEY;
canvasTexture(scene, BOLA_BAR_KEY, 128, 16, (ctx, w, h) => {
const c = h / 2;
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0.0, 'rgba(255,255,255,0.0)');
g.addColorStop(0.5, 'rgba(255,255,255,0.85)');
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(10, c - 3, w - 20, 6);
ctx.beginPath();
ctx.arc(10, c, 4, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(w - 10, c, 4, 0, Math.PI * 2);
ctx.fill();
});
return BOLA_BAR_KEY;
}
// ----------------------------------------------------------- the basics
/** A glow: `size` px across at full strength, fading over `life` ms. SCREEN
@ -610,6 +633,109 @@ export class Fx {
}
}
// ------------------------------------------------------- the Wraith's snare
/**
* A BOLA SET (data/skills.json → effect.kind 'boleadoras'): two glowing
* orbs — the skill sheet's own snaring beads — spinning on the flight axis
* with the soft string spanning them (ensureBar's bar, tinted the data's
* stringColor). One LIVE handle: the combat system drives it every frame
* (bolaStep) and kills it on the snag (bolaStop). It is NOT an Fx item —
* its life is the projectile's (the snag's clock), not a fixed timer's.
*
* @param {number} x spawn
* @param {number} y spawn
* @param {object} [fx] the data's fx block (ballColor / stringColor /
* ballSize / stringRadius — every one read with a neutral fallback)
* @returns {object} the handle (pass to bolaStep / bolaStop)
*/
bolaStart(x, y, fx = {}) {
const ballColor = toColor(fx?.ballColor, 0xffffff);
const stringColor = toColor(fx?.stringColor, 0xffffff);
const ballSize = Math.max(4, Number(fx?.ballSize) || 12);
const r = Math.max(6, Number(fx?.stringRadius) || 14);
const orb = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(ballColor)
.setBlendMode(Phaser.BlendModes.SCREEN) // see the _RAM_BLEND note
.setDepth(12)
.setScale(ballSize / DOT_SIZE));
const b2 = this._world(this.scene
.add.image(x, y, DOT_KEY)
.setTint(ballColor)
.setBlendMode(Phaser.BlendModes.SCREEN)
.setDepth(12)
.setScale(ballSize / DOT_SIZE));
const str = this._world(this.scene
.add.image(x, y, Fx.ensureBar(this.scene))
.setTint(stringColor)
.setBlendMode(Phaser.BlendModes.SCREEN)
.setDepth(11)
.setAlpha(0.85)
.setScale(r / 64, 1));
const handle = { b1: orb, b2, str, r, phase: Math.random() * Math.PI * 2, spin: 14, dead: false };
this.bolaStep(handle, 0, x, y, 0);
return handle;
}
/**
* Re-seat a bola set (called every flight frame from the combat system):
* the orbs advance their spin on the flight axis and the string always
* spans the two of them (its length is the full diameter — a spinning
* pair stays a pair). Pure geometry + position writes, no state.
*
* @param {object} handle a bolaStart's
* @param {number} dt frame delta (s) — the spin's clock
* @param {number} x the set's centre
* @param {number} y the set's centre
* @param {number} angle the travel heading (rad)
*/
bolaStep(handle, dt, x, y, angle) {
if (!handle || handle.dead) return;
handle.phase += (Number(handle.spin) || 14) * (Number(dt) || 0);
const { b1, b2, str, r } = handle;
// The orbs' offset direction: the axis perpendicular to travel, rotated
// by the phase — so the pair WHEELS around the line of flight.
const px = -Math.sin(angle), py = Math.cos(angle);
const dx = Math.cos(angle), dy = Math.sin(angle);
const c = Math.cos(handle.phase), s = Math.sin(handle.phase);
const ox = (px * c + dx * s) * r;
const oy = (py * c + dy * s) * r;
b1.x = x + ox;
b1.y = y + oy;
b2.x = x - ox;
b2.y = y - oy;
str.x = x;
str.y = y;
str.setAngle((Math.atan2(oy, ox) * 180) / Math.PI);
}
/** Kill a bola set NOW (the snag or the fizzle supersedes it). Safe twice. */
bolaStop(handle) {
if (!handle || handle.dead) return;
handle.dead = true;
for (const img of [handle.b1, handle.b2, handle.str]) {
if (img && !img.destroyed) img.destroy();
}
}
/**
* THE SNAG READ (a bola set lands on a craft): the cord's ring around the
* victim at its own scale, hot sparks off the impact, the white hit-flash
* — the craft was just caught, and it shows it.
* @param {object} e the strung hostile (needs .x/.y/.hitRadius)
* @param {number} color the data's ball color (int)
*/
snag(e, color) {
const x = e.x;
const y = e.y;
const r = Math.max(Number(e?.hitRadius) || 20, 12);
this.ring(x, y, color, r * 5, 420);
this.sparks(x, y, color, 10, 240, 480);
this.sparks(x, y, 0xffffff, 6, 180, 420);
this.craftFlash(e);
}
// ----------------------------------------------------------- the impacts
/**

View File

@ -96,6 +96,27 @@ export function radiusHits(crafts = [], x = 0, y = 0, radius = 0) {
});
}
/**
* 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
@ -130,11 +151,35 @@ function hullPool(combat, def, rank) {
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)? */