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:
parent
12d6867eae
commit
a28aa6f7fa
Binary file not shown.
145
data/skills.json
145
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
159
js/combat/Fx.js
159
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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue