@oss-idle/addon-sdk 0.0.0-stage → 0.5.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/LICENSE +661 -0
- package/README.md +222 -2
- package/bin/cli.mjs +136 -0
- package/bin/scaffold.mjs +154 -0
- package/dist/bridge/beatmaps.d.ts +57 -0
- package/dist/bridge/beatmaps.d.ts.map +1 -0
- package/dist/bridge/beatmaps.js +65 -0
- package/dist/bridge/character.d.ts +91 -0
- package/dist/bridge/character.d.ts.map +1 -0
- package/dist/bridge/character.js +107 -0
- package/dist/bridge/db.d.ts +44 -0
- package/dist/bridge/db.d.ts.map +1 -0
- package/dist/bridge/db.js +83 -0
- package/dist/bridge/playlists.d.ts +55 -0
- package/dist/bridge/playlists.d.ts.map +1 -0
- package/dist/bridge/playlists.js +85 -0
- package/dist/bridge/scores.d.ts +28 -0
- package/dist/bridge/scores.d.ts.map +1 -0
- package/dist/bridge/scores.js +43 -0
- package/dist/build/index.d.ts +60 -0
- package/dist/build/index.d.ts.map +1 -0
- package/dist/build/index.js +123 -0
- package/dist/build/plugins.d.ts +88 -0
- package/dist/build/plugins.d.ts.map +1 -0
- package/dist/build/plugins.js +213 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/overlay.d.ts +56 -0
- package/dist/react/overlay.d.ts.map +1 -0
- package/dist/react/overlay.js +63 -0
- package/dist/react/reactAddon.d.ts +37 -0
- package/dist/react/reactAddon.d.ts.map +1 -0
- package/dist/react/reactAddon.js +51 -0
- package/dist/react/useSettings.d.ts +16 -0
- package/dist/react/useSettings.d.ts.map +1 -0
- package/dist/react/useSettings.js +30 -0
- package/dist/runtime/addon.d.ts +83 -0
- package/dist/runtime/addon.d.ts.map +1 -0
- package/dist/runtime/addon.js +108 -0
- package/dist/runtime/buildMeta.d.ts +11 -0
- package/dist/runtime/buildMeta.d.ts.map +1 -0
- package/dist/runtime/buildMeta.js +1 -0
- package/dist/runtime/define.d.ts +23 -0
- package/dist/runtime/define.d.ts.map +1 -0
- package/dist/runtime/define.js +37 -0
- package/dist/runtime/input.d.ts +58 -0
- package/dist/runtime/input.d.ts.map +1 -0
- package/dist/runtime/input.js +119 -0
- package/dist/runtime/settings.d.ts +36 -0
- package/dist/runtime/settings.d.ts.map +1 -0
- package/dist/runtime/settings.js +74 -0
- package/package.json +83 -4
- package/templates/common/README.md +31 -0
- package/templates/common/addon.config.json +6 -0
- package/templates/common/gitignore +3 -0
- package/templates/common/src/env.d.ts +17 -0
- package/templates/common/tsconfig.json +14 -0
- package/templates/plain/src/index.ts +32 -0
- package/templates/react/src/Addon.tsx +44 -0
- package/templates/react/src/Panel.tsx +41 -0
- package/templates/react/src/index.ts +12 -0
- package/templates/react/src/styles.css +61 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A React add-on.
|
|
3
|
+
*
|
|
4
|
+
* Bundles the two things a React overlay always needs - a shadow-rooted host
|
|
5
|
+
* and a React 18 root - and ties both to the add-on lifecycle, so disabling the
|
|
6
|
+
* add-on unmounts the tree and removes the host in one step.
|
|
7
|
+
*
|
|
8
|
+
* React itself is bundled into the add-on's single emitted file. Two add-ons
|
|
9
|
+
* each carrying their own React is fine: they render into separate roots inside
|
|
10
|
+
* separate shadow trees and never share a reconciler.
|
|
11
|
+
*/
|
|
12
|
+
import { createRoot } from 'react-dom/client';
|
|
13
|
+
import { Addon } from '../runtime/addon.js';
|
|
14
|
+
import { HotkeyManager } from '../runtime/input.js';
|
|
15
|
+
import { createOverlay } from './overlay.js';
|
|
16
|
+
export class ReactAddon extends Addon {
|
|
17
|
+
overlay;
|
|
18
|
+
root;
|
|
19
|
+
/** Global shortcuts, bound ahead of the game's own handlers. */
|
|
20
|
+
hotkeys = new HotkeyManager();
|
|
21
|
+
reactMeta;
|
|
22
|
+
constructor(meta) {
|
|
23
|
+
super(meta);
|
|
24
|
+
this.reactMeta = meta;
|
|
25
|
+
}
|
|
26
|
+
async onMount() {
|
|
27
|
+
const id = this.reactMeta.overlayId
|
|
28
|
+
?? `addon-${this.reactMeta.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
29
|
+
this.overlay = createOverlay({ id, styles: this.reactMeta.styles });
|
|
30
|
+
this.root = createRoot(this.overlay.container);
|
|
31
|
+
this.root.render(this.render());
|
|
32
|
+
this.own(() => this.hotkeys.dispose());
|
|
33
|
+
await this.onReady();
|
|
34
|
+
}
|
|
35
|
+
/** Hook for work that needs the overlay to exist (shortcuts, data loading). */
|
|
36
|
+
onReady() { }
|
|
37
|
+
/** Re-render the tree - for state the add-on holds outside React. */
|
|
38
|
+
refresh() {
|
|
39
|
+
this.root?.render(this.render());
|
|
40
|
+
}
|
|
41
|
+
async onUnmount() {
|
|
42
|
+
// React 18 warns if a root is unmounted while it is rendering, which is
|
|
43
|
+
// possible here because the runtime can disable an add-on at any moment.
|
|
44
|
+
const root = this.root;
|
|
45
|
+
this.root = undefined;
|
|
46
|
+
if (root)
|
|
47
|
+
await Promise.resolve().then(() => root.unmount());
|
|
48
|
+
this.overlay?.dispose();
|
|
49
|
+
this.overlay = undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React binding for a {@link SettingsStore}.
|
|
3
|
+
*
|
|
4
|
+
* Loads once on mount and writes back whenever a setting changes, so the values
|
|
5
|
+
* a player picked are there the next time the overlay opens.
|
|
6
|
+
*/
|
|
7
|
+
import type { SettingsStore } from '../runtime/settings.js';
|
|
8
|
+
export type SettingsHandle<T extends object> = [
|
|
9
|
+
settings: T,
|
|
10
|
+
/** Merge a patch into the settings and persist. */
|
|
11
|
+
update: (patch: Partial<T>) => void,
|
|
12
|
+
/** Restore the defaults. */
|
|
13
|
+
reset: () => void
|
|
14
|
+
];
|
|
15
|
+
export declare const usePersistedSettings: <T extends object>(store: SettingsStore<T>, defaults: T) => SettingsHandle<T>;
|
|
16
|
+
//# sourceMappingURL=useSettings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSettings.d.ts","sourceRoot":"","sources":["../../src/react/useSettings.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAE5D,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI;IAC9C,QAAQ,EAAE,CAAC;IACX,mDAAmD;IACnD,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI;IACnC,4BAA4B;IAC5B,KAAK,EAAE,MAAM,IAAI;CACjB,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,CAAC,SAAS,MAAM,EACpD,OAAO,aAAa,CAAC,CAAC,CAAC,EACvB,UAAU,CAAC,KACT,cAAc,CAAC,CAAC,CA2BlB,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React binding for a {@link SettingsStore}.
|
|
3
|
+
*
|
|
4
|
+
* Loads once on mount and writes back whenever a setting changes, so the values
|
|
5
|
+
* a player picked are there the next time the overlay opens.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
|
+
export const usePersistedSettings = (store, defaults) => {
|
|
9
|
+
// Loaded in the initialiser so the first render already has the stored
|
|
10
|
+
// values - a later effect would flash the defaults first.
|
|
11
|
+
const [settings, setSettings] = useState(() => store.load());
|
|
12
|
+
const latest = useRef(settings);
|
|
13
|
+
latest.current = settings;
|
|
14
|
+
const update = useCallback((patch) => {
|
|
15
|
+
const next = { ...latest.current, ...patch };
|
|
16
|
+
latest.current = next;
|
|
17
|
+
setSettings(next);
|
|
18
|
+
store.save(next);
|
|
19
|
+
}, [store]);
|
|
20
|
+
const reset = useCallback(() => {
|
|
21
|
+
const next = { ...defaults };
|
|
22
|
+
latest.current = next;
|
|
23
|
+
setSettings(next);
|
|
24
|
+
store.clear();
|
|
25
|
+
}, [store, defaults]);
|
|
26
|
+
// Persist whatever the component settled on when it goes away, covering a
|
|
27
|
+
// value changed through some path other than `update`.
|
|
28
|
+
useEffect(() => () => store.save(latest.current), [store]);
|
|
29
|
+
return [settings, update, reset];
|
|
30
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The add-on contract.
|
|
3
|
+
*
|
|
4
|
+
* oss!idle loads an add-on by turning its `source` into a Blob URL and
|
|
5
|
+
* `import()`ing it in the page realm, then calling the module's `mount` /
|
|
6
|
+
* `unmount` exports - that pair *is* the contract, and nothing else is passed
|
|
7
|
+
* in. Everything a useful add-on needs (the game's database, its beatmap store,
|
|
8
|
+
* the DOM) it reaches for itself, because it runs unsandboxed on the page.
|
|
9
|
+
*
|
|
10
|
+
* Two consequences shape this class:
|
|
11
|
+
*
|
|
12
|
+
* - **Nothing resolves at load time.** The module is a Blob, so it has no base
|
|
13
|
+
* URL and no import map: every dependency, React included, must already be
|
|
14
|
+
* inside the single emitted file. That is the bundler's job (`./build`).
|
|
15
|
+
* - **`unmount` must actually undo everything.** Add-ons are enabled, disabled,
|
|
16
|
+
* updated and uninstalled without a page reload, so a listener or timer left
|
|
17
|
+
* behind leaks into a game the player keeps playing. {@link Addon} therefore
|
|
18
|
+
* hands out registration helpers that record their own teardown, and reverses
|
|
19
|
+
* them for you.
|
|
20
|
+
*/
|
|
21
|
+
/** A teardown returned by a registration; running it undoes that registration. */
|
|
22
|
+
export type Disposer = () => void;
|
|
23
|
+
export type AddonMeta = {
|
|
24
|
+
/** Human name, used in log lines and error surfaces. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The add-on's own semver. Defaults to the version the bundler stamped in. */
|
|
27
|
+
version?: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Base class for an add-on. Subclass it, implement {@link onMount}, and export
|
|
31
|
+
* the pair the runtime wants with {@link defineAddon}.
|
|
32
|
+
*
|
|
33
|
+
* Anything registered through `this.own`, `this.on`, `this.interval` or
|
|
34
|
+
* `this.timeout` is torn down in reverse order on unmount, so `onUnmount` is
|
|
35
|
+
* only for work those helpers cannot express.
|
|
36
|
+
*/
|
|
37
|
+
export declare abstract class Addon {
|
|
38
|
+
protected readonly meta: AddonMeta;
|
|
39
|
+
/** Registered teardowns, newest first when reversed at unmount. */
|
|
40
|
+
private disposers;
|
|
41
|
+
private mounted;
|
|
42
|
+
constructor(meta: AddonMeta);
|
|
43
|
+
/** Runs when the player enables the add-on (and once per boot thereafter). */
|
|
44
|
+
protected abstract onMount(): void | Promise<void>;
|
|
45
|
+
/** Optional extra teardown, run *before* the registered disposers unwind. */
|
|
46
|
+
protected onUnmount(): void | Promise<void>;
|
|
47
|
+
/** Record a teardown to run at unmount. Returns it, so it can also be run early. */
|
|
48
|
+
protected own<T extends Disposer>(dispose: T): T;
|
|
49
|
+
/**
|
|
50
|
+
* Add a DOM listener that is removed at unmount. Defaults to the capture
|
|
51
|
+
* phase, which is what an overlay wants: the game binds its own handlers on
|
|
52
|
+
* `window` in the bubble phase, so capturing lets an overlay claim an event
|
|
53
|
+
* before gameplay ever sees it.
|
|
54
|
+
*/
|
|
55
|
+
protected on<K extends keyof WindowEventMap>(target: Window, type: K, handler: (event: WindowEventMap[K]) => void, options?: AddEventListenerOptions): Disposer;
|
|
56
|
+
protected on(target: EventTarget, type: string, handler: EventListenerOrEventListenerObject, options?: AddEventListenerOptions): Disposer;
|
|
57
|
+
/** `setInterval` that is cleared at unmount. */
|
|
58
|
+
protected interval(handler: () => void, ms: number): Disposer;
|
|
59
|
+
/** `setTimeout` that is cleared at unmount if it has not fired yet. */
|
|
60
|
+
protected timeout(handler: () => void, ms: number): Disposer;
|
|
61
|
+
/** Prefixed console logger, so a player's console says which add-on spoke. */
|
|
62
|
+
protected get log(): {
|
|
63
|
+
info: (...args: unknown[]) => void;
|
|
64
|
+
warn: (...args: unknown[]) => void;
|
|
65
|
+
error: (...args: unknown[]) => void;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Internal: called by {@link defineAddon}, not by add-on code.
|
|
69
|
+
*
|
|
70
|
+
* Public only because `defineAddon` lives outside the class and TypeScript
|
|
71
|
+
* has no package-private visibility; the underscore marks it as off-limits.
|
|
72
|
+
*/
|
|
73
|
+
_mount(): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Internal: called by {@link defineAddon}, not by add-on code.
|
|
76
|
+
*
|
|
77
|
+
* Every teardown runs even if an earlier one throws - a half-unmounted
|
|
78
|
+
* add-on that keeps a key handler alive would break the game far more
|
|
79
|
+
* visibly than a logged error here.
|
|
80
|
+
*/
|
|
81
|
+
_unmount(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=addon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"addon.d.ts","sourceRoot":"","sources":["../../src/runtime/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC;AAElC,MAAM,MAAM,SAAS,GAAG;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,8BAAsB,KAAK;IAE1B,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAEnC,mEAAmE;IACnE,OAAO,CAAC,SAAS,CAAkB;IAEnC,OAAO,CAAC,OAAO,CAAS;gBAEZ,IAAI,EAAE,SAAS;IAI3B,8EAA8E;IAC9E,SAAS,CAAC,QAAQ,CAAC,OAAO,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAElD,6EAA6E;IAC7E,SAAS,CAAC,SAAS,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE3C,oFAAoF;IACpF,SAAS,CAAC,GAAG,CAAC,CAAC,SAAS,QAAQ,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC;IAKhD;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,SAAS,MAAM,cAAc,EAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,KAAK,IAAI,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IACX,SAAS,CAAC,EAAE,CACX,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,kCAAkC,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IAWX,gDAAgD;IAChD,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK7D,uEAAuE;IACvE,SAAS,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK5D,8EAA8E;IAC9E,SAAS,KAAK,GAAG;wBAGC,OAAO,EAAE;wBACT,OAAO,EAAE;yBACR,OAAO,EAAE;MAE3B;IAED;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAM7B;;;;;;OAMG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAkB/B"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The add-on contract.
|
|
3
|
+
*
|
|
4
|
+
* oss!idle loads an add-on by turning its `source` into a Blob URL and
|
|
5
|
+
* `import()`ing it in the page realm, then calling the module's `mount` /
|
|
6
|
+
* `unmount` exports - that pair *is* the contract, and nothing else is passed
|
|
7
|
+
* in. Everything a useful add-on needs (the game's database, its beatmap store,
|
|
8
|
+
* the DOM) it reaches for itself, because it runs unsandboxed on the page.
|
|
9
|
+
*
|
|
10
|
+
* Two consequences shape this class:
|
|
11
|
+
*
|
|
12
|
+
* - **Nothing resolves at load time.** The module is a Blob, so it has no base
|
|
13
|
+
* URL and no import map: every dependency, React included, must already be
|
|
14
|
+
* inside the single emitted file. That is the bundler's job (`./build`).
|
|
15
|
+
* - **`unmount` must actually undo everything.** Add-ons are enabled, disabled,
|
|
16
|
+
* updated and uninstalled without a page reload, so a listener or timer left
|
|
17
|
+
* behind leaks into a game the player keeps playing. {@link Addon} therefore
|
|
18
|
+
* hands out registration helpers that record their own teardown, and reverses
|
|
19
|
+
* them for you.
|
|
20
|
+
*/
|
|
21
|
+
import { buildMeta } from './buildMeta.js';
|
|
22
|
+
/**
|
|
23
|
+
* Base class for an add-on. Subclass it, implement {@link onMount}, and export
|
|
24
|
+
* the pair the runtime wants with {@link defineAddon}.
|
|
25
|
+
*
|
|
26
|
+
* Anything registered through `this.own`, `this.on`, `this.interval` or
|
|
27
|
+
* `this.timeout` is torn down in reverse order on unmount, so `onUnmount` is
|
|
28
|
+
* only for work those helpers cannot express.
|
|
29
|
+
*/
|
|
30
|
+
export class Addon {
|
|
31
|
+
meta;
|
|
32
|
+
/** Registered teardowns, newest first when reversed at unmount. */
|
|
33
|
+
disposers = [];
|
|
34
|
+
mounted = false;
|
|
35
|
+
constructor(meta) {
|
|
36
|
+
this.meta = { ...meta, version: meta.version ?? buildMeta().version };
|
|
37
|
+
}
|
|
38
|
+
/** Optional extra teardown, run *before* the registered disposers unwind. */
|
|
39
|
+
onUnmount() { }
|
|
40
|
+
/** Record a teardown to run at unmount. Returns it, so it can also be run early. */
|
|
41
|
+
own(dispose) {
|
|
42
|
+
this.disposers.push(dispose);
|
|
43
|
+
return dispose;
|
|
44
|
+
}
|
|
45
|
+
on(target, type, handler, options = { capture: true }) {
|
|
46
|
+
target.addEventListener(type, handler, options);
|
|
47
|
+
return this.own(() => target.removeEventListener(type, handler, options));
|
|
48
|
+
}
|
|
49
|
+
/** `setInterval` that is cleared at unmount. */
|
|
50
|
+
interval(handler, ms) {
|
|
51
|
+
const id = window.setInterval(handler, ms);
|
|
52
|
+
return this.own(() => window.clearInterval(id));
|
|
53
|
+
}
|
|
54
|
+
/** `setTimeout` that is cleared at unmount if it has not fired yet. */
|
|
55
|
+
timeout(handler, ms) {
|
|
56
|
+
const id = window.setTimeout(handler, ms);
|
|
57
|
+
return this.own(() => window.clearTimeout(id));
|
|
58
|
+
}
|
|
59
|
+
/** Prefixed console logger, so a player's console says which add-on spoke. */
|
|
60
|
+
get log() {
|
|
61
|
+
const tag = `[${this.meta.name}]`;
|
|
62
|
+
return {
|
|
63
|
+
info: (...args) => console.log(tag, ...args),
|
|
64
|
+
warn: (...args) => console.warn(tag, ...args),
|
|
65
|
+
error: (...args) => console.error(tag, ...args),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Internal: called by {@link defineAddon}, not by add-on code.
|
|
70
|
+
*
|
|
71
|
+
* Public only because `defineAddon` lives outside the class and TypeScript
|
|
72
|
+
* has no package-private visibility; the underscore marks it as off-limits.
|
|
73
|
+
*/
|
|
74
|
+
async _mount() {
|
|
75
|
+
if (this.mounted)
|
|
76
|
+
return;
|
|
77
|
+
this.mounted = true;
|
|
78
|
+
await this.onMount();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Internal: called by {@link defineAddon}, not by add-on code.
|
|
82
|
+
*
|
|
83
|
+
* Every teardown runs even if an earlier one throws - a half-unmounted
|
|
84
|
+
* add-on that keeps a key handler alive would break the game far more
|
|
85
|
+
* visibly than a logged error here.
|
|
86
|
+
*/
|
|
87
|
+
async _unmount() {
|
|
88
|
+
if (!this.mounted)
|
|
89
|
+
return;
|
|
90
|
+
this.mounted = false;
|
|
91
|
+
try {
|
|
92
|
+
await this.onUnmount();
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
this.log.error('onUnmount failed', e);
|
|
96
|
+
}
|
|
97
|
+
const pending = this.disposers.reverse();
|
|
98
|
+
this.disposers = [];
|
|
99
|
+
for (const dispose of pending) {
|
|
100
|
+
try {
|
|
101
|
+
dispose();
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
this.log.error('teardown failed', e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type BuildMeta = {
|
|
2
|
+
name?: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
/** The oss!idle release the bundle was built against. */
|
|
5
|
+
gameVersion?: string;
|
|
6
|
+
/** The @oss-idle/addon-sdk that built it. */
|
|
7
|
+
sdkVersion?: string;
|
|
8
|
+
license?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const buildMeta: () => BuildMeta;
|
|
11
|
+
//# sourceMappingURL=buildMeta.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buildMeta.d.ts","sourceRoot":"","sources":["../../src/runtime/buildMeta.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,SAAS,GAAG;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,SAAS,QAAO,SACuC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const buildMeta = () => typeof __OSS_IDLE_ADDON__ === 'undefined' ? {} : __OSS_IDLE_ADDON__;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Addon } from './addon.js';
|
|
2
|
+
/** The module shape oss!idle's add-on runtime imports and drives. */
|
|
3
|
+
export type AddonModule = {
|
|
4
|
+
mount: () => void;
|
|
5
|
+
unmount: () => void;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Turn an {@link Addon} subclass into the `mount` / `unmount` pair the runtime
|
|
9
|
+
* expects:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const { mount, unmount } = defineAddon(() => new MyAddon());
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The runtime calls both synchronously and ignores what they return, so an
|
|
16
|
+
* async `onMount` is started and left to run - its failure is reported here
|
|
17
|
+
* rather than surfacing as an unhandled rejection in the player's console.
|
|
18
|
+
*
|
|
19
|
+
* A mount still settling when `unmount` arrives (disable clicked mid-boot) is
|
|
20
|
+
* awaited first, so teardown never races a half-built overlay.
|
|
21
|
+
*/
|
|
22
|
+
export declare const defineAddon: (create: () => Addon) => AddonModule;
|
|
23
|
+
//# sourceMappingURL=define.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define.d.ts","sourceRoot":"","sources":["../../src/runtime/define.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,qEAAqE;AACrE,MAAM,MAAM,WAAW,GAAG;IACzB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,KAAK,KAAG,WAqBjD,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an {@link Addon} subclass into the `mount` / `unmount` pair the runtime
|
|
3
|
+
* expects:
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* export const { mount, unmount } = defineAddon(() => new MyAddon());
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* The runtime calls both synchronously and ignores what they return, so an
|
|
10
|
+
* async `onMount` is started and left to run - its failure is reported here
|
|
11
|
+
* rather than surfacing as an unhandled rejection in the player's console.
|
|
12
|
+
*
|
|
13
|
+
* A mount still settling when `unmount` arrives (disable clicked mid-boot) is
|
|
14
|
+
* awaited first, so teardown never races a half-built overlay.
|
|
15
|
+
*/
|
|
16
|
+
export const defineAddon = (create) => {
|
|
17
|
+
let instance;
|
|
18
|
+
let settling = Promise.resolve();
|
|
19
|
+
return {
|
|
20
|
+
mount: () => {
|
|
21
|
+
instance = create();
|
|
22
|
+
const addon = instance;
|
|
23
|
+
settling = addon._mount().catch((e) => {
|
|
24
|
+
console.error('[addon] mount failed', e);
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
unmount: () => {
|
|
28
|
+
const addon = instance;
|
|
29
|
+
if (!addon)
|
|
30
|
+
return;
|
|
31
|
+
instance = undefined;
|
|
32
|
+
settling = settling
|
|
33
|
+
.then(() => addon._unmount())
|
|
34
|
+
.catch((e) => console.error('[addon] unmount failed', e));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping an overlay's input out of the game.
|
|
3
|
+
*
|
|
4
|
+
* The client binds its gameplay controls as **bubble-phase listeners on
|
|
5
|
+
* `window`** (`input/Controls.ts`), and the same is true of the scene-level
|
|
6
|
+
* shortcuts and the wheel handlers that drive volume and the song carousel.
|
|
7
|
+
* Nothing is bound in the capture phase, which is what makes an overlay able to
|
|
8
|
+
* coexist with it:
|
|
9
|
+
*
|
|
10
|
+
* - **Inside the overlay**, {@link isolateInput} stops these events at the
|
|
11
|
+
* overlay's root while they bubble. The event still reached its target, so a
|
|
12
|
+
* text field types normally, but it never continues up to `window` - so typing
|
|
13
|
+
* "speed" into a filter box cannot press four gameplay keys, and scrolling a
|
|
14
|
+
* results list cannot change the game's volume.
|
|
15
|
+
* - **Outside the overlay**, {@link HotkeyManager} listens in the capture phase,
|
|
16
|
+
* ahead of every game handler, so a shortcut can claim its combo before
|
|
17
|
+
* gameplay interprets it.
|
|
18
|
+
*/
|
|
19
|
+
import type { Disposer } from './addon.js';
|
|
20
|
+
/**
|
|
21
|
+
* Stop the game from seeing input that belongs to `root`.
|
|
22
|
+
*
|
|
23
|
+
* Bubble phase on purpose: the event has already done its job inside the
|
|
24
|
+
* overlay by the time it is stopped here. Capturing instead would take the
|
|
25
|
+
* keystroke away from the very field the player is typing into.
|
|
26
|
+
*/
|
|
27
|
+
export declare const isolateInput: (root: HTMLElement) => Disposer;
|
|
28
|
+
export type Hotkey = {
|
|
29
|
+
/** `KeyboardEvent.key`, compared case-insensitively. */
|
|
30
|
+
key: string;
|
|
31
|
+
ctrl: boolean;
|
|
32
|
+
shift: boolean;
|
|
33
|
+
alt: boolean;
|
|
34
|
+
meta: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** Parse `"Ctrl+Shift+F"` into a {@link Hotkey}. Order and case do not matter. */
|
|
37
|
+
export declare const parseHotkey: (combo: string) => Hotkey;
|
|
38
|
+
/**
|
|
39
|
+
* Global shortcuts that win against the game.
|
|
40
|
+
*
|
|
41
|
+
* One capture-phase listener on `window` serves every binding. A claimed combo
|
|
42
|
+
* is stopped with `stopImmediatePropagation` and `preventDefault`, so neither
|
|
43
|
+
* the game's handlers nor the browser's own act on it.
|
|
44
|
+
*/
|
|
45
|
+
export declare class HotkeyManager {
|
|
46
|
+
private readonly bindings;
|
|
47
|
+
private readonly onKeyDown;
|
|
48
|
+
constructor();
|
|
49
|
+
/**
|
|
50
|
+
* Bind a combo. `allowInText` lets it fire even from a focused field, which
|
|
51
|
+
* is what a close/escape shortcut wants.
|
|
52
|
+
*/
|
|
53
|
+
register(combo: string, handler: (event: KeyboardEvent) => void, { allowInText }?: {
|
|
54
|
+
allowInText?: boolean;
|
|
55
|
+
}): Disposer;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=input.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/runtime/input.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAS3C;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,WAAW,KAAG,QAUhD,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACpB,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,EAAE,OAAO,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;CACd,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,WAAW,GAAI,OAAO,MAAM,KAAG,MAY3C,CAAC;AAqBF;;;;;;GAMG;AACH,qBAAa,aAAa;IAEzB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAE/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAYxB;;IAMF;;;OAGG;IACH,QAAQ,CACP,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACvC,EAAE,WAAmB,EAAE,GAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAO,GACrD,QAAQ;IAMX,OAAO,IAAI,IAAI;CAIf"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping an overlay's input out of the game.
|
|
3
|
+
*
|
|
4
|
+
* The client binds its gameplay controls as **bubble-phase listeners on
|
|
5
|
+
* `window`** (`input/Controls.ts`), and the same is true of the scene-level
|
|
6
|
+
* shortcuts and the wheel handlers that drive volume and the song carousel.
|
|
7
|
+
* Nothing is bound in the capture phase, which is what makes an overlay able to
|
|
8
|
+
* coexist with it:
|
|
9
|
+
*
|
|
10
|
+
* - **Inside the overlay**, {@link isolateInput} stops these events at the
|
|
11
|
+
* overlay's root while they bubble. The event still reached its target, so a
|
|
12
|
+
* text field types normally, but it never continues up to `window` - so typing
|
|
13
|
+
* "speed" into a filter box cannot press four gameplay keys, and scrolling a
|
|
14
|
+
* results list cannot change the game's volume.
|
|
15
|
+
* - **Outside the overlay**, {@link HotkeyManager} listens in the capture phase,
|
|
16
|
+
* ahead of every game handler, so a shortcut can claim its combo before
|
|
17
|
+
* gameplay interprets it.
|
|
18
|
+
*/
|
|
19
|
+
/** Events the game acts on globally, which an overlay must not leak upward. */
|
|
20
|
+
const ISOLATED_EVENTS = [
|
|
21
|
+
'keydown', 'keyup', 'keypress',
|
|
22
|
+
'wheel',
|
|
23
|
+
'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Stop the game from seeing input that belongs to `root`.
|
|
27
|
+
*
|
|
28
|
+
* Bubble phase on purpose: the event has already done its job inside the
|
|
29
|
+
* overlay by the time it is stopped here. Capturing instead would take the
|
|
30
|
+
* keystroke away from the very field the player is typing into.
|
|
31
|
+
*/
|
|
32
|
+
export const isolateInput = (root) => {
|
|
33
|
+
const stop = (event) => event.stopPropagation();
|
|
34
|
+
for (const type of ISOLATED_EVENTS) {
|
|
35
|
+
root.addEventListener(type, stop, { capture: false });
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
for (const type of ISOLATED_EVENTS) {
|
|
39
|
+
root.removeEventListener(type, stop, { capture: false });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** Parse `"Ctrl+Shift+F"` into a {@link Hotkey}. Order and case do not matter. */
|
|
44
|
+
export const parseHotkey = (combo) => {
|
|
45
|
+
const parts = combo.split('+').map(p => p.trim().toLowerCase()).filter(Boolean);
|
|
46
|
+
const hotkey = { key: '', ctrl: false, shift: false, alt: false, meta: false };
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (part === 'ctrl' || part === 'control')
|
|
49
|
+
hotkey.ctrl = true;
|
|
50
|
+
else if (part === 'shift')
|
|
51
|
+
hotkey.shift = true;
|
|
52
|
+
else if (part === 'alt' || part === 'option')
|
|
53
|
+
hotkey.alt = true;
|
|
54
|
+
else if (part === 'meta' || part === 'cmd' || part === 'super')
|
|
55
|
+
hotkey.meta = true;
|
|
56
|
+
else
|
|
57
|
+
hotkey.key = part;
|
|
58
|
+
}
|
|
59
|
+
if (!hotkey.key)
|
|
60
|
+
throw new Error(`Hotkey "${combo}" names no key`);
|
|
61
|
+
return hotkey;
|
|
62
|
+
};
|
|
63
|
+
const matches = (hotkey, event) => event.key.toLowerCase() === hotkey.key
|
|
64
|
+
&& event.ctrlKey === hotkey.ctrl
|
|
65
|
+
&& event.shiftKey === hotkey.shift
|
|
66
|
+
&& event.altKey === hotkey.alt
|
|
67
|
+
&& event.metaKey === hotkey.meta;
|
|
68
|
+
/** Whether the event is going somewhere the player is typing. */
|
|
69
|
+
const isTextEntry = (target) => {
|
|
70
|
+
const el = target;
|
|
71
|
+
if (!el?.tagName)
|
|
72
|
+
return false;
|
|
73
|
+
return el.tagName === 'INPUT'
|
|
74
|
+
|| el.tagName === 'TEXTAREA'
|
|
75
|
+
|| el.tagName === 'SELECT'
|
|
76
|
+
|| el.isContentEditable;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Global shortcuts that win against the game.
|
|
80
|
+
*
|
|
81
|
+
* One capture-phase listener on `window` serves every binding. A claimed combo
|
|
82
|
+
* is stopped with `stopImmediatePropagation` and `preventDefault`, so neither
|
|
83
|
+
* the game's handlers nor the browser's own act on it.
|
|
84
|
+
*/
|
|
85
|
+
export class HotkeyManager {
|
|
86
|
+
bindings = new Set();
|
|
87
|
+
onKeyDown = (event) => {
|
|
88
|
+
if (event.repeat)
|
|
89
|
+
return;
|
|
90
|
+
for (const binding of this.bindings) {
|
|
91
|
+
if (!matches(binding.hotkey, event))
|
|
92
|
+
continue;
|
|
93
|
+
// A shortcut must not fire while the player is typing - unless it is
|
|
94
|
+
// the combo that closes the overlay they are typing in.
|
|
95
|
+
if (!binding.allowInText && isTextEntry(event.target))
|
|
96
|
+
continue;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
event.stopImmediatePropagation();
|
|
99
|
+
binding.handler(event);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
constructor() {
|
|
104
|
+
window.addEventListener('keydown', this.onKeyDown, { capture: true });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Bind a combo. `allowInText` lets it fire even from a focused field, which
|
|
108
|
+
* is what a close/escape shortcut wants.
|
|
109
|
+
*/
|
|
110
|
+
register(combo, handler, { allowInText = false } = {}) {
|
|
111
|
+
const binding = { hotkey: parseHotkey(combo), handler, allowInText };
|
|
112
|
+
this.bindings.add(binding);
|
|
113
|
+
return () => this.bindings.delete(binding);
|
|
114
|
+
}
|
|
115
|
+
dispose() {
|
|
116
|
+
this.bindings.clear();
|
|
117
|
+
window.removeEventListener('keydown', this.onKeyDown, { capture: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted add-on settings.
|
|
3
|
+
*
|
|
4
|
+
* An add-on is remounted on every boot and on every enable/disable, so anything
|
|
5
|
+
* held only in component state is re-entered by the player each time. Settings
|
|
6
|
+
* belong in `localStorage`, which is the same store the client keeps its own
|
|
7
|
+
* options in and survives reloads without touching the game's database.
|
|
8
|
+
*
|
|
9
|
+
* **Keys are namespaced deliberately.** The client writes its settings under
|
|
10
|
+
* bare, unprefixed keys (`db/settings.ts`), and reads them back with a bare
|
|
11
|
+
* `JSON.parse`. An add-on writing a colliding key would corrupt a game setting,
|
|
12
|
+
* so everything here lives under `oss-idle-addon:<namespace>`.
|
|
13
|
+
*
|
|
14
|
+
* Stored values are merged **over** the defaults on load, so adding a setting in
|
|
15
|
+
* a later version does not invalidate what a player already saved, and only keys
|
|
16
|
+
* the defaults still declare are kept - a removed setting cleans itself up.
|
|
17
|
+
*/
|
|
18
|
+
export type SettingsStore<T extends object> = {
|
|
19
|
+
/** The full storage key, for debugging. */
|
|
20
|
+
readonly key: string;
|
|
21
|
+
/** Stored settings merged over the defaults. Never throws. */
|
|
22
|
+
load(): T;
|
|
23
|
+
/** Persist the whole settings object. Never throws. */
|
|
24
|
+
save(value: T): void;
|
|
25
|
+
/** Forget the stored settings. */
|
|
26
|
+
clear(): void;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A namespaced settings store.
|
|
30
|
+
*
|
|
31
|
+
* Every operation is guarded: `localStorage` can be unavailable (private
|
|
32
|
+
* browsing, storage disabled) or full, and losing settings must never take the
|
|
33
|
+
* add-on - or the game - down with it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const createSettings: <T extends object>(namespace: string, defaults: T) => SettingsStore<T>;
|
|
36
|
+
//# sourceMappingURL=settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/runtime/settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,MAAM,IAAI;IAC7C,2CAA2C;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,IAAI,CAAC,CAAC;IACV,uDAAuD;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACrB,kCAAkC;IAClC,KAAK,IAAI,IAAI,CAAC;CACd,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,EAC9C,WAAW,MAAM,EACjB,UAAU,CAAC,KACT,aAAa,CAAC,CAAC,CA6CjB,CAAC"}
|