@debuffjp/dev 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/dist/explain.d.ts +57 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +243 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +157 -0
- package/dist/index.js.map +1 -0
- package/dist/overlay.d.ts +18 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +833 -0
- package/dist/overlay.js.map +1 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +101 -0
- package/dist/replay.js.map +1 -0
- package/dist/session.d.ts +166 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +411 -0
- package/dist/session.js.map +1 -0
- package/package.json +47 -4
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import { loc } from "./explain.js";
|
|
2
|
+
import { DevSession, diagnose, HITCH, summarize } from "./session.js";
|
|
3
|
+
const LIME = "#cffb2d";
|
|
4
|
+
const SKY = "#38bdf8";
|
|
5
|
+
const VIOLET = "#a78bfa";
|
|
6
|
+
const AMBER = "#fbbf24";
|
|
7
|
+
const PINK = "#f472b6";
|
|
8
|
+
/** Accent colours cycled across param groups, for splashes of colour. */
|
|
9
|
+
const ACCENTS = [LIME, SKY, PINK, AMBER, VIOLET];
|
|
10
|
+
const OVER_BUDGET = "#f87171";
|
|
11
|
+
const TEXT = "#cfd6c4";
|
|
12
|
+
const MUTED = "#8a9479";
|
|
13
|
+
const BUDGET_MS = 1000 / 60;
|
|
14
|
+
const BARS = 120;
|
|
15
|
+
const BAR_W = 2;
|
|
16
|
+
const TABS = ["perf", "tune", "state", "log"];
|
|
17
|
+
/**
|
|
18
|
+
* Dev-only overlay. A toolbar (pause, step, speed, snapshot, restart) over four tabs:
|
|
19
|
+
* **perf** (frame graph, headroom, hitches and their causes, a frame inspector), **tune** (live
|
|
20
|
+
* controls for every `game.params` group), **state** (the scene's `inspect()` data, live) and
|
|
21
|
+
* **log** (errors, activity with undo, notes for agents). Plain DOM, no dependencies.
|
|
22
|
+
* Use inside `if (import.meta.env.DEV)`.
|
|
23
|
+
*/
|
|
24
|
+
export function devOverlay(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
name: "dev-overlay",
|
|
27
|
+
install(game) {
|
|
28
|
+
const overlay = new Overlay(game, options);
|
|
29
|
+
return () => overlay.dispose();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
class Overlay {
|
|
34
|
+
game;
|
|
35
|
+
session;
|
|
36
|
+
root;
|
|
37
|
+
body;
|
|
38
|
+
graph;
|
|
39
|
+
label;
|
|
40
|
+
agentBadge;
|
|
41
|
+
errorBadge;
|
|
42
|
+
mascot;
|
|
43
|
+
collapseButton;
|
|
44
|
+
pauseButton;
|
|
45
|
+
summaryLine;
|
|
46
|
+
inspector;
|
|
47
|
+
paramsBox;
|
|
48
|
+
statePre;
|
|
49
|
+
errorsBox;
|
|
50
|
+
activityBox;
|
|
51
|
+
tabButtons = new Map();
|
|
52
|
+
panes = new Map();
|
|
53
|
+
inputs = new Map();
|
|
54
|
+
defaults = new Map();
|
|
55
|
+
cleanups = [];
|
|
56
|
+
/** Value each slider had when the current drag began. */
|
|
57
|
+
dragStart = new Map();
|
|
58
|
+
knownGroups = "";
|
|
59
|
+
/** Frames-ago index of the bar under the pointer or selected, or undefined. */
|
|
60
|
+
selected;
|
|
61
|
+
collapsed = false;
|
|
62
|
+
tab = "perf";
|
|
63
|
+
frameCounter = 0;
|
|
64
|
+
constructor(game, options) {
|
|
65
|
+
this.game = game;
|
|
66
|
+
this.session = DevSession.acquire(game);
|
|
67
|
+
this.root = el("div", { "data-debuff-overlay": "" });
|
|
68
|
+
Object.assign(this.root.style, panelStyle(options.position ?? "top-right"));
|
|
69
|
+
this.root.hidden = options.open === false;
|
|
70
|
+
// ---- header: always visible, even collapsed ----
|
|
71
|
+
const header = el("div");
|
|
72
|
+
Object.assign(header.style, { display: "flex", alignItems: "center", gap: "6px", userSelect: "none" });
|
|
73
|
+
this.mascot = new Buff();
|
|
74
|
+
const title = el("strong", {}, "debuff");
|
|
75
|
+
Object.assign(title.style, { color: LIME, fontSize: "12px", letterSpacing: "0.02em" });
|
|
76
|
+
this.agentBadge = chip("agent", VIOLET);
|
|
77
|
+
this.agentBadge.title = "An agent is connected to this game";
|
|
78
|
+
this.agentBadge.hidden = true;
|
|
79
|
+
this.errorBadge = button("", () => {
|
|
80
|
+
this.setCollapsed(false);
|
|
81
|
+
this.showTab("log");
|
|
82
|
+
});
|
|
83
|
+
Object.assign(this.errorBadge.style, { color: OVER_BUDGET, borderColor: `${OVER_BUDGET}66`, padding: "0 5px" });
|
|
84
|
+
this.errorBadge.hidden = true;
|
|
85
|
+
this.label = chip("", LIME);
|
|
86
|
+
Object.assign(this.label.style, { marginLeft: "auto", fontVariantNumeric: "tabular-nums" });
|
|
87
|
+
this.collapseButton = button("", () => this.setCollapsed(!this.collapsed));
|
|
88
|
+
Object.assign(this.collapseButton.style, { padding: "0 6px", minWidth: "22px" });
|
|
89
|
+
header.append(this.mascot.root, title, this.agentBadge, this.errorBadge, this.label, this.collapseButton);
|
|
90
|
+
// Double-click the header bar to collapse or expand, like a window title bar.
|
|
91
|
+
header.addEventListener("dblclick", (e) => {
|
|
92
|
+
if (!(e.target instanceof HTMLButtonElement))
|
|
93
|
+
this.setCollapsed(!this.collapsed);
|
|
94
|
+
});
|
|
95
|
+
// ---- toolbar ----
|
|
96
|
+
const toolbar = row();
|
|
97
|
+
toolbar.style.margin = "6px 0";
|
|
98
|
+
this.pauseButton = button("pause", () => (this.session.paused ? this.session.resume() : this.session.pause()));
|
|
99
|
+
const step = button("step", () => this.session.step(1));
|
|
100
|
+
step.title = "Advance one fixed step (pauses first)";
|
|
101
|
+
const speed = el("select", { title: "Simulation speed" });
|
|
102
|
+
for (const [v, t] of [
|
|
103
|
+
["1", "1×"],
|
|
104
|
+
["0.5", "½×"],
|
|
105
|
+
["0.25", "¼×"],
|
|
106
|
+
]) {
|
|
107
|
+
speed.append(el("option", { value: v }, t));
|
|
108
|
+
}
|
|
109
|
+
Object.assign(speed.style, inputStyle());
|
|
110
|
+
speed.addEventListener("change", () => this.session.setSpeed(Number(speed.value)));
|
|
111
|
+
const snap = button("snap", () => this.snapshot());
|
|
112
|
+
snap.title = "Download the current frame as a PNG";
|
|
113
|
+
const restart = button("restart", () => this.restartScene());
|
|
114
|
+
restart.title = "Re-enter the current scene";
|
|
115
|
+
restart.style.marginLeft = "auto";
|
|
116
|
+
toolbar.append(this.pauseButton, step, speed, snap, restart);
|
|
117
|
+
// ---- tabs ----
|
|
118
|
+
const tabBar = row();
|
|
119
|
+
Object.assign(tabBar.style, { gap: "2px", borderBottom: `1px solid ${LIME}33`, marginBottom: "6px" });
|
|
120
|
+
for (const t of TABS) {
|
|
121
|
+
const b = el("button", { type: "button", role: "tab" }, t);
|
|
122
|
+
Object.assign(b.style, {
|
|
123
|
+
font: "inherit",
|
|
124
|
+
background: "none",
|
|
125
|
+
border: "none",
|
|
126
|
+
borderBottom: "2px solid transparent",
|
|
127
|
+
color: MUTED,
|
|
128
|
+
padding: "2px 8px",
|
|
129
|
+
cursor: "pointer",
|
|
130
|
+
});
|
|
131
|
+
b.addEventListener("click", () => this.showTab(t));
|
|
132
|
+
this.tabButtons.set(t, b);
|
|
133
|
+
tabBar.append(b);
|
|
134
|
+
}
|
|
135
|
+
// perf
|
|
136
|
+
this.graph = el("canvas", {
|
|
137
|
+
title: "Frame time: bar height is the frame, lime is update, sky is render. Click to inspect.",
|
|
138
|
+
});
|
|
139
|
+
this.graph.width = BARS * BAR_W;
|
|
140
|
+
this.graph.height = 48;
|
|
141
|
+
Object.assign(this.graph.style, { width: "100%", height: "48px", display: "block", imageRendering: "pixelated", cursor: "crosshair" });
|
|
142
|
+
this.graph.addEventListener("pointermove", (e) => this.pick(e));
|
|
143
|
+
this.graph.addEventListener("pointerleave", () => {
|
|
144
|
+
if (!this.session.paused)
|
|
145
|
+
this.selected = undefined;
|
|
146
|
+
this.drawGraph();
|
|
147
|
+
});
|
|
148
|
+
this.graph.addEventListener("click", (e) => {
|
|
149
|
+
this.pick(e);
|
|
150
|
+
if (!this.session.paused)
|
|
151
|
+
this.session.pause("you", "to inspect a frame");
|
|
152
|
+
});
|
|
153
|
+
this.summaryLine = el("div");
|
|
154
|
+
Object.assign(this.summaryLine.style, { margin: "4px 0", lineHeight: "1.45" });
|
|
155
|
+
this.inspector = el("div");
|
|
156
|
+
Object.assign(this.inspector.style, { minHeight: "44px", margin: "4px 0", color: TEXT, fontSize: "10.5px", lineHeight: "1.45" });
|
|
157
|
+
const spikeLabel = el("label", { title: `Pause automatically on the first dropped frame (over ${HITCH}× budget)` });
|
|
158
|
+
Object.assign(spikeLabel.style, { display: "flex", alignItems: "center", gap: "4px", color: TEXT });
|
|
159
|
+
const spike = el("input", { type: "checkbox" });
|
|
160
|
+
spike.style.accentColor = OVER_BUDGET;
|
|
161
|
+
spike.addEventListener("change", () => {
|
|
162
|
+
this.session.pauseOnSpikeMs = spike.checked ? BUDGET_MS * HITCH : 0;
|
|
163
|
+
});
|
|
164
|
+
spikeLabel.append(spike, document.createTextNode("pause on dropped frame"));
|
|
165
|
+
this.addPane("perf", this.graph, this.summaryLine, this.inspector, spikeLabel);
|
|
166
|
+
// tune
|
|
167
|
+
this.paramsBox = el("div");
|
|
168
|
+
this.addPane("tune", this.paramsBox);
|
|
169
|
+
// state
|
|
170
|
+
this.statePre = el("pre");
|
|
171
|
+
Object.assign(this.statePre.style, {
|
|
172
|
+
margin: "0",
|
|
173
|
+
maxHeight: "260px",
|
|
174
|
+
overflow: "auto",
|
|
175
|
+
color: TEXT,
|
|
176
|
+
whiteSpace: "pre-wrap",
|
|
177
|
+
wordBreak: "break-word",
|
|
178
|
+
});
|
|
179
|
+
const stateActions = row();
|
|
180
|
+
stateActions.style.marginTop = "6px";
|
|
181
|
+
stateActions.append(button("copy", () => navigator.clipboard?.writeText(this.stateText())), hint("updates live; pause to read a moment"));
|
|
182
|
+
this.addPane("state", this.statePre, stateActions);
|
|
183
|
+
// log
|
|
184
|
+
this.errorsBox = el("div");
|
|
185
|
+
this.activityBox = el("div");
|
|
186
|
+
Object.assign(this.activityBox.style, { maxHeight: "160px", overflow: "auto", display: "grid", gap: "2px" });
|
|
187
|
+
const noteRow = row();
|
|
188
|
+
noteRow.style.marginTop = "6px";
|
|
189
|
+
const noteInput = el("input", { type: "text", placeholder: "flag this moment for the agent…" });
|
|
190
|
+
Object.assign(noteInput.style, { ...inputStyle(), flex: "1", minWidth: "0" });
|
|
191
|
+
const addNote = () => {
|
|
192
|
+
if (!noteInput.value.trim())
|
|
193
|
+
return;
|
|
194
|
+
this.session.note(noteInput.value.trim());
|
|
195
|
+
noteInput.value = "";
|
|
196
|
+
};
|
|
197
|
+
// Typing here must not drive the game.
|
|
198
|
+
for (const type of ["keydown", "keyup"])
|
|
199
|
+
noteInput.addEventListener(type, (e) => e.stopPropagation());
|
|
200
|
+
noteInput.addEventListener("keydown", (e) => {
|
|
201
|
+
if (e.key === "Enter")
|
|
202
|
+
addNote();
|
|
203
|
+
});
|
|
204
|
+
noteRow.append(noteInput, button("flag", addNote));
|
|
205
|
+
// Replays: everything played since load, as a scenario that reproduces it exactly.
|
|
206
|
+
const replayRow = row();
|
|
207
|
+
replayRow.style.marginTop = "6px";
|
|
208
|
+
const save = button("save replay", () => this.saveReplay(false));
|
|
209
|
+
save.title = "Download this session as a scenario (drop it in scenarios/ or run it with debuff scenario)";
|
|
210
|
+
const copyReplay = button("copy replay", () => this.saveReplay(true));
|
|
211
|
+
copyReplay.title = "Copy this session as scenario JSON, e.g. to paste to an agent";
|
|
212
|
+
replayRow.append(save, copyReplay, hint("replays the session exactly"));
|
|
213
|
+
this.addPane("log", this.errorsBox, this.activityBox, noteRow, replayRow);
|
|
214
|
+
this.body = el("div");
|
|
215
|
+
this.body.append(toolbar, tabBar, ...this.panes.values());
|
|
216
|
+
this.root.append(header, this.body);
|
|
217
|
+
this.showTab(readTab());
|
|
218
|
+
this.setCollapsed(readCollapsed());
|
|
219
|
+
document.body.append(this.root);
|
|
220
|
+
const key = options.toggleKey ?? "Backquote";
|
|
221
|
+
const onKey = (e) => {
|
|
222
|
+
if (e.code !== key)
|
|
223
|
+
return;
|
|
224
|
+
this.root.hidden = !this.root.hidden;
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
};
|
|
227
|
+
addEventListener("keydown", onKey);
|
|
228
|
+
this.cleanups.push(() => removeEventListener("keydown", onKey));
|
|
229
|
+
this.cleanups.push(game.hooks.afterRender(() => this.frame()));
|
|
230
|
+
this.cleanups.push(game.params.onChange((c) => this.sync(`${c.group}.${c.key}`, c.value)));
|
|
231
|
+
this.cleanups.push(game.inspect.register("dev-overlay", () => ({
|
|
232
|
+
open: !this.root.hidden,
|
|
233
|
+
collapsed: this.collapsed,
|
|
234
|
+
tab: this.tab,
|
|
235
|
+
paused: this.session.paused,
|
|
236
|
+
})));
|
|
237
|
+
this.cleanups.push(this.session.subscribe(() => this.refreshSession()));
|
|
238
|
+
this.buildParams();
|
|
239
|
+
this.refreshSession();
|
|
240
|
+
}
|
|
241
|
+
/** Collapse to just the header bar (mascot, fps, badges); remembered across reloads. */
|
|
242
|
+
setCollapsed(collapsed) {
|
|
243
|
+
this.collapsed = collapsed;
|
|
244
|
+
this.body.hidden = collapsed;
|
|
245
|
+
this.collapseButton.textContent = collapsed ? "▸" : "▾";
|
|
246
|
+
this.collapseButton.title = collapsed ? "Expand the dev panel" : "Collapse the dev panel";
|
|
247
|
+
this.collapseButton.setAttribute("aria-expanded", String(!collapsed));
|
|
248
|
+
store(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
249
|
+
}
|
|
250
|
+
showTab(tab) {
|
|
251
|
+
this.tab = tab;
|
|
252
|
+
for (const [t, pane] of this.panes)
|
|
253
|
+
pane.hidden = t !== tab;
|
|
254
|
+
for (const [t, b] of this.tabButtons) {
|
|
255
|
+
const on = t === tab;
|
|
256
|
+
b.setAttribute("aria-selected", String(on));
|
|
257
|
+
b.style.color = on ? LIME : MUTED;
|
|
258
|
+
b.style.borderBottomColor = on ? LIME : "transparent";
|
|
259
|
+
}
|
|
260
|
+
store(TAB_KEY, tab);
|
|
261
|
+
if (tab === "state")
|
|
262
|
+
this.refreshState();
|
|
263
|
+
if (tab === "perf")
|
|
264
|
+
this.drawGraph();
|
|
265
|
+
}
|
|
266
|
+
dispose() {
|
|
267
|
+
for (const c of this.cleanups)
|
|
268
|
+
c();
|
|
269
|
+
this.root.remove();
|
|
270
|
+
this.session.release();
|
|
271
|
+
}
|
|
272
|
+
addPane(tab, ...children) {
|
|
273
|
+
const pane = el("div", { role: "tabpanel", "data-tab": tab });
|
|
274
|
+
pane.append(...children);
|
|
275
|
+
this.panes.set(tab, pane);
|
|
276
|
+
}
|
|
277
|
+
frame() {
|
|
278
|
+
if (this.root.hidden)
|
|
279
|
+
return;
|
|
280
|
+
// Groups can be defined after the overlay installs; rebuild controls when they change.
|
|
281
|
+
const groups = Object.keys(this.game.params.describe()).join(",");
|
|
282
|
+
if (groups !== this.knownGroups)
|
|
283
|
+
this.buildParams();
|
|
284
|
+
const slowTick = ++this.frameCounter % 10 === 0 || this.session.paused;
|
|
285
|
+
if (slowTick) {
|
|
286
|
+
const s = this.game.stats.summary();
|
|
287
|
+
const slow = s.fps > 0 && s.fps < 55 && !this.session.paused;
|
|
288
|
+
this.label.textContent = this.session.paused ? `paused · tick ${this.game.clock.tick}` : `${s.fps} fps`;
|
|
289
|
+
this.label.title = `work ${s.workMs.toFixed(1)} ms (p95 ${s.workP95Ms.toFixed(1)} ms)`;
|
|
290
|
+
const color = this.session.paused ? AMBER : slow ? OVER_BUDGET : LIME;
|
|
291
|
+
this.label.style.color = color;
|
|
292
|
+
this.label.style.background = `${color}22`;
|
|
293
|
+
const last = this.session.recorder.get(0);
|
|
294
|
+
this.mascot.mood((last && last.intervalMs > BUDGET_MS * HITCH) || slow ? "worried" : "calm");
|
|
295
|
+
this.agentBadge.hidden = !this.session.agentConnected;
|
|
296
|
+
}
|
|
297
|
+
if (this.collapsed)
|
|
298
|
+
return;
|
|
299
|
+
if (this.tab === "perf") {
|
|
300
|
+
this.drawGraph();
|
|
301
|
+
if (slowTick)
|
|
302
|
+
this.refreshSummary();
|
|
303
|
+
if (this.selected === undefined)
|
|
304
|
+
this.showFrame(undefined);
|
|
305
|
+
}
|
|
306
|
+
else if (this.tab === "state" && slowTick && !this.session.paused) {
|
|
307
|
+
this.refreshState();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Bar height is the frame interval (what the player feels), coloured by health; work is stacked inside. */
|
|
311
|
+
drawGraph() {
|
|
312
|
+
if (this.collapsed || this.tab !== "perf")
|
|
313
|
+
return;
|
|
314
|
+
const ctx = this.graph.getContext("2d");
|
|
315
|
+
if (!ctx)
|
|
316
|
+
return;
|
|
317
|
+
const { width: w, height: h } = this.graph;
|
|
318
|
+
const scale = h / (BUDGET_MS * 2);
|
|
319
|
+
ctx.clearRect(0, 0, w, h);
|
|
320
|
+
ctx.fillStyle = "rgba(255,255,255,0.18)";
|
|
321
|
+
ctx.fillRect(0, Math.round(h - BUDGET_MS * scale), w, 1);
|
|
322
|
+
const rec = this.session.recorder;
|
|
323
|
+
for (let i = 0; i < BARS; i++) {
|
|
324
|
+
const s = rec.get(BARS - 1 - i);
|
|
325
|
+
if (!s)
|
|
326
|
+
continue;
|
|
327
|
+
const x = i * BAR_W;
|
|
328
|
+
const total = Math.min(h, s.intervalMs * scale);
|
|
329
|
+
const hitch = s.intervalMs > BUDGET_MS * HITCH;
|
|
330
|
+
const late = s.intervalMs > BUDGET_MS * 1.05;
|
|
331
|
+
ctx.fillStyle = hitch ? `${OVER_BUDGET}cc` : late ? `${AMBER}55` : `${LIME}40`;
|
|
332
|
+
ctx.fillRect(x, h - total, BAR_W, total);
|
|
333
|
+
// Work is usually a sliver; give it at least a pixel so it's visible.
|
|
334
|
+
const upd = s.updateMs > 0 ? Math.max(1, Math.min(h, s.updateMs * scale)) : 0;
|
|
335
|
+
const ren = s.renderMs > 0 ? Math.max(1, Math.min(h - upd, s.renderMs * scale)) : 0;
|
|
336
|
+
ctx.fillStyle = LIME;
|
|
337
|
+
ctx.fillRect(x, h - upd, BAR_W, upd);
|
|
338
|
+
ctx.fillStyle = SKY;
|
|
339
|
+
ctx.fillRect(x, h - upd - ren, BAR_W, ren);
|
|
340
|
+
if (this.selected === BARS - 1 - i) {
|
|
341
|
+
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
|
342
|
+
ctx.fillRect(x, 0, BAR_W, h);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
refreshSummary() {
|
|
347
|
+
const sum = summarize(this.session.recorder.recent(BARS), BUDGET_MS);
|
|
348
|
+
if (!sum.frames) {
|
|
349
|
+
this.summaryLine.replaceChildren(line(["waiting for frames…", MUTED]));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const headroom = Math.round(sum.headroom * 100);
|
|
353
|
+
const cause = sum.hitches === 0
|
|
354
|
+
? ["no dropped frames", LIME]
|
|
355
|
+
: sum.gameHitches === 0
|
|
356
|
+
? [`${sum.hitches} dropped, all from outside the game (GC, browser, other tabs)`, AMBER]
|
|
357
|
+
: [`${sum.hitches} dropped, ${sum.gameHitches} from the game's own work`, OVER_BUDGET];
|
|
358
|
+
this.summaryLine.replaceChildren(line("work ", [`${sum.avgWorkMs.toFixed(1)} ms`, LIME], ` avg · ${sum.p95WorkMs.toFixed(1)} p95 · `, [
|
|
359
|
+
`${headroom}% headroom`,
|
|
360
|
+
headroom > 50 ? LIME : headroom > 20 ? AMBER : OVER_BUDGET,
|
|
361
|
+
]), line(`last ${sum.seconds.toFixed(0)} s: `, cause));
|
|
362
|
+
}
|
|
363
|
+
pick(e) {
|
|
364
|
+
const rect = this.graph.getBoundingClientRect();
|
|
365
|
+
const i = Math.floor(((e.clientX - rect.left) / rect.width) * BARS);
|
|
366
|
+
this.selected = Math.max(0, Math.min(BARS - 1, BARS - 1 - i));
|
|
367
|
+
this.drawGraph();
|
|
368
|
+
this.showFrame(this.session.recorder.get(this.selected));
|
|
369
|
+
}
|
|
370
|
+
showFrame(s) {
|
|
371
|
+
if (!s) {
|
|
372
|
+
this.inspector.replaceChildren(line([
|
|
373
|
+
this.session.paused
|
|
374
|
+
? "paused: hover or click bars to inspect frames, or step"
|
|
375
|
+
: "hover the graph to inspect a frame · click to pause there",
|
|
376
|
+
MUTED,
|
|
377
|
+
]));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const hitch = s.intervalMs > BUDGET_MS * HITCH;
|
|
381
|
+
this.inspector.replaceChildren(line(`tick ${s.tick} · ${s.scene ?? "no scene"} · `, [`${s.intervalMs.toFixed(1)} ms`, hitch ? OVER_BUDGET : LIME]), line("", [`update ${s.updateMs.toFixed(2)}`, LIME], " · ", [`render ${s.renderMs.toFixed(2)} ms`, SKY], ` · ${s.steps} step${s.steps === 1 ? "" : "s"}`), line(`${s.quads} quads · ${s.drawCalls} draw calls${s.longTaskMs ? ` · long task ${s.longTaskMs.toFixed(0)} ms` : ""}`), line([diagnose(s, BUDGET_MS), hitch ? AMBER : MUTED]));
|
|
382
|
+
}
|
|
383
|
+
stateText() {
|
|
384
|
+
const scene = this.game.scene;
|
|
385
|
+
const plugins = this.game.inspect.snapshot();
|
|
386
|
+
delete plugins["dev-overlay"];
|
|
387
|
+
return JSON.stringify({
|
|
388
|
+
scene: scene?.id ?? null,
|
|
389
|
+
tick: this.game.clock.tick,
|
|
390
|
+
state: scene?.inspect?.() ?? null,
|
|
391
|
+
...(Object.keys(plugins).length ? { plugins } : {}),
|
|
392
|
+
}, null, 2);
|
|
393
|
+
}
|
|
394
|
+
refreshState() {
|
|
395
|
+
let text;
|
|
396
|
+
try {
|
|
397
|
+
text = this.stateText();
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
text = `inspect() threw: ${err instanceof Error ? err.message : err}`;
|
|
401
|
+
}
|
|
402
|
+
if (!this.game.scene?.inspect)
|
|
403
|
+
text += "\n\n// add inspect() to your scene to see its state here";
|
|
404
|
+
if (this.statePre.textContent !== text)
|
|
405
|
+
this.statePre.textContent = text;
|
|
406
|
+
}
|
|
407
|
+
refreshSession() {
|
|
408
|
+
const { session } = this;
|
|
409
|
+
this.pauseButton.textContent = session.paused ? "play" : "pause";
|
|
410
|
+
this.pauseButton.title = session.paused ? `Paused by ${session.pausedBy}` : "Pause the game loop";
|
|
411
|
+
if (!session.paused)
|
|
412
|
+
this.selected = undefined;
|
|
413
|
+
const errors = session.errors;
|
|
414
|
+
this.errorBadge.hidden = errors.length === 0;
|
|
415
|
+
this.errorBadge.textContent = `${errors.length} error${errors.length === 1 ? "" : "s"}`;
|
|
416
|
+
this.errorBadge.title = "Show errors in the log tab";
|
|
417
|
+
this.errorsBox.replaceChildren(...errors
|
|
418
|
+
.slice(-5)
|
|
419
|
+
.reverse()
|
|
420
|
+
.map((e) => errorCard(e, () => this.session.clearErrors())));
|
|
421
|
+
// Activity log, newest first.
|
|
422
|
+
const entries = session.activity.slice(-30).reverse();
|
|
423
|
+
this.activityBox.replaceChildren(...(entries.length ? [] : [hint("nothing yet: your changes, an agent's and notes show here")]), ...entries.map((a) => {
|
|
424
|
+
const r = row();
|
|
425
|
+
r.style.alignItems = "baseline";
|
|
426
|
+
const who = el("span", {}, a.who);
|
|
427
|
+
who.style.color = a.who === "agent" ? VIOLET : a.who === "spike" ? OVER_BUDGET : LIME;
|
|
428
|
+
const what = el("span", {}, `${a.action}${a.detail ? ` ${a.detail}` : ""}`);
|
|
429
|
+
Object.assign(what.style, { color: TEXT, flex: "1", minWidth: "0", overflowWrap: "anywhere" });
|
|
430
|
+
r.append(who, what);
|
|
431
|
+
if (a.undo) {
|
|
432
|
+
const undo = button("undo", () => {
|
|
433
|
+
a.undo?.();
|
|
434
|
+
delete a.undo;
|
|
435
|
+
session.log("you", "undid", a.detail);
|
|
436
|
+
});
|
|
437
|
+
undo.style.padding = "0 4px";
|
|
438
|
+
r.append(undo);
|
|
439
|
+
}
|
|
440
|
+
return r;
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
buildParams() {
|
|
444
|
+
const described = this.game.params.describe();
|
|
445
|
+
this.knownGroups = Object.keys(described).join(",");
|
|
446
|
+
this.paramsBox.replaceChildren();
|
|
447
|
+
this.inputs.clear();
|
|
448
|
+
let groupIndex = 0;
|
|
449
|
+
for (const [groupId, { schema, values }] of Object.entries(described)) {
|
|
450
|
+
if (groupId.startsWith("dev-"))
|
|
451
|
+
continue;
|
|
452
|
+
const accent = ACCENTS[groupIndex++ % ACCENTS.length] ?? LIME;
|
|
453
|
+
const box = el("details", { open: "" });
|
|
454
|
+
Object.assign(box.style, { borderLeft: `2px solid ${accent}`, paddingLeft: "8px", margin: "0 0 8px" });
|
|
455
|
+
const summary = el("summary", {}, groupId);
|
|
456
|
+
Object.assign(summary.style, { cursor: "pointer", color: accent, margin: "2px 0 4px", fontWeight: "600" });
|
|
457
|
+
box.append(summary);
|
|
458
|
+
for (const [key, spec] of Object.entries(schema))
|
|
459
|
+
box.append(this.paramRow(groupId, key, spec, values[key], accent));
|
|
460
|
+
const actions = row();
|
|
461
|
+
actions.style.margin = "4px 0 2px";
|
|
462
|
+
const copy = button("copy changes", () => navigator.clipboard?.writeText(this.changesText(groupId)));
|
|
463
|
+
copy.title = "Copy the values you changed from their defaults, ready to paste into your params";
|
|
464
|
+
actions.append(button("reset all", () => this.game.params.group(groupId)?.reset()), copy);
|
|
465
|
+
box.append(actions);
|
|
466
|
+
this.paramsBox.append(box);
|
|
467
|
+
}
|
|
468
|
+
if (!this.paramsBox.childElementCount) {
|
|
469
|
+
this.paramsBox.append(hint("define params with game.params.define(...) to tune them here, live"));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
paramRow(groupId, key, spec, value, accent) {
|
|
473
|
+
const id = `${groupId}.${key}`;
|
|
474
|
+
this.defaults.set(id, spec.default);
|
|
475
|
+
const r = el("label");
|
|
476
|
+
Object.assign(r.style, { display: "grid", gridTemplateColumns: "1fr 1.4fr 3.5em", gap: "6px", alignItems: "center" });
|
|
477
|
+
const units = spec.type === "number" && spec.units ? ` (${spec.units})` : "";
|
|
478
|
+
const name = el("span", { title: `${spec.description ?? id}${units}\ndefault ${String(spec.default)} · double-click to reset` }, key);
|
|
479
|
+
name.style.cursor = "default";
|
|
480
|
+
name.addEventListener("dblclick", (e) => {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
this.session.setParam(groupId, key, spec.default, "you");
|
|
483
|
+
});
|
|
484
|
+
const input = el("input", { "data-param": id });
|
|
485
|
+
const readout = el("span");
|
|
486
|
+
readout.style.textAlign = "right";
|
|
487
|
+
if (spec.type === "boolean") {
|
|
488
|
+
input.type = "checkbox";
|
|
489
|
+
input.checked = value;
|
|
490
|
+
input.addEventListener("change", () => this.set(groupId, key, input.checked));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
Object.assign(input, rangeFor(spec, value));
|
|
494
|
+
input.style.accentColor = accent;
|
|
495
|
+
// Remember the value a drag starts from, so the whole drag becomes one undoable change.
|
|
496
|
+
const remember = () => {
|
|
497
|
+
if (!this.dragStart.has(id))
|
|
498
|
+
this.dragStart.set(id, this.game.params.group(groupId)?.get(key) ?? value);
|
|
499
|
+
};
|
|
500
|
+
input.addEventListener("pointerdown", remember);
|
|
501
|
+
input.addEventListener("focus", remember);
|
|
502
|
+
input.addEventListener("input", () => {
|
|
503
|
+
remember();
|
|
504
|
+
this.set(groupId, key, Number(input.value), true);
|
|
505
|
+
});
|
|
506
|
+
input.addEventListener("change", () => this.set(groupId, key, Number(input.value)));
|
|
507
|
+
// Arrow keys on a focused slider must tune it, not drive the game.
|
|
508
|
+
for (const type of ["keydown", "keyup"])
|
|
509
|
+
input.addEventListener(type, (e) => e.stopPropagation());
|
|
510
|
+
}
|
|
511
|
+
this.inputs.set(id, input);
|
|
512
|
+
r.append(name, input, readout);
|
|
513
|
+
this.markChanged(id, value);
|
|
514
|
+
return r;
|
|
515
|
+
}
|
|
516
|
+
/** Changed-from-default values get an amber name and readout, so you can see what you've tuned. */
|
|
517
|
+
markChanged(id, value) {
|
|
518
|
+
const input = this.inputs.get(id);
|
|
519
|
+
if (!input)
|
|
520
|
+
return;
|
|
521
|
+
const name = input.previousElementSibling;
|
|
522
|
+
const readout = input.nextElementSibling;
|
|
523
|
+
const changed = value !== this.defaults.get(id);
|
|
524
|
+
if (name)
|
|
525
|
+
name.style.color = changed ? AMBER : "";
|
|
526
|
+
if (readout) {
|
|
527
|
+
readout.textContent = typeof value === "number" ? format(value) : "";
|
|
528
|
+
readout.style.color = changed ? AMBER : "";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
changesText(groupId) {
|
|
532
|
+
const group = this.game.params.describe()[groupId];
|
|
533
|
+
if (!group)
|
|
534
|
+
return "";
|
|
535
|
+
const changed = Object.entries(group.values).filter(([k, v]) => v !== group.schema[k]?.default);
|
|
536
|
+
if (!changed.length)
|
|
537
|
+
return `// ${groupId}: all defaults`;
|
|
538
|
+
return changed
|
|
539
|
+
.map(([k, v]) => `${k}: { default: ${JSON.stringify(v)} }, // was ${JSON.stringify(group.schema[k]?.default)}`)
|
|
540
|
+
.join("\n");
|
|
541
|
+
}
|
|
542
|
+
/** Slider drags update live without logging; the final value is logged once (with undo). */
|
|
543
|
+
set(group, key, value, live = false) {
|
|
544
|
+
try {
|
|
545
|
+
if (live)
|
|
546
|
+
this.game.params.group(group)?.set(key, value);
|
|
547
|
+
else
|
|
548
|
+
this.commitSet(group, key, value);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
console.warn(`[debuff overlay] ${err instanceof Error ? err.message : err}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
commitSet(group, key, value) {
|
|
555
|
+
const id = `${group}.${key}`;
|
|
556
|
+
const start = this.dragStart.get(id);
|
|
557
|
+
this.dragStart.delete(id);
|
|
558
|
+
// Put the pre-drag value back first so the session records one change with a correct undo.
|
|
559
|
+
if (start !== undefined)
|
|
560
|
+
this.game.params.group(group)?.set(key, start);
|
|
561
|
+
this.session.setParam(group, key, value, "you");
|
|
562
|
+
}
|
|
563
|
+
/** Reflect changes made elsewhere (console, agents, scenarios) in the controls. */
|
|
564
|
+
sync(id, value) {
|
|
565
|
+
const input = this.inputs.get(id);
|
|
566
|
+
if (!input)
|
|
567
|
+
return;
|
|
568
|
+
if (input.type === "checkbox")
|
|
569
|
+
input.checked = value;
|
|
570
|
+
else
|
|
571
|
+
input.value = String(value);
|
|
572
|
+
this.markChanged(id, value);
|
|
573
|
+
}
|
|
574
|
+
saveReplay(copy) {
|
|
575
|
+
const recording = this.session.recording;
|
|
576
|
+
if (!recording) {
|
|
577
|
+
this.session.log("you", "can't save a replay", "pass { input } to devTools() to record");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const replay = recording.toReplay();
|
|
581
|
+
const json = `${JSON.stringify({ name: replay.name, steps: replay.steps }, null, 2)}\n`;
|
|
582
|
+
if (copy)
|
|
583
|
+
void navigator.clipboard?.writeText(json);
|
|
584
|
+
else {
|
|
585
|
+
const a = el("a", {
|
|
586
|
+
href: URL.createObjectURL(new Blob([json], { type: "application/json" })),
|
|
587
|
+
download: `replay-tick-${replay.ticks}.json`,
|
|
588
|
+
});
|
|
589
|
+
a.click();
|
|
590
|
+
setTimeout(() => URL.revokeObjectURL(a.getAttribute("href") ?? ""), 1000);
|
|
591
|
+
}
|
|
592
|
+
const note = replay.warnings.length ? ` (approximate: ${replay.warnings.join(" ")})` : "";
|
|
593
|
+
this.session.log("you", copy ? "copied a replay" : "saved a replay", `${replay.ticks} ticks${note}`);
|
|
594
|
+
}
|
|
595
|
+
snapshot() {
|
|
596
|
+
// Re-render without advancing time: WebGL clears its buffer after presenting, so read in the same task.
|
|
597
|
+
this.game.redraw();
|
|
598
|
+
const a = el("a", {
|
|
599
|
+
href: this.game.canvas.toDataURL("image/png"),
|
|
600
|
+
download: `debuff-${this.game.scene?.id ?? "game"}-${this.game.clock.tick}.png`,
|
|
601
|
+
});
|
|
602
|
+
a.click();
|
|
603
|
+
this.session.log("you", "saved a snapshot", `tick ${this.game.clock.tick}`);
|
|
604
|
+
}
|
|
605
|
+
restartScene() {
|
|
606
|
+
const id = this.game.scene?.id;
|
|
607
|
+
if (!id)
|
|
608
|
+
return;
|
|
609
|
+
this.game.go(id);
|
|
610
|
+
this.session.log("you", "restarted", id);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const KIND = {
|
|
614
|
+
"your-code": ["your code", AMBER],
|
|
615
|
+
"debuff-usage": ["debuff rejected a call", SKY],
|
|
616
|
+
"debuff-bug": ["debuff bug", OVER_BUDGET],
|
|
617
|
+
outside: ["outside the game", MUTED],
|
|
618
|
+
};
|
|
619
|
+
/** One error, explained: whose problem it is, where to look (click to open in your editor), what to try. */
|
|
620
|
+
function errorCard(e, clear) {
|
|
621
|
+
const [label, color] = KIND[e.kind];
|
|
622
|
+
const card = el("div", { "data-error-kind": e.kind });
|
|
623
|
+
Object.assign(card.style, {
|
|
624
|
+
margin: "0 0 6px",
|
|
625
|
+
padding: "5px 7px",
|
|
626
|
+
borderLeft: `2px solid ${color}`,
|
|
627
|
+
background: `${color}12`,
|
|
628
|
+
borderRadius: "4px",
|
|
629
|
+
});
|
|
630
|
+
const head = row();
|
|
631
|
+
head.append(chip(label, color));
|
|
632
|
+
if (e.count > 1)
|
|
633
|
+
head.append(hint(`×${e.count}`));
|
|
634
|
+
if (e.phase)
|
|
635
|
+
head.append(hint(`in ${e.phase}${e.tick === undefined ? "" : ` · tick ${e.tick}`}`));
|
|
636
|
+
const msg = el("div", {}, `${e.name}: ${e.message}`);
|
|
637
|
+
Object.assign(msg.style, { color: "#fecaca", margin: "3px 0", overflowWrap: "anywhere" });
|
|
638
|
+
card.append(head, msg);
|
|
639
|
+
if (e.where) {
|
|
640
|
+
const where = loc(e.where);
|
|
641
|
+
// Vite's dev server opens files in your editor at this endpoint.
|
|
642
|
+
const link = el("a", { href: `/__open-in-editor?file=${encodeURIComponent(where)}`, title: "Open in your editor" }, `→ ${where}${e.where.fn ? ` (${e.where.fn})` : ""}`);
|
|
643
|
+
Object.assign(link.style, { color: SKY, textDecoration: "none", display: "block" });
|
|
644
|
+
link.addEventListener("click", (ev) => {
|
|
645
|
+
ev.preventDefault();
|
|
646
|
+
void fetch(link.getAttribute("href") ?? "");
|
|
647
|
+
});
|
|
648
|
+
card.append(link);
|
|
649
|
+
}
|
|
650
|
+
const tip = el("div", {}, e.hint);
|
|
651
|
+
Object.assign(tip.style, { color: TEXT, margin: "3px 0" });
|
|
652
|
+
card.append(tip);
|
|
653
|
+
const actions = row();
|
|
654
|
+
if (e.reportUrl) {
|
|
655
|
+
const report = el("a", { href: e.reportUrl, target: "_blank", rel: "noopener" }, "report to debuff");
|
|
656
|
+
Object.assign(report.style, { color: OVER_BUDGET });
|
|
657
|
+
actions.append(report);
|
|
658
|
+
}
|
|
659
|
+
const copy = button("copy for agent", () => navigator.clipboard?.writeText(JSON.stringify(e, null, 2)));
|
|
660
|
+
copy.title = "Copy the explained error as JSON to paste into a chat with an agent";
|
|
661
|
+
actions.append(copy, button("clear", clear));
|
|
662
|
+
card.append(actions);
|
|
663
|
+
return card;
|
|
664
|
+
}
|
|
665
|
+
function rangeFor(spec, value) {
|
|
666
|
+
// Without bounds, offer a range around the current value.
|
|
667
|
+
const min = spec.min ?? Math.min(0, value * 2);
|
|
668
|
+
const max = spec.max ?? Math.max(1, Math.abs(value) * 2);
|
|
669
|
+
const span = max - min;
|
|
670
|
+
const step = span <= 1 ? 0.01 : span <= 20 ? 0.1 : 1;
|
|
671
|
+
return { type: "range", min: String(min), max: String(max), step: String(step), value: String(value) };
|
|
672
|
+
}
|
|
673
|
+
function format(n) {
|
|
674
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
675
|
+
}
|
|
676
|
+
const COLLAPSED_KEY = "debuff:dev-overlay:collapsed";
|
|
677
|
+
const TAB_KEY = "debuff:dev-overlay:tab";
|
|
678
|
+
function readCollapsed() {
|
|
679
|
+
return load(COLLAPSED_KEY) === "1";
|
|
680
|
+
}
|
|
681
|
+
function readTab() {
|
|
682
|
+
const t = load(TAB_KEY);
|
|
683
|
+
return TABS.includes(t ?? "") ? t : "perf";
|
|
684
|
+
}
|
|
685
|
+
function load(key) {
|
|
686
|
+
try {
|
|
687
|
+
return localStorage.getItem(key);
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function store(key, value) {
|
|
694
|
+
try {
|
|
695
|
+
localStorage.setItem(key, value);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// Storage blocked: the state just isn't remembered.
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function el(tag, attrs = {}, text) {
|
|
702
|
+
const node = document.createElement(tag);
|
|
703
|
+
for (const [k, v] of Object.entries(attrs))
|
|
704
|
+
node.setAttribute(k, v);
|
|
705
|
+
if (text !== undefined)
|
|
706
|
+
node.textContent = text;
|
|
707
|
+
return node;
|
|
708
|
+
}
|
|
709
|
+
function row() {
|
|
710
|
+
const r = el("div");
|
|
711
|
+
Object.assign(r.style, { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap" });
|
|
712
|
+
return r;
|
|
713
|
+
}
|
|
714
|
+
function hint(text) {
|
|
715
|
+
const h = el("span", {}, text);
|
|
716
|
+
h.style.color = MUTED;
|
|
717
|
+
return h;
|
|
718
|
+
}
|
|
719
|
+
function chip(text, color) {
|
|
720
|
+
const c = el("span", {}, text);
|
|
721
|
+
Object.assign(c.style, { padding: "1px 6px", borderRadius: "999px", background: `${color}22`, color, whiteSpace: "nowrap" });
|
|
722
|
+
return c;
|
|
723
|
+
}
|
|
724
|
+
/** One line from plain strings and [text, colour] pairs. */
|
|
725
|
+
function line(...parts) {
|
|
726
|
+
const div = el("div");
|
|
727
|
+
for (const p of parts) {
|
|
728
|
+
if (typeof p === "string")
|
|
729
|
+
div.append(document.createTextNode(p));
|
|
730
|
+
else {
|
|
731
|
+
const span = el("span", {}, p[0]);
|
|
732
|
+
span.style.color = p[1];
|
|
733
|
+
div.append(span);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return div;
|
|
737
|
+
}
|
|
738
|
+
function inputStyle() {
|
|
739
|
+
return {
|
|
740
|
+
font: "inherit",
|
|
741
|
+
background: "#1a2010",
|
|
742
|
+
color: "#e7ebe0",
|
|
743
|
+
border: `1px solid ${LIME}40`,
|
|
744
|
+
borderRadius: "4px",
|
|
745
|
+
padding: "1px 4px",
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function button(text, onClick) {
|
|
749
|
+
const b = el("button", { type: "button" }, text);
|
|
750
|
+
Object.assign(b.style, {
|
|
751
|
+
font: "inherit",
|
|
752
|
+
background: "#1a2010",
|
|
753
|
+
color: LIME,
|
|
754
|
+
border: `1px solid ${LIME}55`,
|
|
755
|
+
borderRadius: "4px",
|
|
756
|
+
padding: "1px 6px",
|
|
757
|
+
cursor: "pointer",
|
|
758
|
+
});
|
|
759
|
+
b.addEventListener("click", onClick);
|
|
760
|
+
return b;
|
|
761
|
+
}
|
|
762
|
+
function panelStyle(position) {
|
|
763
|
+
const [v, h] = position.split("-");
|
|
764
|
+
return {
|
|
765
|
+
position: "fixed",
|
|
766
|
+
[v]: "8px",
|
|
767
|
+
[h]: "8px",
|
|
768
|
+
zIndex: "2147483647",
|
|
769
|
+
width: "290px",
|
|
770
|
+
maxHeight: "calc(100vh - 16px)",
|
|
771
|
+
overflow: "auto",
|
|
772
|
+
padding: "8px 10px",
|
|
773
|
+
background: "rgba(10, 12, 8, 0.92)",
|
|
774
|
+
color: "#e5e7eb",
|
|
775
|
+
font: "11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
776
|
+
border: `1px solid ${LIME}40`,
|
|
777
|
+
borderRadius: "6px",
|
|
778
|
+
backdropFilter: "blur(4px)",
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/** buff, the debuff mascot, drawn with DOM: a lime block with eyes that blinks and reacts to dropped frames. */
|
|
782
|
+
class Buff {
|
|
783
|
+
root;
|
|
784
|
+
eyes;
|
|
785
|
+
current = "calm";
|
|
786
|
+
constructor() {
|
|
787
|
+
this.root = el("span", { "aria-hidden": "true" });
|
|
788
|
+
Object.assign(this.root.style, {
|
|
789
|
+
position: "relative",
|
|
790
|
+
display: "inline-block",
|
|
791
|
+
width: "16px",
|
|
792
|
+
height: "14px",
|
|
793
|
+
borderRadius: "3px",
|
|
794
|
+
background: LIME,
|
|
795
|
+
boxShadow: "inset 0 3px 0 #e7fd96",
|
|
796
|
+
animation: "debuff-buff-hop 1.2s ease-in-out infinite",
|
|
797
|
+
transformOrigin: "50% 100%",
|
|
798
|
+
flex: "none",
|
|
799
|
+
});
|
|
800
|
+
this.eyes = [3, 9].map((left) => {
|
|
801
|
+
const eye = el("span");
|
|
802
|
+
Object.assign(eye.style, {
|
|
803
|
+
position: "absolute",
|
|
804
|
+
left: `${left}px`,
|
|
805
|
+
top: "5px",
|
|
806
|
+
width: "3px",
|
|
807
|
+
height: "4px",
|
|
808
|
+
borderRadius: "1px",
|
|
809
|
+
background: "#11140d",
|
|
810
|
+
animation: "debuff-buff-blink 3.3s infinite",
|
|
811
|
+
});
|
|
812
|
+
this.root.append(eye);
|
|
813
|
+
return eye;
|
|
814
|
+
});
|
|
815
|
+
if (!document.getElementById("debuff-buff-style")) {
|
|
816
|
+
const style = el("style", { id: "debuff-buff-style" });
|
|
817
|
+
style.textContent = `
|
|
818
|
+
@keyframes debuff-buff-hop { 0%,100% { transform: translateY(0) scale(1.08, .92) } 50% { transform: translateY(-2px) scale(.96, 1.04) } }
|
|
819
|
+
@keyframes debuff-buff-blink { 0%,46%,50%,100% { transform: scaleY(1) } 48% { transform: scaleY(.1) } }
|
|
820
|
+
@media (prefers-reduced-motion: reduce) { [data-debuff-overlay] * { animation: none !important } }`;
|
|
821
|
+
document.head.append(style);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
mood(next) {
|
|
825
|
+
if (next === this.current)
|
|
826
|
+
return;
|
|
827
|
+
this.current = next;
|
|
828
|
+
for (const eye of this.eyes)
|
|
829
|
+
eye.style.background = next === "worried" ? OVER_BUDGET : "#11140d";
|
|
830
|
+
this.root.style.animationDuration = next === "worried" ? "0.4s" : "1.2s";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
//# sourceMappingURL=overlay.js.map
|