diff --git a/assets/images/ships-player.png b/assets/images/ships-player.png index 6f5cad0..e0c636b 100644 Binary files a/assets/images/ships-player.png and b/assets/images/ships-player.png differ diff --git a/assets/images/ships-player.psd b/assets/images/ships-player.psd index 194a68a..22a827d 100644 Binary files a/assets/images/ships-player.psd and b/assets/images/ships-player.psd differ diff --git a/assets/images/skills.png b/assets/images/skills.png index 836849d..2d59da6 100644 Binary files a/assets/images/skills.png and b/assets/images/skills.png differ diff --git a/assets/images/skills.psd b/assets/images/skills.psd index 4714947..fe868d7 100644 Binary files a/assets/images/skills.psd and b/assets/images/skills.psd differ diff --git a/assets/videos/toxic-land-01.mp4 b/assets/videos/toxic-land-01.mp4 new file mode 100644 index 0000000..2c2709a Binary files /dev/null and b/assets/videos/toxic-land-01.mp4 differ diff --git a/data/skills.json b/data/skills.json index 1d426b7..0bcd00a 100644 --- a/data/skills.json +++ b/data/skills.json @@ -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" + } + } + } + ] } }, diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 6c3562f..724fb81 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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')); diff --git a/dev/shots/skills-bar-assign.png b/dev/shots/skills-bar-assign.png index f3595bc..9c1857d 100644 Binary files a/dev/shots/skills-bar-assign.png and b/dev/shots/skills-bar-assign.png differ diff --git a/dev/shots/skills-bar-no-energy.png b/dev/shots/skills-bar-no-energy.png index d41f015..8aaff94 100644 Binary files a/dev/shots/skills-bar-no-energy.png and b/dev/shots/skills-bar-no-energy.png differ diff --git a/dev/shots/skills-bar-ready.png b/dev/shots/skills-bar-ready.png index 126e681..5fbe40e 100644 Binary files a/dev/shots/skills-bar-ready.png and b/dev/shots/skills-bar-ready.png differ diff --git a/dev/shots/skills-bar-recharging.png b/dev/shots/skills-bar-recharging.png index e77179f..0a820c9 100644 Binary files a/dev/shots/skills-bar-recharging.png and b/dev/shots/skills-bar-recharging.png differ diff --git a/dev/shots/skills-bar.json b/dev/shots/skills-bar.json index f070ffc..bfebb3d 100644 --- a/dev/shots/skills-bar.json +++ b/dev/shots/skills-bar.json @@ -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", diff --git a/dev/shots/skills-seat.png b/dev/shots/skills-seat.png index 2bfc318..2a5276e 100644 Binary files a/dev/shots/skills-seat.png and b/dev/shots/skills-seat.png differ diff --git a/dev/shots/skills-window-chain.png b/dev/shots/skills-window-chain.png index 63ff9eb..a1d1f51 100644 Binary files a/dev/shots/skills-window-chain.png and b/dev/shots/skills-window-chain.png differ diff --git a/dev/shots/skills-window-closed.png b/dev/shots/skills-window-closed.png index 3ed9b22..600883c 100644 Binary files a/dev/shots/skills-window-closed.png and b/dev/shots/skills-window-closed.png differ diff --git a/dev/shots/skills-window-long-tree.png b/dev/shots/skills-window-long-tree.png index 19099b2..2e0adac 100644 Binary files a/dev/shots/skills-window-long-tree.png and b/dev/shots/skills-window-long-tree.png differ diff --git a/dev/shots/skills-window-ranked.png b/dev/shots/skills-window-ranked.png index 6b13a85..0cdf902 100644 Binary files a/dev/shots/skills-window-ranked.png and b/dev/shots/skills-window-ranked.png differ diff --git a/dev/shots/skills-window-selected.png b/dev/shots/skills-window-selected.png index 5e99935..6b95960 100644 Binary files a/dev/shots/skills-window-selected.png and b/dev/shots/skills-window-selected.png differ diff --git a/dev/shots/skills-window.json b/dev/shots/skills-window.json index 732237b..a12aead 100644 --- a/dev/shots/skills-window.json +++ b/dev/shots/skills-window.json @@ -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" } }, diff --git a/dev/skills-combat.test.mjs b/dev/skills-combat.test.mjs index 8d74168..8e8ca07 100644 --- a/dev/skills-combat.test.mjs +++ b/dev/skills-combat.test.mjs @@ -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 } }); diff --git a/dev/skills-devdoor-browser.test.mjs b/dev/skills-devdoor-browser.test.mjs index 66d9010..4086704 100644 --- a/dev/skills-devdoor-browser.test.mjs +++ b/dev/skills-devdoor-browser.test.mjs @@ -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); diff --git a/dev/skills.test.mjs b/dev/skills.test.mjs index ef30efa..1458bb7 100644 --- a/dev/skills.test.mjs +++ b/dev/skills.test.mjs @@ -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 -------------------------------------------------------- diff --git a/js/combat/CombatSfx.js b/js/combat/CombatSfx.js index c4d487f..5ca81d1 100644 --- a/js/combat/CombatSfx.js +++ b/js/combat/CombatSfx.js @@ -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 diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 9009330..b3c8bcc 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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(); diff --git a/js/combat/Fx.js b/js/combat/Fx.js index 42edf0d..a0f044a 100644 --- a/js/combat/Fx.js +++ b/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 /** diff --git a/js/skills/SkillEffects.js b/js/skills/SkillEffects.js index 191a7c7..27bd7b9 100644 --- a/js/skills/SkillEffects.js +++ b/js/skills/SkillEffects.js @@ -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)? */