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
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 530 KiB After Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 590 KiB After Width: | Height: | Size: 467 KiB |
|
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 459 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 194 KiB |
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
126
js/combat/Fx.js
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)? */
|
||||
|
|
|
|||