@umicat/three-sdk 0.11.0 → 0.12.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 CHANGED
@@ -3,15 +3,27 @@
3
3
  The three.js runtime for Umicat games, per ADR-033. Platform services come from
4
4
  `@umicat/platform-sdk` untouched; what lives here is the engine layer we own.
5
5
 
6
- **Status: seed, not a product.** It loads a scene, runs physics, and proves the
7
- platform seam. It has no editor, no input system, no character controller, no
8
- audio, and no published package. Nothing in production uses it.
6
+ **Status: published and in use.** `@umicat/three-sdk` is on npm; Balaboo (a
7
+ tower defence that ships from `woodland/`) is built on it, and `umicat-template-3d`
8
+ is what new 3D projects start from.
9
+
10
+ What it has: the scene3d format and loader, physics wiring, a character
11
+ controller and animator, an input layer that mounts on-screen controls on touch
12
+ devices and binds keys everywhere, bone sockets, hit tints, and audio. What it
13
+ still does not have: an editor.
14
+
15
+ > This paragraph said "seed, not a product — no input system, no character
16
+ > controller, no audio, and no published package" for ten minor versions after
17
+ > each of those arrived. A status line is the first thing anyone reads and the
18
+ > last thing anyone updates.
9
19
 
10
20
  ```
11
21
  @umicat/platform-sdk identity · saves · gameData · rooms · ai · voice · dialogue
12
22
  ▲ (shared with @umicat/phaser-sdk)
13
23
  │
14
- @umicat/three-sdk ThreeUmicat · scene3d format · loadScene3D · physics wiring
24
+ @umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
25
+ CharacterController3D · CharacterAnimator · GameAudio
26
+ sockets · tints
15
27
  ▲
16
28
  your game gameplay
17
29
  ```
@@ -31,6 +43,11 @@ mocked — every line of SDK is the shipped code.**
31
43
  | physics runs | a crate dropped from y=6 settles on the ground and does not fall through |
32
44
  | design mode is render-only | same entities, zero mixers, zero bodies |
33
45
  | authoring mistakes fail loudly | duplicate ids and missing clips throw at load, naming the clips that exist |
46
+ | music and effects are two volumes | the clip's gain hangs off `sfxGain`, not the master — `audio-volume.test.mjs` |
47
+
48
+ Eight suites, not one: the table above is the platform seam. The others cover
49
+ the character controller, the animator, sockets, tints, input icons, the
50
+ courtyard sample and the audio graph.
34
51
 
35
52
  `slice.png` is the scene those assertions describe, rendered.
36
53
 
@@ -55,6 +55,10 @@ export interface GameAudioOptions {
55
55
  musicVolume?: number;
56
56
  }
57
57
  export declare class GameAudio {
58
+ /** How long a sound asked for during the unlock may wait for the context. */
59
+ private static readonly PENDING_MS;
60
+ private loading;
61
+ private pending;
58
62
  private ctx;
59
63
  private master;
60
64
  private musicGain;
@@ -82,7 +86,43 @@ export declare class GameAudio {
82
86
  /** Whether sound can be heard right now. Asked of the context every time —
83
87
  * a cached flag is exactly the bug described above. */
84
88
  get ready(): boolean;
89
+ /**
90
+ * Build the graph and start fetching, WITHOUT asking to be heard.
91
+ *
92
+ * A context may be constructed and a buffer decoded while suspended; only
93
+ * `resume()` needs a gesture. Separating the two is what lets `preload()`
94
+ * do the slow half early.
95
+ */
96
+ private ensureContext;
97
+ /**
98
+ * Fetch and decode every clip now, before anything has been pressed.
99
+ *
100
+ * Without this the first gesture of a session does BOTH jobs — it creates
101
+ * the context and it starts the downloads — so a sound asked for on that
102
+ * gesture has no buffer to play and is silently dropped. The gesture in
103
+ * question is usually a title screen's only button, which is the one press
104
+ * every player makes.
105
+ *
106
+ * Safe to call at boot: nothing here needs permission, and on a browser that
107
+ * has not been touched yet the context simply stays suspended until it is.
108
+ * Awaiting it is optional — the point is to have started.
109
+ */
110
+ preload(): Promise<void>;
85
111
  private start;
112
+ /**
113
+ * A sound asked for DURING the unlocking gesture, played the moment the
114
+ * context comes up.
115
+ *
116
+ * `resume()` is asynchronous, so a `play()` made inside the gesture that
117
+ * unlocks audio always finds `ready` false — the press that turns the sound
118
+ * on is the one press that cannot make one. Holding the request for a beat
119
+ * and firing it on `ready` costs nothing and covers exactly that case.
120
+ *
121
+ * One request, not a queue, and only if it is still FRESH. A button's click
122
+ * arriving a second late is worse than no click at all, and replaying a
123
+ * backlog the moment audio comes up is how a game greets you with a chord.
124
+ */
125
+ private flushPending;
86
126
  /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
87
127
  private urlFor;
88
128
  private loadAll;
package/dist/GameAudio.js CHANGED
@@ -35,6 +35,8 @@
35
35
  */
36
36
  export class GameAudio {
37
37
  constructor(opts) {
38
+ this.loading = null;
39
+ this.pending = null;
38
40
  this.ctx = null;
39
41
  this.master = null;
40
42
  this.musicGain = null;
@@ -81,34 +83,85 @@ export class GameAudio {
81
83
  /** Whether sound can be heard right now. Asked of the context every time —
82
84
  * a cached flag is exactly the bug described above. */
83
85
  get ready() { return this.ctx?.state === 'running'; }
84
- async start() {
85
- if (this.ready)
86
- return;
86
+ /**
87
+ * Build the graph and start fetching, WITHOUT asking to be heard.
88
+ *
89
+ * A context may be constructed and a buffer decoded while suspended; only
90
+ * `resume()` needs a gesture. Separating the two is what lets `preload()`
91
+ * do the slow half early.
92
+ */
93
+ ensureContext() {
94
+ if (this.ctx)
95
+ return true;
87
96
  const AC = window.AudioContext
88
97
  ?? window.webkitAudioContext;
89
98
  if (!AC)
99
+ return false;
100
+ this.ctx = new AC();
101
+ this.master = this.ctx.createGain();
102
+ this.master.gain.value = this.muted ? 0 : 1;
103
+ this.master.connect(this.ctx.destination);
104
+ this.musicGain = this.ctx.createGain();
105
+ this.musicGain.gain.value = this.musicVolume;
106
+ this.musicGain.connect(this.master);
107
+ this.sfxGain = this.ctx.createGain();
108
+ this.sfxGain.gain.value = this.sfxVolume;
109
+ this.sfxGain.connect(this.master);
110
+ this.loading ?? (this.loading = this.loadAll());
111
+ return true;
112
+ }
113
+ /**
114
+ * Fetch and decode every clip now, before anything has been pressed.
115
+ *
116
+ * Without this the first gesture of a session does BOTH jobs — it creates
117
+ * the context and it starts the downloads — so a sound asked for on that
118
+ * gesture has no buffer to play and is silently dropped. The gesture in
119
+ * question is usually a title screen's only button, which is the one press
120
+ * every player makes.
121
+ *
122
+ * Safe to call at boot: nothing here needs permission, and on a browser that
123
+ * has not been touched yet the context simply stays suspended until it is.
124
+ * Awaiting it is optional — the point is to have started.
125
+ */
126
+ async preload() {
127
+ if (!this.ensureContext())
128
+ return;
129
+ await (this.loading ?? (this.loading = this.loadAll()));
130
+ }
131
+ async start() {
132
+ if (this.ready)
133
+ return;
134
+ if (!this.ensureContext())
90
135
  return;
91
- if (!this.ctx) {
92
- this.ctx = new AC();
93
- this.master = this.ctx.createGain();
94
- this.master.gain.value = this.muted ? 0 : 1;
95
- this.master.connect(this.ctx.destination);
96
- this.musicGain = this.ctx.createGain();
97
- this.musicGain.gain.value = this.musicVolume;
98
- this.musicGain.connect(this.master);
99
- this.sfxGain = this.ctx.createGain();
100
- this.sfxGain.gain.value = this.sfxVolume;
101
- this.sfxGain.connect(this.master);
102
- void this.loadAll();
103
- }
104
136
  // Called inside the gesture's call stack, and awaited before anything asks
105
137
  // whether it worked.
106
138
  try {
107
139
  await this.ctx.resume();
108
140
  }
109
141
  catch { /* a blocked context is not fatal */ }
110
- if (this.ready)
142
+ if (this.ready) {
111
143
  this.startMusic();
144
+ this.flushPending();
145
+ }
146
+ }
147
+ /**
148
+ * A sound asked for DURING the unlocking gesture, played the moment the
149
+ * context comes up.
150
+ *
151
+ * `resume()` is asynchronous, so a `play()` made inside the gesture that
152
+ * unlocks audio always finds `ready` false — the press that turns the sound
153
+ * on is the one press that cannot make one. Holding the request for a beat
154
+ * and firing it on `ready` costs nothing and covers exactly that case.
155
+ *
156
+ * One request, not a queue, and only if it is still FRESH. A button's click
157
+ * arriving a second late is worse than no click at all, and replaying a
158
+ * backlog the moment audio comes up is how a game greets you with a chord.
159
+ */
160
+ flushPending() {
161
+ const p = this.pending;
162
+ this.pending = null;
163
+ if (p && performance.now() - p.at < GameAudio.PENDING_MS)
164
+ this.play(p.name);
112
165
  }
113
166
  /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
114
167
  urlFor(name) {
@@ -175,8 +228,14 @@ export class GameAudio {
175
228
  }
176
229
  play(name) {
177
230
  const ctx = this.ctx;
178
- if (this.muted || !this.ready || !ctx || !this.master)
231
+ if (this.muted)
232
+ return;
233
+ if (!this.ready || !ctx || !this.master) {
234
+ // Not "drop it": this is the unlocking gesture, and the sound it asked
235
+ // for is the one the player is waiting to hear. `start()` plays it.
236
+ this.pending = { name, at: performance.now() };
179
237
  return;
238
+ }
180
239
  const buf = this.buffers.get(name);
181
240
  if (!buf)
182
241
  return;
@@ -266,3 +325,5 @@ export class GameAudio {
266
325
  this.ctx = null;
267
326
  }
268
327
  }
328
+ /** How long a sound asked for during the unlock may wait for the context. */
329
+ GameAudio.PENDING_MS = 400;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",