@umicat/three-sdk 0.9.0 → 0.10.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.
@@ -25,6 +25,12 @@
25
25
  * **4. iOS suspends the context when the app goes away** and does not bring it
26
26
  * back, so sound works until the first phone call and then never again.
27
27
  *
28
+ * Music is the one exception to rule 1, and for the same reason rule 1 exists.
29
+ * A three-minute track decodes to something like seventy megabytes of PCM;
30
+ * forty short effects as elements was fatal, but ONE element playing one long
31
+ * thing is exactly what elements are for. So effects are buffers and music
32
+ * streams, routed through the same graph so ducking still works.
33
+ *
28
34
  * The game brings its own clips; nothing here knows what a game sounds like.
29
35
  */
30
36
  export interface AudioClipSpec {
@@ -42,8 +48,9 @@ export interface GameAudioOptions {
42
48
  base?: string;
43
49
  /** File extension, including the dot. */
44
50
  extension?: string;
45
- /** A clip to loop as music. Loaded first, so the theme is not queued behind
46
- * every sound effect. */
51
+ /** A track to loop as music. Streamed, not decoded — see the note above.
52
+ * A name containing a dot is used as-is, so `'theme.mp3'` works alongside
53
+ * `.ogg` effects. */
47
54
  music?: string;
48
55
  musicVolume?: number;
49
56
  }
@@ -51,13 +58,13 @@ export declare class GameAudio {
51
58
  private ctx;
52
59
  private master;
53
60
  private musicGain;
54
- private musicSource;
61
+ private musicEl;
62
+ private musicName;
55
63
  private readonly buffers;
56
64
  private readonly lastPlayed;
57
65
  private readonly clips;
58
66
  private readonly base;
59
67
  private readonly ext;
60
- private readonly music?;
61
68
  private readonly musicVolume;
62
69
  private muted;
63
70
  private readonly cleanups;
@@ -66,8 +73,17 @@ export declare class GameAudio {
66
73
  * a cached flag is exactly the bug described above. */
67
74
  get ready(): boolean;
68
75
  private start;
76
+ /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
77
+ private urlFor;
69
78
  private loadAll;
70
79
  private startMusic;
80
+ /**
81
+ * Change the track. Pass `null` for silence.
82
+ *
83
+ * Each scene gets its own: a lobby that sounds like the fight is a lobby you
84
+ * do not linger in.
85
+ */
86
+ setMusic(name: string | null): void;
71
87
  play(name: string): void;
72
88
  /** Duck the music for a moment — for an ending that should be heard over it. */
73
89
  duck(seconds?: number): void;
package/dist/GameAudio.js CHANGED
@@ -25,6 +25,12 @@
25
25
  * **4. iOS suspends the context when the app goes away** and does not bring it
26
26
  * back, so sound works until the first phone call and then never again.
27
27
  *
28
+ * Music is the one exception to rule 1, and for the same reason rule 1 exists.
29
+ * A three-minute track decodes to something like seventy megabytes of PCM;
30
+ * forty short effects as elements was fatal, but ONE element playing one long
31
+ * thing is exactly what elements are for. So effects are buffers and music
32
+ * streams, routed through the same graph so ducking still works.
33
+ *
28
34
  * The game brings its own clips; nothing here knows what a game sounds like.
29
35
  */
30
36
  export class GameAudio {
@@ -32,7 +38,8 @@ export class GameAudio {
32
38
  this.ctx = null;
33
39
  this.master = null;
34
40
  this.musicGain = null;
35
- this.musicSource = null;
41
+ this.musicEl = null;
42
+ this.musicName = null;
36
43
  this.buffers = new Map();
37
44
  this.lastPlayed = new Map();
38
45
  this.muted = false;
@@ -40,7 +47,7 @@ export class GameAudio {
40
47
  this.clips = opts.clips;
41
48
  this.base = opts.base ?? 'audio/';
42
49
  this.ext = opts.extension ?? '.ogg';
43
- this.music = opts.music;
50
+ this.musicName = opts.music ?? null;
44
51
  this.musicVolume = opts.musicVolume ?? 0.28;
45
52
  const unlock = () => {
46
53
  void this.start().then(() => {
@@ -92,42 +99,68 @@ export class GameAudio {
92
99
  if (this.ready)
93
100
  this.startMusic();
94
101
  }
102
+ /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
103
+ urlFor(name) {
104
+ return `${this.base}${name}${name.includes('.') ? '' : this.ext}`;
105
+ }
95
106
  async loadAll() {
96
107
  const load = async (name) => {
97
108
  try {
98
- const res = await fetch(`${this.base}${name}${this.ext}`);
109
+ const res = await fetch(this.urlFor(name));
99
110
  const bytes = await res.arrayBuffer();
100
111
  const buf = await this.ctx.decodeAudioData(bytes);
101
112
  // Tagged so a test can see WHICH clip played — a buffer has no name,
102
113
  // and "some audio happened" is not a check.
103
114
  buf.__name = name;
104
115
  this.buffers.set(name, buf);
105
- if (name === this.music && this.ready && !this.musicSource)
106
- this.startMusic();
107
116
  }
108
117
  catch {
109
118
  /* a clip that will not decode is not worth taking the game down for */
110
119
  }
111
120
  };
112
- // Music first, then everything else at once. One at a time queued the theme
113
- // behind every effect and left the first actions silent — caution about
121
+ // All at once. One at a time left the first actions silent — caution about
114
122
  // decode cost, for work that is not on the main thread.
115
- if (this.music)
116
- await load(this.music);
117
123
  await Promise.all(Object.keys(this.clips).map(load));
118
124
  }
119
125
  startMusic() {
120
- if (!this.ctx || !this.musicGain || this.musicSource || this.muted || !this.music)
126
+ if (!this.ctx || !this.musicGain || this.muted || !this.musicName)
121
127
  return;
122
- const buf = this.buffers.get(this.music);
123
- if (!buf)
124
- return; // still decoding; loadAll calls back
125
- const src = this.ctx.createBufferSource();
126
- src.buffer = buf;
127
- src.loop = true;
128
- src.connect(this.musicGain);
129
- src.start();
130
- this.musicSource = src;
128
+ if (!this.musicEl) {
129
+ const el = new Audio(this.urlFor(this.musicName));
130
+ el.loop = true;
131
+ el.preload = 'auto';
132
+ el.crossOrigin = 'anonymous';
133
+ // Routed through the graph, not played on its own, so `duck()` and the
134
+ // master gain reach it like everything else.
135
+ try {
136
+ this.ctx.createMediaElementSource(el).connect(this.musicGain);
137
+ }
138
+ catch {
139
+ // Some engines refuse to route an element they consider tainted. Fall
140
+ // back to playing it directly rather than losing the music entirely.
141
+ el.volume = this.musicVolume;
142
+ }
143
+ this.musicEl = el;
144
+ }
145
+ void this.musicEl.play().catch(() => { });
146
+ }
147
+ /**
148
+ * Change the track. Pass `null` for silence.
149
+ *
150
+ * Each scene gets its own: a lobby that sounds like the fight is a lobby you
151
+ * do not linger in.
152
+ */
153
+ setMusic(name) {
154
+ if (name === this.musicName)
155
+ return;
156
+ this.musicName = name;
157
+ if (this.musicEl) {
158
+ this.musicEl.pause();
159
+ this.musicEl.src = '';
160
+ this.musicEl = null; // a new element: the old one's graph node is spent
161
+ }
162
+ if (name && this.ready && !this.muted)
163
+ this.startMusic();
131
164
  }
132
165
  play(name) {
133
166
  const ctx = this.ctx;
@@ -168,7 +201,11 @@ export class GameAudio {
168
201
  if (this.master && this.ctx) {
169
202
  this.master.gain.setTargetAtTime(on ? 0 : 1, this.ctx.currentTime, 0.02);
170
203
  }
171
- if (!on && this.ready)
204
+ // Pause the stream as well as silencing it: a muted track still costs a
205
+ // decoder and a download for something nobody can hear.
206
+ if (on)
207
+ this.musicEl?.pause();
208
+ else if (this.ready)
172
209
  this.startMusic();
173
210
  }
174
211
  get isMuted() { return this.muted; }
@@ -176,7 +213,7 @@ export class GameAudio {
176
213
  for (const c of this.cleanups)
177
214
  c();
178
215
  this.cleanups.length = 0;
179
- this.musicSource?.stop();
216
+ this.musicEl?.pause();
180
217
  void this.ctx?.close();
181
218
  this.ctx = null;
182
219
  }
package/dist/Input3D.d.ts CHANGED
@@ -2,8 +2,24 @@
2
2
  export interface Input3DAction {
3
3
  /** What the game calls it: `input.held('attack')`, `input.consume('attack')`. */
4
4
  id: string;
5
- /** What the button shows. Keep it one glyph. */
5
+ /** What the button shows, as text. Keep it one glyph. Ignored when `icon`
6
+ * is given. */
6
7
  label?: string;
8
+ /**
9
+ * What the button shows, as a SHAPE: the URL of a single-colour SVG or PNG.
10
+ *
11
+ * Preferred over `label`, and the reason is not decoration. A label is text,
12
+ * and text on this layer has three problems that only show up on a device:
13
+ * an emoji is a different picture in every platform's font, text can be
14
+ * SELECTED (long-pressing a game's attack button is how the iOS Copy / Look
15
+ * Up / Translate callout comes up mid-fight), and it cannot take the
16
+ * button's colour, so a glyph can never match the controls drawn beside it.
17
+ *
18
+ * Drawn as a CSS mask, so the file's own colour is discarded and the shape
19
+ * is painted in the button's — which means one icon keeps matching if the
20
+ * control layer is ever restyled.
21
+ */
22
+ icon?: string;
7
23
  /** Keyboard equivalents, e.g. `['KeyJ']`. */
8
24
  keys?: string[];
9
25
  }
@@ -33,6 +49,9 @@ export interface Input3DOptions {
33
49
  * platform places the controls, so the platform has to place ALL of them.
34
50
  */
35
51
  actions?: Input3DAction[];
52
+ /** A shape for the JUMP button, in place of its `▲`. Same reasoning as
53
+ * `Input3DAction.icon`, for the one button the platform owns outright. */
54
+ jumpIcon?: string;
36
55
  /**
37
56
  * How the thumbstick behaves on touch.
38
57
  *
@@ -91,6 +110,13 @@ export declare class Input3D {
91
110
  private readonly stick;
92
111
  private touchJump;
93
112
  private actions;
113
+ private jumpIcon?;
114
+ /** The glyph inside each on-screen button, by action id (`'jump'` for the
115
+ * platform's own). Kept so an icon can be CHANGED while the game runs —
116
+ * which is the point of it for anything the player switches: an attack
117
+ * button that still shows a sword while you are holding a bow is a button
118
+ * that is lying about what it does. */
119
+ private glyphs;
94
120
  private stickMode;
95
121
  private wantLook;
96
122
  private topInset;
@@ -137,6 +163,18 @@ export declare class Input3D {
137
163
  /** Whether the jump control is held right now. Pass it to the controller —
138
164
  * coyote time and buffering live there, not here. */
139
165
  get jump(): boolean;
166
+ /**
167
+ * Change what a button SHOWS, without rebuilding the controls.
168
+ *
169
+ * `id` is an action's id, or `'jump'` for the platform's own button. Passing
170
+ * a url the button had no icon for at build time does nothing: the glyph is
171
+ * an element that only exists when one was given, and quietly growing one
172
+ * would move the label out from under a game that was relying on it.
173
+ *
174
+ * This is for buttons whose MEANING changes — an attack button under a bow
175
+ * and under a staff are two different actions wearing one control.
176
+ */
177
+ setActionIcon(id: string, icon: string): void;
140
178
  /** Whether an action is held right now — button or bound key, same answer. */
141
179
  held(id: string): boolean;
142
180
  /**
package/dist/Input3D.js CHANGED
@@ -1,3 +1,29 @@
1
+ /**
2
+ * The control layer's palette.
3
+ *
4
+ * A DARK translucent fill with a light rim, not a light fill. White-on-white
5
+ * is what the first version was, and over a bright board — snow, or this
6
+ * engine's default grass — the buttons washed out into the scene exactly when
7
+ * a thumb was looking for them. A dark disc reads against anything: bright
8
+ * scenes darken it into view, dark scenes leave the rim and the glyph doing
9
+ * the work.
10
+ *
11
+ * Deliberately neutral rather than themed. This layer belongs to the platform
12
+ * and sits over every game, so it cannot take a side about what colour a game
13
+ * is.
14
+ */
15
+ const CONTROL = {
16
+ /** The disc under a thumb. */
17
+ fill: 'rgba(16,20,28,0.38)',
18
+ /** The rim, which is what actually makes it findable. */
19
+ rim: 'rgba(255,255,255,0.62)',
20
+ /** Glyphs and the stick's knob. */
21
+ ink: 'rgba(255,255,255,0.95)',
22
+ /** The stick's ring, a shade quieter than a button: it is a place to put a
23
+ * thumb rather than a thing to press. */
24
+ padFill: 'rgba(16,20,28,0.30)',
25
+ padRim: 'rgba(255,255,255,0.45)',
26
+ };
1
27
  /** How long a press stays readable, however briefly it was actually made.
2
28
  *
3
29
  * A tap whose down and up both land between two frames is invisible to a
@@ -67,6 +93,12 @@ export class Input3D {
67
93
  this.stick = { x: 0, z: 0 };
68
94
  this.touchJump = false;
69
95
  this.actions = [];
96
+ /** The glyph inside each on-screen button, by action id (`'jump'` for the
97
+ * platform's own). Kept so an icon can be CHANGED while the game runs —
98
+ * which is the point of it for anything the player switches: an attack
99
+ * button that still shows a sword while you are holding a bow is a button
100
+ * that is lying about what it does. */
101
+ this.glyphs = new Map();
70
102
  this.stickMode = 'floating';
71
103
  this.wantLook = true;
72
104
  this.topInset = 'max(64px, 12%)';
@@ -87,6 +119,7 @@ export class Input3D {
87
119
  // Losing focus mid-press would otherwise leave the character walking.
88
120
  this.target.addEventListener('blur', this.onBlur);
89
121
  this.actions = opts.actions ?? [];
122
+ this.jumpIcon = opts.jumpIcon;
90
123
  this.stickMode = opts.stick ?? 'floating';
91
124
  this.wantLook = opts.look ?? true;
92
125
  this.topInset = opts.controlsTopInset ?? 'max(64px, 12%)';
@@ -175,6 +208,25 @@ export class Input3D {
175
208
  get jump() {
176
209
  return this.enabled && this.held_('jump', this.touchJump || this.isDown('Space'));
177
210
  }
211
+ /**
212
+ * Change what a button SHOWS, without rebuilding the controls.
213
+ *
214
+ * `id` is an action's id, or `'jump'` for the platform's own button. Passing
215
+ * a url the button had no icon for at build time does nothing: the glyph is
216
+ * an element that only exists when one was given, and quietly growing one
217
+ * would move the label out from under a game that was relying on it.
218
+ *
219
+ * This is for buttons whose MEANING changes — an attack button under a bow
220
+ * and under a staff are two different actions wearing one control.
221
+ */
222
+ setActionIcon(id, icon) {
223
+ const g = this.glyphs.get(id);
224
+ if (!g)
225
+ return;
226
+ const u = `url("${icon}")`;
227
+ g.style.setProperty('mask-image', u);
228
+ g.style.setProperty('-webkit-mask-image', u);
229
+ }
178
230
  /** Whether an action is held right now — button or bound key, same answer. */
179
231
  held(id) {
180
232
  if (!this.enabled)
@@ -333,8 +385,8 @@ export class Input3D {
333
385
  Object.assign(pad.style, floating ? {
334
386
  position: 'absolute', width: '30vmin', height: '30vmin',
335
387
  maxWidth: '180px', maxHeight: '180px',
336
- borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
337
- border: '2px solid rgba(255,255,255,0.35)',
388
+ borderRadius: '50%', background: CONTROL.padFill,
389
+ border: `2px solid ${CONTROL.padRim}`,
338
390
  // Never interactive when floating: the ZONE owns the pointer, and a pad
339
391
  // that also captured it would steal the very first move event as the
340
392
  // thumb crosses its edge.
@@ -344,13 +396,13 @@ export class Input3D {
344
396
  } : {
345
397
  position: 'absolute', left: '5vmin', bottom: '5vmin',
346
398
  width: '30vmin', height: '30vmin', maxWidth: '180px', maxHeight: '180px',
347
- borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
348
- border: '2px solid rgba(255,255,255,0.35)', pointerEvents: 'auto',
399
+ borderRadius: '50%', background: CONTROL.padFill,
400
+ border: `2px solid ${CONTROL.padRim}`, pointerEvents: 'auto',
349
401
  });
350
402
  const knob = document.createElement('div');
351
403
  Object.assign(knob.style, {
352
404
  position: 'absolute', left: '50%', top: '50%', width: '40%', height: '40%',
353
- borderRadius: '50%', background: 'rgba(255,255,255,0.55)',
405
+ borderRadius: '50%', background: CONTROL.ink,
354
406
  transform: 'translate(-50%, -50%)', pointerEvents: 'none',
355
407
  });
356
408
  pad.appendChild(knob);
@@ -365,28 +417,62 @@ export class Input3D {
365
417
  flexWrap: 'wrap-reverse', justifyContent: 'flex-start',
366
418
  gap: '3vmin', maxWidth: '52vmin', pointerEvents: 'none',
367
419
  });
368
- const makeButton = (label) => {
420
+ /** Returns the button; its glyph, if it has one, is left in `glyph`. */
421
+ let glyph = null;
422
+ const makeButton = (label, icon) => {
423
+ glyph = null;
369
424
  const el = document.createElement('div');
370
425
  Object.assign(el.style, {
371
426
  width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
372
427
  flex: '0 0 auto',
373
- borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
374
- border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
428
+ borderRadius: '50%', background: CONTROL.fill,
429
+ border: `2px solid ${CONTROL.rim}`, pointerEvents: 'auto',
375
430
  display: 'flex', alignItems: 'center', justifyContent: 'center',
376
- color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
431
+ color: CONTROL.ink, font: '600 4vmin/1 system-ui, sans-serif',
377
432
  });
378
- el.textContent = label;
379
- // Explicitly, not by inheritance. The label IS text -- a glyph in a div
380
- // -- and long-pressing the attack button is how the iOS Copy / Look Up /
433
+ if (icon) {
434
+ // A MASK, not an <img>. The file's own colour is thrown away and the
435
+ // shape is painted in the button's `color`, so an icon keeps matching
436
+ // the controls beside it if those are ever restyled — and one file can
437
+ // serve a light layout and a dark one.
438
+ const g = document.createElement('span');
439
+ const u = `url("${icon}")`;
440
+ Object.assign(g.style, {
441
+ width: '45%', height: '45%', display: 'block',
442
+ backgroundColor: 'currentColor',
443
+ webkitMask: `${u} center/contain no-repeat`,
444
+ mask: `${u} center/contain no-repeat`,
445
+ });
446
+ // Safari still wants the prefixed longhands; the shorthand above is not
447
+ // enough there, and a mask that does not apply is a SOLID SQUARE rather
448
+ // than nothing — the most visible possible failure.
449
+ g.style.setProperty('-webkit-mask-image', u);
450
+ g.style.setProperty('-webkit-mask-size', 'contain');
451
+ g.style.setProperty('-webkit-mask-repeat', 'no-repeat');
452
+ g.style.setProperty('-webkit-mask-position', 'center');
453
+ el.append(g);
454
+ glyph = g;
455
+ }
456
+ else {
457
+ el.textContent = label;
458
+ }
459
+ // Explicitly, not by inheritance. A label IS text -- a glyph in a div --
460
+ // and long-pressing the attack button is how the iOS Copy / Look Up /
381
461
  // Translate callout came up mid-fight, with the selection handles
382
- // clamped around the little crossed swords.
462
+ // clamped around the little crossed swords. Which is most of why `icon`
463
+ // exists.
383
464
  Object.assign(el.style, NO_SELECTION);
384
465
  return el;
385
466
  };
386
- const btn = makeButton('▲');
467
+ this.glyphs.clear();
468
+ const btn = makeButton('▲', this.jumpIcon);
469
+ if (glyph)
470
+ this.glyphs.set('jump', glyph);
387
471
  cluster.append(btn);
388
472
  for (const a of this.actions) {
389
- const el = makeButton(a.label ?? a.id.slice(0, 1).toUpperCase());
473
+ const el = makeButton(a.label ?? a.id.slice(0, 1).toUpperCase(), a.icon);
474
+ if (glyph)
475
+ this.glyphs.set(a.id, glyph);
390
476
  cluster.append(el);
391
477
  this.wireButton(el, a.id);
392
478
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",