Add stun visual read to Boleadoras-strung enemies

- Strung hostiles dim to the data's fx.stunAlpha and show a crackling discharge (Fx.stunArms) while the snare holds, then recover with a release beat when the stun expires or on kill/revive
- Add Fx.stunArms/killStunArms + jagBolt helpers for the per-frame discharge (jagged bolts, sparks, hull stutters) tied to the snare's clock
- Store per-craft stun color/alpha from the bola catch and drive hold/release via _stunHoldFrame/_stunRelease in CombatSystem
- Add dev/stun-shot.mjs CDP script + screenshots, extend combat tests for dim/discharge/release, bump skills-bar test timeout, reformat skills.json, refresh toxic-land video
This commit is contained in:
Brian Fertig 2026-09-27 16:58:59 -06:00
parent 12d6867eae
commit a28aa6f7fa
11 changed files with 560 additions and 41 deletions

Binary file not shown.

View File

@ -2,23 +2,40 @@
"_comment": "THE SKILL LAYER — what a class can actually DO with the skill points its lock-in banks (data/classes.json → bonuses.skillPoints, the ledger js/player/ClassState.js). THE SHAPE: code owns behavior, this file owns every number, string, colour and icon frame (js/skills/SkillModel.js reads it and normalizes; js/skills/SkillState.js spends it; js/ui/SkillWindow.js draws it; js/combat/CombatSystem.js executes the effects). Keys starting with `_` are documentation, not data. THE RULE: adding skill #3 is an edit to this file plus, at most, one new effect `kind` handler in js/skills/SkillEffects.js — never a re-plumb of the tree, the bar or the ledger.",
"enabled": true,
"_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), 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
},
"types": {
"_comment": "THE TYPE TAXONOMY. A skill's `type` names an entry here; the type carries the accent the tree/badge wears, the label it prints, and `passive` — the ONLY thing that decides whether a skill is CASTABLE (an active skill can hold a combat-bar seat and spend energy; a passive is always-on at rank ≥1 and can never take a seat). Types are first-class so later skills can reference them ('+10% to all Offense skills'): reference by the SAME key. Add a type = add an entry here.",
"Offense": { "label": "OFFENSE", "accent": "#ff3a4d", "passive": false },
"Defense": { "label": "DEFENSE", "accent": "#5fd4ff", "passive": false },
"Utility": { "label": "UTILITY", "accent": "#7ce8a4", "passive": false },
"Mobility": { "label": "MOBILITY", "accent": "#ffc94d", "passive": false },
"Passive": { "label": "PASSIVE", "accent": "#c084fc", "passive": true }
"Offense": {
"label": "OFFENSE",
"accent": "#ff3a4d",
"passive": false
},
"Defense": {
"label": "DEFENSE",
"accent": "#5fd4ff",
"passive": false
},
"Utility": {
"label": "UTILITY",
"accent": "#7ce8a4",
"passive": false
},
"Mobility": {
"label": "MOBILITY",
"accent": "#ffc94d",
"passive": false
},
"Passive": {
"label": "PASSIVE",
"accent": "#c084fc",
"passive": true
}
},
"seats": {
"_comment": "THE COMBAT BAR'S SEATS. `count` is the fallback when data/combat.json → bar.slot.count is unreadable (the bar owns the real number — eight seats wearing keys 1-8). A skill's `seat` below is its DEFAULT binding (1-based); the player reassigns by clicking a skill in the tree and then a seat, and the run's bindings live in the ledger (js/skills/SkillState.js → record.playerSkills.seats), so this table is only ever the first-run suggestion. seatOutOfRangePolicy: 'ignore' drops a default that points past the last seat, 'clamp' pins it to the last seat.",
"count": 8,
@ -54,7 +71,6 @@
"hintUnbind": "RIGHT-CLICK A SEAT TO CLEAR IT"
}
},
"classes": {
"_comment": "ONE TREE PER CLASS, keyed by the class id in data/classes.json. `skills` is the tree in DISPLAY order (the order the console selects through); the GRAPH is the skills' own `requires` edges. Where each skill SITS is its own `node: { col, row }` — a grid cell, row 0 at the top of the console's tree pane and one row further down for every generation below it (`window.tree`). Leave `node` out and the console lays it out itself: roots across the top, each child one row under its deepest parent (js/skills/SkillModel.js → treeLayout). A class with no authored skills shows the console's empty-trees plate rather than a dead window.",
"vanguard": {
@ -71,7 +87,10 @@
"maxPoints": 5,
"requires": [],
"seat": 1,
"node": { "col": 1, "row": 0 },
"node": {
"col": 1,
"row": 0
},
"description": "Charge the nearest enemy at 3x speed over 1 second at ramming speed. Inflict 4x regular damage upon hit, 2x additional damage per additional skill point.",
"shortDescription": "Ram the nearest hostile for 4x weapon damage.",
"effect": {
@ -104,7 +123,10 @@
"maxPoints": 5,
"requires": [],
"seat": null,
"node": { "col": 3, "row": 0 },
"node": {
"col": 3,
"row": 0
},
"description": "Strengthen your ship's hull, adding a 5% increase in hull strength, with an additional 5% increase per additional skill point.",
"shortDescription": "+5% hull. +5% more per point.",
"effect": {
@ -115,7 +137,6 @@
}
]
},
"wraith": {
"_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": [
@ -130,11 +151,14 @@
"maxPoints": 5,
"requires": [],
"seat": 1,
"node": { "col": 1, "row": 0 },
"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.",
"_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) and the STRUNG STATE while the stun holds (stunAlpha = the strung craft's alpha — the dimmed/disabled read; the crackling discharge's look lives in js/combat/Fx.js → stunArms); sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played when a set lands.",
"kind": "boleadoras",
"targetCount": 3,
"speed": 700,
@ -146,7 +170,8 @@
"stringColor": "#9fd8ff",
"ballSize": 14,
"stringRadius": 16,
"sfxEvent": "bola_snag"
"sfxEvent": "bola_snag",
"stunAlpha": 0.42
}
}
},
@ -161,7 +186,10 @@
"maxPoints": 5,
"requires": [],
"seat": 2,
"node": { "col": 3, "row": 0 },
"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": {
@ -183,7 +211,6 @@
]
}
},
"window": {
"_comment": "THE SKILL TREE CONSOLE (js/ui/SkillWindow.js, depth 80). Same cabinet as every other window, but a three-region body: the class PLATE upper-left, the TREE upper-right (a scrollable grid of icon tiles wearing n/m rank badges and unlock lines — see `tree` below), and the DETAIL drawer along the bottom third for whichever tile is selected (big icon left, copy right, ACTIONS lower-right).",
"title": "SKILL TREE",
@ -192,7 +219,10 @@
"video": {
"_comment": "The left feed — a muted 2:3 loop, authored separately. A missing clip leaves the NO SIGNAL plate up and the console still works (the ship/class consoles' rule).",
"file": "assets/videos/skills.mp4",
"aspect": [2, 3],
"aspect": [
2,
3
],
"tag": "TRAINING SIM // OPEN CHANNEL",
"noSignalTag": "FEED OFFLINE"
},
@ -255,9 +285,20 @@
"_comment": "THE TREE PANE (upper-right region): a GRID of icon tiles. `cols` is the authored grid width (a skill's own `node: {col,row}` places it; an unplaced skill falls into the auto-layout); the console fits `cols` cells across whatever room it has and clamps the tile between minPx and maxPx. Every look-knob below is the data's — the code restates none of them.",
"cols": 5,
"_commentRegions": "How the console's body is cut up: the tree pane takes the upper `splitFraction` of the body and the class plate `plateFraction` of its width (the drawer lives under both). The remaining third is the detail drawer.",
"regions": { "splitFraction": 0.66, "plateFraction": 0.27, "gapPx": 14 },
"tile": { "minPx": 46, "maxPx": 92 },
"gap": { "colPx": 34, "rowPx": 44, "padPx": 18 },
"regions": {
"splitFraction": 0.66,
"plateFraction": 0.27,
"gapPx": 14
},
"tile": {
"minPx": 46,
"maxPx": 92
},
"gap": {
"colPx": 34,
"rowPx": 44,
"padPx": 18
},
"edge": {
"_comment": "The unlock lines between a parent tile and its child. `spent` = the requirement is met AND the child has a rank (the chosen path — the bright gold), `open` = the requirement is met but the child is still unranked, `locked` = the edge is not satisfied yet. Lines are drawn as an elbow: straight down out of the parent, across, straight into the child.",
"spentColor": "#ffd84d",
@ -292,15 +333,20 @@
"fontSizePx": 12
},
"_commentSocket": "Every cell inside the tree's bounds that holds NO skill is plated as a dim socket (the reference sheet's empty nodes): the authored grid stays legible when a tree is young instead of leaving icons adrift in an empty pane.",
"socket": {
"enabled": true,
"faceColor": "#080f1c",
"faceAlpha": 0.5,
"edgeColor": "#3d4c74",
"edgeAlpha": 0.35,
"tickPx": 7
},
"veil": { "lockedColor": "#050a13", "lockedAlpha": 0.62, "pendingGlow": "#ffd84d", "pendingAlpha": 0.5 },
"socket": {
"enabled": true,
"faceColor": "#080f1c",
"faceAlpha": 0.5,
"edgeColor": "#3d4c74",
"edgeAlpha": 0.35,
"tickPx": 7
},
"veil": {
"lockedColor": "#050a13",
"lockedAlpha": 0.62,
"pendingGlow": "#ffd84d",
"pendingAlpha": 0.5
},
"scrollbar": {
"_comment": "The tree scrolls (wheel, drag, or the thumb) whenever the laid-out content is taller than the pane — a long install's tree must never be cut off.",
"wPx": 8,
@ -318,19 +364,35 @@
"_comment": "Rank-up + window sfx ids (data/sfx.json). spend = the point lands (the discovery chime), denied = a refused spend (the deconstruct voice — it has to sound like a step backwards), assign = a seat bound, open/close = the cabinet.",
"spend": "discovery",
"denied": "deconstruct",
"_commentRefund": "RESET is a step backwards, so it wears the same voice as a refusal — its own key only so the two decisions stay separable.",
"refund": "deconstruct",
"_commentRefund": "RESET is a step backwards, so it wears the same voice as a refusal — its own key only so the two decisions stay separable.",
"refund": "deconstruct",
"assign": "ui_click",
"open": "ui_window",
"close": "ui_window"
},
"fx": {
"_comment": "Window dressing, the research console's recipe: occasional RGB-split slice bursts over the plate and a scan band crawling the feed.",
"glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] },
"sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 }
"glitch": {
"enabled": true,
"intervalMs": [
6000,
13000
],
"durationMs": [
220,
420
]
},
"sweep": {
"enabled": true,
"everyMs": [
4200,
8600
],
"durationMs": 1500
}
}
},
"_skillTemplate": {
"_comment": "THE CONTRACT one skill record owes the model (defaults in brackets when a key is missing/wrong): id [required — dropped without it], label [id], type ['Utility' → see types], frame [0], energy [0], cooldown [0], maxPoints [1], requires [[] — ids, or {skill, points} for a 'put N points into X first' edge], seat [null = no default binding], node [{col,row} on the console's tree grid — auto-laid-out when absent], description [''], shortDescription [''], effect [{kind:'none'}].",
"id": "skill-id",
@ -340,11 +402,18 @@
"energy": 40,
"cooldown": 8,
"maxPoints": 5,
"requires": ["ramming_charge"],
"requires": [
"ramming_charge"
],
"seat": null,
"node": { "col": 1, "row": 1 },
"node": {
"col": 1,
"row": 1
},
"description": "The full effect text the console prints.",
"shortDescription": "One line for the tooltip / bar seat.",
"effect": { "kind": "none" }
"effect": {
"kind": "none"
}
}
}

BIN
dev/shots/stun-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
dev/shots/stun-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

BIN
dev/shots/stun-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@ -309,7 +309,11 @@ const broke = await page
return g.combatBar.seatArt[0].state === 'noEnergy' ? { ok: true } : null;
},
{ cost: COST, id: RAM.id },
{ timeout: 40000, polling: 250 },
// 120s (not 40s): under a loaded runner the headless frames run slow and the
// game clock (frame-based) outruns the wall clock — the ${CD}s recharge can
// take far longer to expire than the numbers suggest (same "keep the range
// open" rule as the waits above).
{ timeout: 120000, polling: 250 },
)
.then(() => snap());
check(broke.art[0].state === 'noEnergy' && broke.seats[0].affordable === false, `a seat the ${Math.round(broke.energy)}-point orb cannot pay for draws the dimmed "not enough energy" face`);

View File

@ -105,6 +105,7 @@ function makeGraphics() {
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setVisible(v) { this.visible = v; return this; },
@ -504,11 +505,22 @@ function dummy(combat, x, y) {
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);
// THE STRUNG READ: the hull DIMS to the data's fx.stunAlpha (the
// disabled read) and the crackling discharge is live on the combat fx.
check('stun: the strung craft is dimmed (its alpha sinks while the snare holds)', foe.alpha < 0.9);
check(`stun: the dim is the data's (fx.stunAlpha ${BEFF.fx.stunAlpha})`, Math.abs(foe.alpha - BEFF.fx.stunAlpha) < 0.01);
check('stun: the crackling discharge is live (a stunArms item on the combat fx)',
f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === foe));
// …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);
// …and the strung read settles with it: the hull's alpha comes back,
// the discharge is gone.
check('stun: the release restores the hull (alpha back, the discharge settled)',
foe.alpha >= 0.99 && !f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === foe));
}
{
@ -525,6 +537,10 @@ function dummy(combat, x, y) {
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);
check('stun: every strung craft is dimmed with its discharge live',
[near1, near2, near3].every((e) => e.alpha < 0.9 && f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === e)));
check('stun: the unstrung hostile is neither dimmed nor arced',
far.alpha >= 0.99 && !f.combat.fx.items.some((it) => it.kind === 'stunArms' && it.ship === far));
// 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));

207
dev/stun-shot.mjs Normal file
View File

@ -0,0 +1,207 @@
/**
* dev/stun-shot.mjs — the Boleadoras STUN READ in the real game: the strung
* hostiles dim to the data's fx.stunAlpha and crackle with the discharge
* (Fx.stunArms) while the snare holds, then recover (alpha back, bolts
* settled) exactly when the data's clock runs out.
*
* node dev/stun-shot.mjs
*
* Needs: the user's headless Brave (CDP :9333) and a static server on
* :8080 (or ORBIT_URL). Screenshots land in dev/shots/stun-*.png.
*/
import { mkdir, writeFile } from 'node:fs/promises';
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8080';
const CDP = process.env.CDP_PORT ? `127.0.0.1:${CDP_PORT}` : '127.0.0.1:9333';
const URL = `${BASE}/index.html?skill=wraith&combat`;
let target = await (await fetch(`http://${CDP}/json/list`)).json()
.then((ts) => ts.find((t) => t.type === 'page'));
if (!target) {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(URL)}`, { method: 'PUT' })).json();
}
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
const consoleErrors = [];
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws connect')); });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
consoleErrors.push(msg.params.args.map((a) => a.value ?? a.description ?? '').join(' '));
} else if (msg.method === 'Runtime.exceptionThrown') {
consoleErrors.push(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text ?? 'exception');
}
};
await send('Page.enable');
await send('Runtime.enable');
await send('Page.navigate', { url: 'about:blank' });
await new Promise((r) => setTimeout(r, 250));
await send('Page.navigate', { url: URL });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' });
await mkdir('dev/shots', { recursive: true });
await writeFile(`dev/shots/${name}.png`, Buffer.from(r.data, 'base64'));
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 1) wait for the MENU (seeded), start a NEW GAME, then wait for the scene
// + the combat layer (ack any comm that holds the screen)
const menuReady = `(function(){
const m = window.game && window.game.scene.getScene('MenuScene');
return !!(m && typeof m.seedValue === 'string' && m.seedValue.length > 0);
})()`;
let booted = false;
{
const deadline = Date.now() + 90000;
while (Date.now() < deadline && !booted) {
booted = await evaluate(menuReady).catch(() => false);
await sleep(250);
}
if (!booted) {
console.error('FAIL: the menu never became ready');
process.exit(1);
}
await evaluate(`(function(){ const m = window.game.scene.getScene('MenuScene'); if (m && !m.dead) m.startNewGame(); return true; })()`);
const deadline2 = Date.now() + 90000;
booted = false;
while (Date.now() < deadline2 && !booted) {
booted = await evaluate(`(function(){
const g = window.game && window.game.scene.getScene('GameScene');
if (g && g.comms?.paused) { try { g.comms._ack(); } catch {} }
return !!(g && g.ship && g.skillState && g.combat && typeof g.combat.cast === 'function');
})()`).catch(() => false);
await sleep(250);
}
if (!booted) {
console.error('FAIL: the scene never became ready');
process.exit(1);
}
}
await sleep(1500); // let the demo pack spawn + fly in
// 2a) the dev door filed the wraith but its tree came in unranked — rank it
// the way ?skill=max does (the door's own loop: grant + rankUpSkill)
const ranked = await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
const tree = window.config.data.skills.classes[s.classState.id].skills;
s.classState.awardSkillPoints(tree.reduce((n, d) => n + (d.maxPoints || 0), 0));
const ranks = {};
for (const def of tree) {
ranks[def.id] = 0;
for (let i = 0; i < (def.maxPoints || 0); i++) {
if (!s.rankUpSkill(def.id).ok) break;
ranks[def.id] += 1;
}
}
return ranks;
})()`).catch((e) => ({ error: String(e) }));
console.log('ranks after the door:', JSON.stringify(ranked));
// 2) make the player unkillable (the test's own trick) and note the roster
await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
const c = s.combat;
c.maxHull = 1e6; c.hull = 1e6; c.maxShields = 1e6; c.shields = 1e6;
return true;
})()`);
const before = await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
const row = s.combat.skillState && s.combat.skillState.seatRow && s.combat.skillState.seatRow()[0];
return {
enemies: s.combat.enemies.length,
seat1: row && row.def ? { id: row.def.id, rank: s.combat.skillState.rank(row.def.id) } : null,
energy: Math.floor(s.combat.energy),
};
})()`);
console.log('roster before cast:', JSON.stringify(before));
// 3) cast Boleadoras (seat 1) and wait for the snag
const cast = await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
return s.combat.cast(0);
})()`);
console.log('cast:', JSON.stringify({ ok: cast?.ok, cost: cast?.cost, bolas: cast?.bolas?.count }));
// 4) poll for the strung read (dim + the live discharge)
let strung = null;
{
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
const v = await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
s._devReleaseComms && s._devReleaseComms();
const c = s.combat;
const now = s.time.now;
const es = c.enemies.filter((e) => !e.dead && (e.stunUntil || 0) > now);
if (es.length < 3) return null;
return es.map((e) => ({
alpha: +e.alpha.toFixed(3),
base: e._stunBaseAlpha ?? null,
arms: !!e._stunArms,
stunLeftMs: Math.round((e.stunUntil || 0) - now),
bolts: e._stunArms ? e._stunArms.bolts.length : null,
}));
})()`).catch(() => null);
if (v) { strung = v; break; }
await sleep(120);
}
}
if (!strung) {
console.error('FAIL: no strung read within 8s (the sets never landed?)');
process.exit(1);
}
console.log('STUNNED:', JSON.stringify(strung, null, 1));
// 5) capture the read (the crackle changes frame to frame — three beats)
await shot('stun-1');
await sleep(420);
await shot('stun-2');
await sleep(420);
await shot('stun-3');
// 6) the release: alpha back, the discharge settled
let released = null;
{
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
const v = await evaluate(`(function(){
const s = window.game.scene.getScene('GameScene');
s._devReleaseComms && s._devReleaseComms();
const c = s.combat;
const now = s.time.now;
const live = c.enemies.filter((e) => !e.dead && (e.stunUntil || 0) > now);
if (live.length) return null;
return c.enemies.filter((e) => !e.dead).map((e) => ({ alpha: +e.alpha.toFixed(3), arms: !!e._stunArms }));
})()`).catch(() => null);
if (v) { released = v; break; }
await sleep(150);
}
}
if (!released) {
console.error('FAIL: the stun never released');
process.exit(1);
}
console.log('RELEASED:', JSON.stringify(released));
await shot('stun-4-released');
const errs = consoleErrors.filter((m) => !/favicon|404|font/i.test(m));
console.log(errs.length === 0 ? 'console: clean' : 'console errors:\n' + errs.join('\n'));
ws.close();
process.exit(strung.every((e) => e.alpha < 0.9 && e.arms) && released.every((e) => e.alpha >= 0.99 && !e.arms) && errs.length === 0 ? 0 : 1);

View File

@ -731,14 +731,18 @@ export class CombatSystem {
}
// 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).
// craft stands on the line where the cords caught it) — and it SHOWS
// it: dimmed to the data's fx.stunAlpha with the crackling discharge
// (Fx.stunArms). The release's edge settles the read (the beat).
if (time < (e.stunUntil || 0)) {
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
this._stunHoldFrame(e, time); // the strung read (idempotent per frame)
continue;
}
if (e._stunDimmed || e._stunArms) this._stunRelease(e, true); // the release beat (once)
// 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
@ -1453,6 +1457,9 @@ export class CombatSystem {
// Settle any pending white hit-flash NOW — the craft dies in its
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
// AND settle any strung read (the kill supersedes the stun's dim and
// discharge — no dimmed corpse, no orphaned bolts).
this._stunRelease(e, false);
this.kills += 1;
// COMBAT XP — the kill's reward (the hostile's own type's value —
// combat.enemy.xp / the type's override; the curve is
@ -1508,6 +1515,7 @@ export class CombatSystem {
e._commedAttack = false;
e._wasOnScreen = false;
e.stunUntil = 0; // a new life stands unstrung (the snare's clock resets with it)
this._stunRelease(e, false); // ...and the strung read goes with the old life
this._setActive();
}
@ -1976,7 +1984,11 @@ export class CombatSystem {
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));
// THE STRUNG READ's data (the hull's dim + the discharge's color) —
// the read itself lives in _stunHoldFrame/_stunRelease + Fx.stunArms.
e._stunColor = hexToInt(b.fx.ballColor, 0xb489ff);
e._stunAlpha = Math.max(0.1, Math.min(1, Number(b.fx?.stunAlpha) || 0.42));
this.fx.snag(e, e._stunColor);
}
if (caught.length > 0) {
combatSfx(this.scene, String(b.fx.sfxEvent || 'bola_snag'));
@ -1985,6 +1997,58 @@ export class CombatSystem {
}
}
/**
* THE STRUNG READ's per-frame keep (the snare's gate calls it while the
* stun holds): the hull DIMS to the data's fx.stunAlpha (the disabled
* read) and the discharge (Fx.stunArms) is live — both driven by the
* same `stunUntil` the gate reads. Idempotent: one dim, one discharge;
* a re-snag extends the discharge's life with the snare's clock.
*/
_stunHoldFrame(e, time) {
if (!e._stunDimmed) {
e._stunBaseAlpha = Number.isFinite(e.alpha) ? e.alpha : 1;
const dim = e._stunAlpha ?? 0.42;
if (typeof e.setAlpha === 'function') {
try { e.setAlpha((e._stunBaseAlpha || 1) * dim); } catch { /* a stub without alpha */ }
}
e._stunDimmed = true;
}
if (e._stunArms && !e._stunArms.killed) {
// a RE-SNAG (overlapping sets) extends the discharge with the snare
e._stunArms.life = Math.max(e._stunArms.life, e._stunArms.t0 + Math.max(0, (e.stunUntil || 0) - time));
} else {
e._stunArms = this.fx.stunArms(e, e._stunColor ?? 0xb489ff, Math.max(150, (e.stunUntil || time) - time));
}
}
/**
* THE RELEASE (the snare's clock ran out — or the kill/revive seams):
* the discharge settles (Fx.killStunArms), the hull's alpha comes
* back, and — only when the craft is alive — a small beat marks the
* moment it snaps back to its own power.
* @param {object} e the strung hostile
* @param {boolean} cue play the release beat (the stun expiry's edge)
*/
_stunRelease(e, cue = true) {
const color = e._stunColor ?? 0xb489ff;
if (e._stunArms) {
this.fx.killStunArms(e);
e._stunArms = null;
}
if (e._stunDimmed) {
e._stunDimmed = false;
if (typeof e.setAlpha === 'function') {
try { e.setAlpha(e._stunBaseAlpha ?? 1); } catch { /* the craft may be gone */ }
}
}
e._stunColor = null;
e._stunAlpha = null;
if (cue && !e.dead && !e.destroyed) {
this.fx.flash(e.x, e.y, color, 44, 200);
this.fx.sparks(e.x, e.y, 0xffffff, 8, 170, 320);
}
}
/**
* 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.

View File

@ -310,6 +310,42 @@ function drawArc(ctx, w, h) {
* (js/visuals/UiCameras.js) so the UI pass (bar, feed) never double-draws
* or shakes with it — a no-op while the split doesn't exist.
*/
/**
* A jagged bolt radiating OUTWARD from near the hull (midpoint
* displacement, three refinement passes) — the strung craft's discharge
* (stunArms). Local coords: the origin is the craft's center, the hull
* at `r`. Random per strike — combat sparks, not the sky's seeded
* lightning (js/visuals/NebulaLightning.js).
*
* @returns {{pts: [number, number][], age: number, life: number}}
*/
function jagBolt(r) {
const a0 = Math.random() * Math.PI * 2;
const len = r * (2.0 + Math.random() * 1.5);
const sx = Math.cos(a0) * r * 0.3;
const sy = Math.sin(a0) * r * 0.3;
const ex = Math.cos(a0) * len;
const ey = Math.sin(a0) * len;
let pts = [[sx, sy], [ex, ey]];
let disp = len * 0.3;
for (let pass = 0; pass < 3; pass++) {
const next = [pts[0]];
for (let i = 0; i < pts.length - 1; i++) {
const ax = pts[i][0], ay = pts[i][1];
const bx = pts[i + 1][0], by = pts[i + 1][1];
const mx = (ax + bx) / 2, my = (ay + by) / 2;
const nx = -(by - ay), ny = bx - ax;
const nl = Math.hypot(nx, ny) || 1;
const off = (Math.random() * 2 - 1) * disp;
next.push([mx + (nx / nl) * off, my + (ny / nl) * off]);
}
next.push(pts[pts.length - 1]);
pts = next;
disp *= 0.5;
}
return { pts, age: 0, life: 140 + Math.random() * 180 };
}
export class Fx {
/** @param {object} scene the combat scene */
constructor(scene) {
@ -736,6 +772,61 @@ export class Fx {
this.craftFlash(e);
}
/**
* THE STRUNG CRAFT'S DISCHARGE (data/skills.json → 'boleadoras' stun;
* its `fx.stunAlpha` dims the hull — the combat gate does that).
* While the snare holds, the craft is a live circuit: jagged bolts
* crackle off the hull on a strike clock, hot sparks tick, and every
* fewth strike the hull STUTTERS (a white bloom). One world-pass
* Graphics (glow + white-hot core in the data's ball color), redrawn
* each frame; the item's life IS the snare's remaining clock, so the
* discharge dies exactly when the snare's does.
*
* @param {object} e the strung craft (.x/.y/.hitRadius/.dead)
* @param {number} color the data's ball color (int)
* @param {number} lifeMs the snare's remaining time (ms)
* @returns {object} the item (killStunArms settles it early)
*/
stunArms(e, color, lifeMs) {
const col = toColor(color, 0xb489ff);
const gfx = this._world(this.scene.add.graphics())
.setBlendMode(Phaser.BlendModes.SCREEN)
.setDepth(22);
const it = {
img: gfx,
t0: 0,
life: Math.max(60, lifeMs),
kind: 'stunArms',
ship: e,
color: col,
r: Math.max(10, Number(e?.hitRadius) || 18),
bolts: [],
strikeIn: 30, // the first crackle is near-instant
sparkIn: 120,
strikeCount: 0,
};
// The expiry seam: the combat side keeps a pointer (e._stunArms) —
// flag the item killed so a later re-snag makes a fresh discharge.
it.restore = () => { it.killed = true; };
this.items.push(it);
return it;
}
/**
* Settle a craft's discharge NOW (the kill / revive / release seams).
* The bolts are gone, the hull is free — idempotent, safe when the
* discharge already expired.
*/
killStunArms(e) {
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (it.kind !== 'stunArms' || it.ship !== e) continue;
it.killed = true;
if (it.img && !it.img.destroyed) it.img.destroy();
this.items.splice(i, 1);
}
}
// ----------------------------------------------------------- the impacts
/**
@ -1209,6 +1300,74 @@ export class Fx {
const a = t < 0.3 ? t / 0.3 : 1 - (t - 0.3) / 0.7;
it.img.setAlpha(it.alpha0 * Math.max(0, a));
it.img.setScale(it.scale0 * (0.85 + 0.4 * t));
} else if (it.kind === 'stunArms') {
// THE STRUNG CRAFT'S DISCHARGE: the bolts re-strike on their own
// crackle clock, sparks tick off the hull, and every fewth strike
// the hull stutters — until the item's life (the snare's clock)
// dies with it. The glow follows the craft each frame (a
// re-anchor never orphans it).
const ship = it.ship;
const sx = ship ? ship.x : 0;
const sy = ship ? ship.y : 0;
const g = it.img;
g.x = sx;
g.y = sy;
// the bolts age and die on their own lives (the crackle's fade)
for (let bi = it.bolts.length - 1; bi >= 0; bi--) {
const b = it.bolts[bi];
b.age += deltaMs;
if (b.age >= b.life) it.bolts.splice(bi, 1);
}
// the CRACKLE: one or two fresh bolts off the hull, staggered
it.strikeIn -= deltaMs;
if (it.strikeIn <= 0) {
it.strikeIn = 45 + Math.random() * 90;
const n = 2 + (Math.random() < 0.55 ? 1 : 0) + (Math.random() < 0.3 ? 1 : 0);
for (let k = 0; k < n; k++) it.bolts.push(jagBolt(it.r));
it.strikeCount += 1;
// every fewth strike the hull STUTTERS: a white bloom + a
// spark tick at the newest bolt's tip
if (it.strikeCount % 3 === 1 && ship && !ship.dead) {
this.flash(sx, sy, 0xffffff, it.r * 3.0, 140);
const tipPts = it.bolts[it.bolts.length - 1].pts;
const tip = tipPts[tipPts.length - 1];
this.sparks(sx + tip[0], sy + tip[1], 0xffffff, 4, 160, 300);
}
}
// the SPARK TICKS: small colored sparks off the hull, on their
// own slower clock
it.sparkIn -= deltaMs;
if (it.sparkIn <= 0) {
it.sparkIn = 130 + Math.random() * 210;
const a = Math.random() * Math.PI * 2;
const px = sx + Math.cos(a) * it.r * 0.7;
const py = sy + Math.sin(a) * it.r * 0.7;
this.sparks(px, py, it.color, 3 + (Math.random() < 0.4 ? 3 : 0), 140, 280);
}
// REDRAW: a faint flickering charge at the center, then the glow
// pass (the data's color, wide and soft) under the white-hot
// cores — each bolt fading on its own life.
g.clear();
const flick = 0.16 + 0.14 * Math.sin(eff * 0.045);
g.fillStyle(it.color, Math.max(0, flick));
g.fillCircle(0, 0, it.r * 1.5);
const fade = (b) => 1 - b.age / b.life;
for (const b of it.bolts) {
const f = fade(b);
if (f <= 0) continue;
g.lineStyle(5.5, it.color, 0.65 * f);
for (let i = 1; i < b.pts.length; i++) {
g.lineBetween(b.pts[i - 1][0], b.pts[i - 1][1], b.pts[i][0], b.pts[i][1]);
}
}
for (const b of it.bolts) {
const f = fade(b);
if (f <= 0) continue;
g.lineStyle(2.4, 0xffffff, 0.95 * f);
for (let i = 1; i < b.pts.length; i++) {
g.lineBetween(b.pts[i - 1][0], b.pts[i - 1][1], b.pts[i][0], b.pts[i][1]);
}
}
}
// 'tintFlash' needs no per-frame work (the pulse is set at spawn,
// restored on expiry above).