@aethervtt/shard-platform-web 0.0.1
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 +21 -0
- package/README.md +3 -0
- package/dist/audio.d.ts +18 -0
- package/dist/audio.js +273 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +61 -0
- package/dist/input.d.ts +6 -0
- package/dist/input.js +94 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +49 -0
- package/dist/workers.d.ts +7 -0
- package/dist/workers.js +32 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AudioBackend } from '@aethervtt/shard-platform';
|
|
2
|
+
/** Web Audio in a browser or webview: one context, a gain node per bus, a PannerNode per spatial voice. */
|
|
3
|
+
export interface WebAudioBackend extends AudioBackend {
|
|
4
|
+
readonly context: AudioContext;
|
|
5
|
+
/** Everything mixed (before the destination), for meters. */
|
|
6
|
+
readonly output: GainNode;
|
|
7
|
+
/** A bus's gain node (after its gain), for meters. */
|
|
8
|
+
busNode(name: string): GainNode;
|
|
9
|
+
/** Resumes the context (browsers need a user gesture first). */
|
|
10
|
+
resume(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The Web Audio backend. The context starts suspended in most browsers; it resumes on the first
|
|
14
|
+
* pointer, key, or touch, and voices asked for before then start in step at that moment.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createWebAudioBackend(options?: {
|
|
17
|
+
context?: AudioContext;
|
|
18
|
+
}): WebAudioBackend;
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const GESTURES = ["pointerdown", "keydown", "touchend"];
|
|
3
|
+
const SMOOTH = 0.015;
|
|
4
|
+
const MIN_FADE = 8e-3;
|
|
5
|
+
const MIME = {
|
|
6
|
+
wav: "audio/wav",
|
|
7
|
+
vorbis: 'audio/ogg; codecs="vorbis"',
|
|
8
|
+
opus: 'audio/ogg; codecs="opus"',
|
|
9
|
+
mp3: "audio/mpeg",
|
|
10
|
+
flac: "audio/flac"
|
|
11
|
+
};
|
|
12
|
+
function createWebAudioBackend(options = {}) {
|
|
13
|
+
const ctx = options.context ?? new AudioContext({ latencyHint: "interactive" });
|
|
14
|
+
const output = ctx.createGain();
|
|
15
|
+
output.connect(ctx.destination);
|
|
16
|
+
const buses = /* @__PURE__ */ new Map();
|
|
17
|
+
const busGains = /* @__PURE__ */ new Map();
|
|
18
|
+
const voices = /* @__PURE__ */ new Map();
|
|
19
|
+
const decoded = /* @__PURE__ */ new WeakMap();
|
|
20
|
+
const urls = /* @__PURE__ */ new WeakMap();
|
|
21
|
+
let next = 1;
|
|
22
|
+
const report = (err) => {
|
|
23
|
+
if (backend.onError) backend.onError(err);
|
|
24
|
+
else console.error(err);
|
|
25
|
+
};
|
|
26
|
+
const resume = async () => {
|
|
27
|
+
if (ctx.state !== "running" && ctx.state !== "closed") await ctx.resume().catch(() => {
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const onGesture = () => void resume();
|
|
31
|
+
for (const g of GESTURES) window.addEventListener(g, onGesture, { capture: true });
|
|
32
|
+
ctx.addEventListener("statechange", () => {
|
|
33
|
+
if (ctx.state === "running") {
|
|
34
|
+
for (const g of GESTURES) window.removeEventListener(g, onGesture, { capture: true });
|
|
35
|
+
for (const voice of voices.values()) if (!voice.started) start(voice);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
function bus(name) {
|
|
39
|
+
let node = buses.get(name);
|
|
40
|
+
if (!node) {
|
|
41
|
+
node = ctx.createGain();
|
|
42
|
+
node.gain.value = busGains.get(name) ?? 1;
|
|
43
|
+
node.connect(output);
|
|
44
|
+
buses.set(name, node);
|
|
45
|
+
}
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
function decode(clip) {
|
|
49
|
+
let p = decoded.get(clip.bytes);
|
|
50
|
+
if (!p) {
|
|
51
|
+
const copy = clip.bytes.slice().buffer;
|
|
52
|
+
p = ctx.decodeAudioData(copy).then(
|
|
53
|
+
(buffer) => ({ buffer, norm: clip.normalize ? normalization(buffer) : 1 }),
|
|
54
|
+
(cause) => {
|
|
55
|
+
throw new ShardError("audio/decode-failed", `Can't decode ${clip.id} (${clip.codec})`, {
|
|
56
|
+
hint: clip.codec === "opus" ? "This browser can't decode Opus (Safari before 18.4). Use Ogg Vorbis or MP3." : "The file may be damaged, or this browser lacks the codec. Re-export it as Ogg Vorbis or MP3.",
|
|
57
|
+
cause
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
decoded.set(clip.bytes, p);
|
|
62
|
+
}
|
|
63
|
+
return p;
|
|
64
|
+
}
|
|
65
|
+
function mediaUrl(clip) {
|
|
66
|
+
let url = urls.get(clip.bytes);
|
|
67
|
+
if (!url) {
|
|
68
|
+
const blob = new Blob([clip.bytes.slice().buffer], { type: MIME[clip.codec] ?? "audio/*" });
|
|
69
|
+
url = URL.createObjectURL(blob);
|
|
70
|
+
urls.set(clip.bytes, url);
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
function offsetNow(voice, duration) {
|
|
75
|
+
const d = voice.desc;
|
|
76
|
+
let t = d.offset + (performance.now() - voice.requestedAt) / 1e3 * voice.params.pitch;
|
|
77
|
+
if (!d.loop) return t;
|
|
78
|
+
const end = d.clip.loopEnd > 0 ? Math.min(d.clip.loopEnd, duration) : duration;
|
|
79
|
+
const begin = d.clip.loopEnd > 0 && d.clip.loopStart < end ? d.clip.loopStart : 0;
|
|
80
|
+
if (t >= end && end > begin) t = begin + (t - end) % (end - begin);
|
|
81
|
+
return t;
|
|
82
|
+
}
|
|
83
|
+
function start(voice) {
|
|
84
|
+
if (voice.stopped || voice.started || ctx.state !== "running") return;
|
|
85
|
+
const d = voice.desc;
|
|
86
|
+
if (d.clip.stream) {
|
|
87
|
+
voice.started = true;
|
|
88
|
+
const el = new Audio(mediaUrl(d.clip));
|
|
89
|
+
el.loop = d.loop;
|
|
90
|
+
el.preservesPitch = false;
|
|
91
|
+
el.playbackRate = voice.params.pitch;
|
|
92
|
+
el.currentTime = offsetNow(voice, d.clip.duration) % Math.max(d.clip.duration, 1e-3);
|
|
93
|
+
ctx.createMediaElementSource(el).connect(voice.gain);
|
|
94
|
+
voice.element = el;
|
|
95
|
+
el.play().catch(
|
|
96
|
+
(cause) => report(
|
|
97
|
+
new ShardError("audio/decode-failed", `Can't stream ${d.clip.id} (${d.clip.codec})`, {
|
|
98
|
+
cause
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
decode(d.clip).then(
|
|
105
|
+
({ buffer, norm }) => {
|
|
106
|
+
if (voice.stopped || voice.started || ctx.state !== "running") return;
|
|
107
|
+
const offset = offsetNow(voice, buffer.duration);
|
|
108
|
+
if (!d.loop && offset >= buffer.duration) return;
|
|
109
|
+
voice.started = true;
|
|
110
|
+
voice.norm = norm;
|
|
111
|
+
voice.gain.gain.value = voice.params.gain * norm;
|
|
112
|
+
const src = ctx.createBufferSource();
|
|
113
|
+
src.buffer = buffer;
|
|
114
|
+
src.loop = d.loop;
|
|
115
|
+
if (d.loop && d.clip.loopEnd > 0) {
|
|
116
|
+
src.loopStart = d.clip.loopStart;
|
|
117
|
+
src.loopEnd = d.clip.loopEnd;
|
|
118
|
+
}
|
|
119
|
+
src.playbackRate.value = voice.params.pitch;
|
|
120
|
+
src.connect(voice.gain);
|
|
121
|
+
src.start(0, offset);
|
|
122
|
+
voice.source = src;
|
|
123
|
+
},
|
|
124
|
+
(err) => report(err)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function place(panner, x, y, z, at) {
|
|
128
|
+
if (panner.positionX) {
|
|
129
|
+
panner.positionX.setTargetAtTime(x, at, SMOOTH);
|
|
130
|
+
panner.positionY.setTargetAtTime(y, at, SMOOTH);
|
|
131
|
+
panner.positionZ.setTargetAtTime(z, at, SMOOTH);
|
|
132
|
+
} else panner.setPosition(x, y, z);
|
|
133
|
+
}
|
|
134
|
+
const backend = {
|
|
135
|
+
kind: "web",
|
|
136
|
+
context: ctx,
|
|
137
|
+
output,
|
|
138
|
+
get state() {
|
|
139
|
+
const s = ctx.state;
|
|
140
|
+
return s === "running" ? "running" : s === "closed" ? "closed" : "suspended";
|
|
141
|
+
},
|
|
142
|
+
resume,
|
|
143
|
+
busNode: bus,
|
|
144
|
+
play(desc) {
|
|
145
|
+
const id = next++;
|
|
146
|
+
const gain = ctx.createGain();
|
|
147
|
+
gain.gain.value = desc.gain;
|
|
148
|
+
let panner = null;
|
|
149
|
+
const s = desc.spatial;
|
|
150
|
+
if (s) {
|
|
151
|
+
panner = ctx.createPanner();
|
|
152
|
+
panner.panningModel = s.panning === "hrtf" ? "HRTF" : "equalpower";
|
|
153
|
+
panner.distanceModel = s.distanceModel;
|
|
154
|
+
panner.refDistance = s.refDistance;
|
|
155
|
+
panner.maxDistance = Math.max(s.maxDistance, s.refDistance + 1e-3);
|
|
156
|
+
panner.rolloffFactor = s.rolloffFactor;
|
|
157
|
+
if (panner.positionX) {
|
|
158
|
+
panner.positionX.value = desc.x;
|
|
159
|
+
panner.positionY.value = desc.y;
|
|
160
|
+
panner.positionZ.value = desc.z;
|
|
161
|
+
} else panner.setPosition(desc.x, desc.y, desc.z);
|
|
162
|
+
gain.connect(panner).connect(bus(desc.bus));
|
|
163
|
+
} else gain.connect(bus(desc.bus));
|
|
164
|
+
const voice = {
|
|
165
|
+
desc,
|
|
166
|
+
params: { gain: desc.gain, pitch: desc.pitch, x: desc.x, y: desc.y, z: desc.z },
|
|
167
|
+
gain,
|
|
168
|
+
panner,
|
|
169
|
+
source: null,
|
|
170
|
+
element: null,
|
|
171
|
+
norm: 1,
|
|
172
|
+
requestedAt: performance.now(),
|
|
173
|
+
started: false,
|
|
174
|
+
stopped: false
|
|
175
|
+
};
|
|
176
|
+
voices.set(id, voice);
|
|
177
|
+
start(voice);
|
|
178
|
+
return id;
|
|
179
|
+
},
|
|
180
|
+
update(id, params) {
|
|
181
|
+
const v = voices.get(id);
|
|
182
|
+
if (!v) return;
|
|
183
|
+
const now = ctx.currentTime;
|
|
184
|
+
const p = v.params;
|
|
185
|
+
if (p.gain !== params.gain) v.gain.gain.setTargetAtTime(params.gain * v.norm, now, SMOOTH);
|
|
186
|
+
if (p.pitch !== params.pitch) {
|
|
187
|
+
if (v.source) v.source.playbackRate.setTargetAtTime(params.pitch, now, SMOOTH);
|
|
188
|
+
if (v.element) v.element.playbackRate = params.pitch;
|
|
189
|
+
}
|
|
190
|
+
if (v.panner && (p.x !== params.x || p.y !== params.y || p.z !== params.z)) {
|
|
191
|
+
place(v.panner, params.x, params.y, params.z, now);
|
|
192
|
+
}
|
|
193
|
+
p.gain = params.gain;
|
|
194
|
+
p.pitch = params.pitch;
|
|
195
|
+
p.x = params.x;
|
|
196
|
+
p.y = params.y;
|
|
197
|
+
p.z = params.z;
|
|
198
|
+
},
|
|
199
|
+
stop(id, fade = 0) {
|
|
200
|
+
const v = voices.get(id);
|
|
201
|
+
if (!v) return;
|
|
202
|
+
voices.delete(id);
|
|
203
|
+
v.stopped = true;
|
|
204
|
+
const now = ctx.currentTime;
|
|
205
|
+
const f = Math.max(fade, MIN_FADE);
|
|
206
|
+
v.gain.gain.cancelScheduledValues(now);
|
|
207
|
+
v.gain.gain.setValueAtTime(v.gain.gain.value, now);
|
|
208
|
+
v.gain.gain.linearRampToValueAtTime(0, now + f);
|
|
209
|
+
const release = () => {
|
|
210
|
+
v.gain.disconnect();
|
|
211
|
+
v.panner?.disconnect();
|
|
212
|
+
if (v.element) {
|
|
213
|
+
v.element.pause();
|
|
214
|
+
v.element.removeAttribute("src");
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
if (v.source) {
|
|
218
|
+
v.source.onended = release;
|
|
219
|
+
v.source.stop(now + f);
|
|
220
|
+
} else setTimeout(release, f * 1e3 + 50);
|
|
221
|
+
},
|
|
222
|
+
setListener(m) {
|
|
223
|
+
const l = ctx.listener;
|
|
224
|
+
const fl = Math.sqrt(m[2] * m[2] + m[6] * m[6] + m[10] * m[10]) || 1;
|
|
225
|
+
const ul = Math.sqrt(m[1] * m[1] + m[5] * m[5] + m[9] * m[9]) || 1;
|
|
226
|
+
const fx = -m[2] / fl;
|
|
227
|
+
const fy = -m[6] / fl;
|
|
228
|
+
const fz = -m[10] / fl;
|
|
229
|
+
const ux = m[1] / ul;
|
|
230
|
+
const uy = m[5] / ul;
|
|
231
|
+
const uz = m[9] / ul;
|
|
232
|
+
if (l.positionX) {
|
|
233
|
+
const now = ctx.currentTime;
|
|
234
|
+
l.positionX.setTargetAtTime(m[3], now, SMOOTH);
|
|
235
|
+
l.positionY.setTargetAtTime(m[7], now, SMOOTH);
|
|
236
|
+
l.positionZ.setTargetAtTime(m[11], now, SMOOTH);
|
|
237
|
+
l.forwardX.setTargetAtTime(fx, now, SMOOTH);
|
|
238
|
+
l.forwardY.setTargetAtTime(fy, now, SMOOTH);
|
|
239
|
+
l.forwardZ.setTargetAtTime(fz, now, SMOOTH);
|
|
240
|
+
l.upX.setTargetAtTime(ux, now, SMOOTH);
|
|
241
|
+
l.upY.setTargetAtTime(uy, now, SMOOTH);
|
|
242
|
+
l.upZ.setTargetAtTime(uz, now, SMOOTH);
|
|
243
|
+
} else {
|
|
244
|
+
l.setPosition(m[3], m[7], m[11]);
|
|
245
|
+
l.setOrientation(fx, fy, fz, ux, uy, uz);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
setBus(name, gain) {
|
|
249
|
+
busGains.set(name, gain);
|
|
250
|
+
bus(name).gain.setTargetAtTime(gain, ctx.currentTime, SMOOTH);
|
|
251
|
+
},
|
|
252
|
+
dispose() {
|
|
253
|
+
for (const g of GESTURES) window.removeEventListener(g, onGesture, { capture: true });
|
|
254
|
+
for (const id of [...voices.keys()]) backend.stop(id);
|
|
255
|
+
void ctx.close();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
return backend;
|
|
259
|
+
}
|
|
260
|
+
function normalization(buffer) {
|
|
261
|
+
let peak = 0;
|
|
262
|
+
for (let c = 0; c < buffer.numberOfChannels; c++) {
|
|
263
|
+
const data = buffer.getChannelData(c);
|
|
264
|
+
for (let i = 0; i < data.length; i++) {
|
|
265
|
+
const a = Math.abs(data[i]);
|
|
266
|
+
if (a > peak) peak = a;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return peak > 0 ? 1 / peak : 1;
|
|
270
|
+
}
|
|
271
|
+
export {
|
|
272
|
+
createWebAudioBackend
|
|
273
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Platform } from '@aethervtt/shard-platform';
|
|
2
|
+
export interface WebPlatformOptions {
|
|
3
|
+
/** Base URL that project paths resolve against. Defaults to the document base. */
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
/** IndexedDB database for `storage` (saves, settings). Default `shard`. */
|
|
6
|
+
storageName?: string;
|
|
7
|
+
/** Web workers for `workers` (made on first use). 0 runs jobs inline. Default: cores − 1, 1 to 8. */
|
|
8
|
+
workers?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function createWebPlatform(options?: WebPlatformOptions): Platform;
|
|
11
|
+
export { createWebAudioBackend, type WebAudioBackend } from './audio.js';
|
|
12
|
+
export { createDomInputSource } from './input.js';
|
|
13
|
+
export { createIndexedDbStorage } from './storage.js';
|
|
14
|
+
export { createWebWorkers } from './workers.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { createWebAudioBackend } from "./audio.js";
|
|
3
|
+
import { createIndexedDbStorage } from "./storage.js";
|
|
4
|
+
import { createWebWorkers } from "./workers.js";
|
|
5
|
+
function createWebPlatform(options = {}) {
|
|
6
|
+
const baseUrl = options.baseUrl ?? document.baseURI;
|
|
7
|
+
const readOnly = (path) => {
|
|
8
|
+
throw new ShardError("platform/fs-read-only", `Cannot write "${path}" on the web platform`, {
|
|
9
|
+
hint: "Writes need a writable host such as Studio or the CLI.",
|
|
10
|
+
path
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
const fetchOk = async (path) => {
|
|
14
|
+
const res = await fetch(new URL(path, baseUrl));
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
throw new ShardError("platform/fs-not-found", `Failed to fetch "${path}" (${res.status})`, {
|
|
17
|
+
path
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return res;
|
|
21
|
+
};
|
|
22
|
+
const fs = {
|
|
23
|
+
writable: false,
|
|
24
|
+
readText: async (path) => (await fetchOk(path)).text(),
|
|
25
|
+
readBytes: async (path) => new Uint8Array(await (await fetchOk(path)).arrayBuffer()),
|
|
26
|
+
writeText: async (path) => readOnly(path),
|
|
27
|
+
writeBytes: async (path) => readOnly(path),
|
|
28
|
+
exists: async (path) => (await fetch(new URL(path, baseUrl), { method: "HEAD" })).ok
|
|
29
|
+
};
|
|
30
|
+
let audio;
|
|
31
|
+
let workers;
|
|
32
|
+
return {
|
|
33
|
+
name: "web",
|
|
34
|
+
get workers() {
|
|
35
|
+
workers ??= createWebWorkers(options.workers);
|
|
36
|
+
return workers;
|
|
37
|
+
},
|
|
38
|
+
// Made on first use, so pages that never play sound don't open an AudioContext.
|
|
39
|
+
get audio() {
|
|
40
|
+
audio ??= createWebAudioBackend();
|
|
41
|
+
return audio;
|
|
42
|
+
},
|
|
43
|
+
fs,
|
|
44
|
+
storage: createIndexedDbStorage(options.storageName),
|
|
45
|
+
clock: { now: () => performance.now() },
|
|
46
|
+
log: {
|
|
47
|
+
log: (level, message, data) => console[level](`[shard] ${message}`, data ?? "")
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
import { createWebAudioBackend as createWebAudioBackend2 } from "./audio.js";
|
|
52
|
+
import { createDomInputSource } from "./input.js";
|
|
53
|
+
import { createIndexedDbStorage as createIndexedDbStorage2 } from "./storage.js";
|
|
54
|
+
import { createWebWorkers as createWebWorkers2 } from "./workers.js";
|
|
55
|
+
export {
|
|
56
|
+
createDomInputSource,
|
|
57
|
+
createIndexedDbStorage2 as createIndexedDbStorage,
|
|
58
|
+
createWebAudioBackend2 as createWebAudioBackend,
|
|
59
|
+
createWebPlatform,
|
|
60
|
+
createWebWorkers2 as createWebWorkers
|
|
61
|
+
};
|
package/dist/input.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { InputSource } from '@aethervtt/shard-platform';
|
|
2
|
+
/**
|
|
3
|
+
* DOM input: pointer and wheel events on `element` (positions in its backing pixels), keys and
|
|
4
|
+
* focus on the window, gamepads polled on drain. Works in browsers and Tauri webviews alike.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createDomInputSource(element: HTMLElement): InputSource;
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const BUTTONS = ["left", "middle", "right", "back", "forward"];
|
|
2
|
+
function createDomInputSource(element) {
|
|
3
|
+
const queue = [];
|
|
4
|
+
const target = globalThis.window;
|
|
5
|
+
const toPixels = (e) => {
|
|
6
|
+
const rect = element.getBoundingClientRect();
|
|
7
|
+
const sx = element instanceof HTMLCanvasElement ? element.width / Math.max(1, rect.width) : 1;
|
|
8
|
+
const sy = element instanceof HTMLCanvasElement ? element.height / Math.max(1, rect.height) : 1;
|
|
9
|
+
return { x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy, sx, sy };
|
|
10
|
+
};
|
|
11
|
+
const knownPads = /* @__PURE__ */ new Set();
|
|
12
|
+
const onKeyDown = (e) => {
|
|
13
|
+
if (!e.ctrlKey && !e.metaKey) {
|
|
14
|
+
if (e.key.length === 1) queue.push({ type: "text", text: e.key });
|
|
15
|
+
else if (e.key === "Backspace") queue.push({ type: "text", text: "\b" });
|
|
16
|
+
}
|
|
17
|
+
if (e.repeat) return;
|
|
18
|
+
queue.push({ type: "key", code: e.code, pressed: true });
|
|
19
|
+
};
|
|
20
|
+
const onKeyUp = (e) => {
|
|
21
|
+
queue.push({ type: "key", code: e.code, pressed: false });
|
|
22
|
+
};
|
|
23
|
+
const onPointerDown = (e) => {
|
|
24
|
+
const { x, y } = toPixels(e);
|
|
25
|
+
if (e.pointerType === "touch")
|
|
26
|
+
queue.push({ type: "touch", id: e.pointerId, phase: "start", x, y });
|
|
27
|
+
else queue.push({ type: "mouse-button", button: BUTTONS[e.button] ?? "left", pressed: true });
|
|
28
|
+
};
|
|
29
|
+
const onPointerUp = (e) => {
|
|
30
|
+
const { x, y } = toPixels(e);
|
|
31
|
+
if (e.pointerType === "touch")
|
|
32
|
+
queue.push({ type: "touch", id: e.pointerId, phase: "end", x, y });
|
|
33
|
+
else queue.push({ type: "mouse-button", button: BUTTONS[e.button] ?? "left", pressed: false });
|
|
34
|
+
};
|
|
35
|
+
const onPointerMove = (e) => {
|
|
36
|
+
const { x, y, sx, sy } = toPixels(e);
|
|
37
|
+
if (e.pointerType === "touch")
|
|
38
|
+
queue.push({ type: "touch", id: e.pointerId, phase: "move", x, y });
|
|
39
|
+
else queue.push({ type: "mouse-move", x, y, dx: e.movementX * sx, dy: e.movementY * sy });
|
|
40
|
+
};
|
|
41
|
+
const onWheel = (e) => {
|
|
42
|
+
const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
|
|
43
|
+
queue.push({ type: "wheel", dx: e.deltaX * scale, dy: e.deltaY * scale });
|
|
44
|
+
};
|
|
45
|
+
const onBlur = () => queue.push({ type: "focus", focused: false });
|
|
46
|
+
const onFocus = () => queue.push({ type: "focus", focused: true });
|
|
47
|
+
const onContextMenu = (e) => e.preventDefault();
|
|
48
|
+
target.addEventListener("keydown", onKeyDown);
|
|
49
|
+
target.addEventListener("keyup", onKeyUp);
|
|
50
|
+
target.addEventListener("blur", onBlur);
|
|
51
|
+
target.addEventListener("focus", onFocus);
|
|
52
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
53
|
+
target.addEventListener("pointerup", onPointerUp);
|
|
54
|
+
element.addEventListener("pointermove", onPointerMove);
|
|
55
|
+
element.addEventListener("wheel", onWheel, { passive: true });
|
|
56
|
+
element.addEventListener("contextmenu", onContextMenu);
|
|
57
|
+
return {
|
|
58
|
+
drain(out) {
|
|
59
|
+
for (const e of queue) out.push(e);
|
|
60
|
+
queue.length = 0;
|
|
61
|
+
const pads = globalThis.navigator?.getGamepads?.() ?? [];
|
|
62
|
+
for (let i = 0; i < pads.length; i++) {
|
|
63
|
+
const pad = pads[i];
|
|
64
|
+
if (pad?.connected) {
|
|
65
|
+
knownPads.add(i);
|
|
66
|
+
out.push({
|
|
67
|
+
type: "gamepad",
|
|
68
|
+
index: i,
|
|
69
|
+
connected: true,
|
|
70
|
+
buttons: pad.buttons.map((b) => b.value),
|
|
71
|
+
axes: [...pad.axes]
|
|
72
|
+
});
|
|
73
|
+
} else if (knownPads.has(i)) {
|
|
74
|
+
knownPads.delete(i);
|
|
75
|
+
out.push({ type: "gamepad", index: i, connected: false, buttons: [], axes: [] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
dispose() {
|
|
80
|
+
target.removeEventListener("keydown", onKeyDown);
|
|
81
|
+
target.removeEventListener("keyup", onKeyUp);
|
|
82
|
+
target.removeEventListener("blur", onBlur);
|
|
83
|
+
target.removeEventListener("focus", onFocus);
|
|
84
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
85
|
+
target.removeEventListener("pointerup", onPointerUp);
|
|
86
|
+
element.removeEventListener("pointermove", onPointerMove);
|
|
87
|
+
element.removeEventListener("wheel", onWheel);
|
|
88
|
+
element.removeEventListener("contextmenu", onContextMenu);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export {
|
|
93
|
+
createDomInputSource
|
|
94
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { KeyValueStorage } from '@aethervtt/shard-platform';
|
|
2
|
+
/**
|
|
3
|
+
* Storage in an IndexedDB database (one object store of key → bytes): saves and settings survive
|
|
4
|
+
* reloads and aren't limited to localStorage's few megabytes of strings.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createIndexedDbStorage(name?: string): KeyValueStorage;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const STORE = "files";
|
|
3
|
+
function request(req) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
req.onsuccess = () => resolve(req.result);
|
|
6
|
+
req.onerror = () => reject(req.error);
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function createIndexedDbStorage(name = "shard") {
|
|
10
|
+
let db;
|
|
11
|
+
const open = () => {
|
|
12
|
+
db ??= new Promise((resolve, reject) => {
|
|
13
|
+
if (typeof indexedDB === "undefined") {
|
|
14
|
+
reject(
|
|
15
|
+
new ShardError("platform/no-storage", "IndexedDB is not available here", {
|
|
16
|
+
hint: "Private browsing modes and some embedded webviews disable it."
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const req = indexedDB.open(name, 1);
|
|
22
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
23
|
+
req.onsuccess = () => resolve(req.result);
|
|
24
|
+
req.onerror = () => reject(req.error);
|
|
25
|
+
});
|
|
26
|
+
return db;
|
|
27
|
+
};
|
|
28
|
+
const store = async (mode) => (await open()).transaction(STORE, mode).objectStore(STORE);
|
|
29
|
+
return {
|
|
30
|
+
read: async (key) => {
|
|
31
|
+
const value = await request((await store("readonly")).get(key));
|
|
32
|
+
return value === void 0 ? void 0 : new Uint8Array(value);
|
|
33
|
+
},
|
|
34
|
+
write: async (key, data) => {
|
|
35
|
+
await request((await store("readwrite")).put(data.slice().buffer, key));
|
|
36
|
+
},
|
|
37
|
+
list: async (prefix) => {
|
|
38
|
+
const range = prefix === "" ? void 0 : IDBKeyRange.bound(prefix, `${prefix}\uFFFF`);
|
|
39
|
+
const keys = await request((await store("readonly")).getAllKeys(range));
|
|
40
|
+
return keys.filter((k) => k.startsWith(prefix)).sort();
|
|
41
|
+
},
|
|
42
|
+
delete: async (key) => {
|
|
43
|
+
await request((await store("readwrite")).delete(key));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
createIndexedDbStorage
|
|
49
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Workers } from '@aethervtt/shard-platform';
|
|
2
|
+
/**
|
|
3
|
+
* A pool of module web workers, used by the web and Tauri platforms. Each runs the pool's host
|
|
4
|
+
* script from a blob URL and imports job modules by absolute URL. `size` 0 runs jobs inline.
|
|
5
|
+
* Default: one per core but one, 1 to 8.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createWebWorkers(size?: number): Workers;
|
package/dist/workers.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createInlineWorkers,
|
|
3
|
+
createWorkerPool,
|
|
4
|
+
defaultWorkerCount,
|
|
5
|
+
workerHostSource
|
|
6
|
+
} from "@aethervtt/shard-platform";
|
|
7
|
+
let hostUrl;
|
|
8
|
+
function createWebWorkers(size) {
|
|
9
|
+
const count = size ?? defaultWorkerCount(navigator.hardwareConcurrency || 2);
|
|
10
|
+
if (count <= 0 || typeof Worker === "undefined") return createInlineWorkers();
|
|
11
|
+
hostUrl ??= URL.createObjectURL(new Blob([workerHostSource("web")], { type: "text/javascript" }));
|
|
12
|
+
const url = hostUrl;
|
|
13
|
+
return createWorkerPool({
|
|
14
|
+
size: count,
|
|
15
|
+
spawn: (events) => {
|
|
16
|
+
const worker = new Worker(url, { type: "module", name: "shard-worker" });
|
|
17
|
+
worker.onmessage = (e) => events.message(e.data);
|
|
18
|
+
worker.onerror = (e) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
worker.terminate();
|
|
21
|
+
events.exit(e.message || "worker error");
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
post: (message, transfer) => worker.postMessage(message, transfer),
|
|
25
|
+
terminate: () => worker.terminate()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
createWebWorkers
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aethervtt/shard-platform-web",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@aethervtt/shard-core": "0.0.1",
|
|
20
|
+
"@aethervtt/shard-platform": "0.0.1"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/aethervtt/shard.git",
|
|
25
|
+
"directory": "packages/platform-web"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"shard": {
|
|
32
|
+
"buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
|
|
33
|
+
}
|
|
34
|
+
}
|