diff --git a/assets/videos/toxic-land-01.mp4 b/assets/videos/toxic-land-01.mp4 index 2c2709a..077852d 100644 Binary files a/assets/videos/toxic-land-01.mp4 and b/assets/videos/toxic-land-01.mp4 differ diff --git a/data/skills.json b/data/skills.json index 0bcd00a..1738ccf 100644 --- a/data/skills.json +++ b/data/skills.json @@ -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" + } } } diff --git a/dev/shots/stun-1.png b/dev/shots/stun-1.png new file mode 100644 index 0000000..8e7efc2 Binary files /dev/null and b/dev/shots/stun-1.png differ diff --git a/dev/shots/stun-2.png b/dev/shots/stun-2.png new file mode 100644 index 0000000..710f61f Binary files /dev/null and b/dev/shots/stun-2.png differ diff --git a/dev/shots/stun-3.png b/dev/shots/stun-3.png new file mode 100644 index 0000000..1276f9f Binary files /dev/null and b/dev/shots/stun-3.png differ diff --git a/dev/shots/stun-4-released.png b/dev/shots/stun-4-released.png new file mode 100644 index 0000000..e25bec9 Binary files /dev/null and b/dev/shots/stun-4-released.png differ diff --git a/dev/skills-bar-browser.test.mjs b/dev/skills-bar-browser.test.mjs index 103b699..8ab4d65 100644 --- a/dev/skills-bar-browser.test.mjs +++ b/dev/skills-bar-browser.test.mjs @@ -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`); diff --git a/dev/skills-combat.test.mjs b/dev/skills-combat.test.mjs index 8e8ca07..dd6b677 100644 --- a/dev/skills-combat.test.mjs +++ b/dev/skills-combat.test.mjs @@ -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)); diff --git a/dev/stun-shot.mjs b/dev/stun-shot.mjs new file mode 100644 index 0000000..f2bcdd6 --- /dev/null +++ b/dev/stun-shot.mjs @@ -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); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index b3c8bcc..eae3363 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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. diff --git a/js/combat/Fx.js b/js/combat/Fx.js index a0f044a..7b14c82 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -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).