@umicat/three-sdk 0.11.0 → 0.13.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 +21 -4
- package/dist/GameAudio.d.ts +40 -0
- package/dist/GameAudio.js +79 -18
- package/dist/Input3D.d.ts +20 -2
- package/dist/Input3D.js +36 -17
- package/package.json +1 -1
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:
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
package/dist/GameAudio.d.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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/dist/Input3D.d.ts
CHANGED
|
@@ -27,9 +27,10 @@ export interface Input3DOptions {
|
|
|
27
27
|
/** Where key events are read from. */
|
|
28
28
|
target?: Window;
|
|
29
29
|
/**
|
|
30
|
-
* On-screen thumbstick
|
|
30
|
+
* On-screen thumbstick and buttons. `'auto'` (the default) adds them when
|
|
31
31
|
* the device reports coarse pointers and no fine one — i.e. a phone, not a
|
|
32
|
-
* laptop with a touchscreen.
|
|
32
|
+
* laptop with a touchscreen. What the cluster CONTAINS is a separate
|
|
33
|
+
* question — see `jump` and `actions`.
|
|
33
34
|
*/
|
|
34
35
|
touch?: boolean | 'auto';
|
|
35
36
|
/**
|
|
@@ -52,6 +53,21 @@ export interface Input3DOptions {
|
|
|
52
53
|
/** A shape for the JUMP button, in place of its `▲`. Same reasoning as
|
|
53
54
|
* `Input3DAction.icon`, for the one button the platform owns outright. */
|
|
54
55
|
jumpIcon?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether this game HAS a jump. Default `true`.
|
|
58
|
+
*
|
|
59
|
+
* Jumping is not universal — a game played by walking around a board does
|
|
60
|
+
* not want a jump button, and it was getting one anyway because the button
|
|
61
|
+
* came bundled with the thumbstick under `touch`. Turning `touch` off to be
|
|
62
|
+
* rid of it takes the stick with it, which is not a trade anyone would make.
|
|
63
|
+
*
|
|
64
|
+
* `false` removes the on-screen button AND makes `jump` permanently false,
|
|
65
|
+
* which also hands **`Space` back to the game**. Those two belong together:
|
|
66
|
+
* Space is read here as jump, so a game with no jump that binds Space to
|
|
67
|
+
* something of its own would otherwise have the hero hop every time the
|
|
68
|
+
* player used it.
|
|
69
|
+
*/
|
|
70
|
+
jump?: boolean;
|
|
55
71
|
/**
|
|
56
72
|
* How the thumbstick behaves on touch.
|
|
57
73
|
*
|
|
@@ -111,6 +127,8 @@ export declare class Input3D {
|
|
|
111
127
|
private touchJump;
|
|
112
128
|
private actions;
|
|
113
129
|
private jumpIcon?;
|
|
130
|
+
/** Whether this game has a jump at all — see `Input3DOptions.jump`. */
|
|
131
|
+
private readonly hasJump;
|
|
114
132
|
/** The glyph inside each on-screen button, by action id (`'jump'` for the
|
|
115
133
|
* platform's own). Kept so an icon can be CHANGED while the game runs —
|
|
116
134
|
* which is the point of it for anything the player switches: an attack
|
package/dist/Input3D.js
CHANGED
|
@@ -71,7 +71,7 @@ export class Input3D {
|
|
|
71
71
|
this.onDown = (e) => {
|
|
72
72
|
this.keysDown.add(e.code);
|
|
73
73
|
if (!e.repeat) {
|
|
74
|
-
if (e.code === 'Space')
|
|
74
|
+
if (e.code === 'Space' && this.hasJump)
|
|
75
75
|
this.beginPress('jump');
|
|
76
76
|
for (const a of this.actions)
|
|
77
77
|
if (a.keys?.includes(e.code))
|
|
@@ -120,6 +120,7 @@ export class Input3D {
|
|
|
120
120
|
this.target.addEventListener('blur', this.onBlur);
|
|
121
121
|
this.actions = opts.actions ?? [];
|
|
122
122
|
this.jumpIcon = opts.jumpIcon;
|
|
123
|
+
this.hasJump = opts.jump ?? true;
|
|
123
124
|
this.stickMode = opts.stick ?? 'floating';
|
|
124
125
|
this.wantLook = opts.look ?? true;
|
|
125
126
|
this.topInset = opts.controlsTopInset ?? 'max(64px, 12%)';
|
|
@@ -206,6 +207,8 @@ export class Input3D {
|
|
|
206
207
|
/** Whether the jump control is held right now. Pass it to the controller —
|
|
207
208
|
* coyote time and buffering live there, not here. */
|
|
208
209
|
get jump() {
|
|
210
|
+
if (!this.hasJump)
|
|
211
|
+
return false;
|
|
209
212
|
return this.enabled && this.held_('jump', this.touchJump || this.isDown('Space'));
|
|
210
213
|
}
|
|
211
214
|
/**
|
|
@@ -278,7 +281,7 @@ export class Input3D {
|
|
|
278
281
|
if (this.keysDown.has(code))
|
|
279
282
|
return; // held, not re-pressed
|
|
280
283
|
this.keysDown.add(code);
|
|
281
|
-
if (code === 'Space')
|
|
284
|
+
if (code === 'Space' && this.hasJump)
|
|
282
285
|
this.beginPress('jump');
|
|
283
286
|
for (const a of this.actions)
|
|
284
287
|
if (a.keys?.includes(code))
|
|
@@ -465,16 +468,29 @@ export class Input3D {
|
|
|
465
468
|
return el;
|
|
466
469
|
};
|
|
467
470
|
this.glyphs.clear();
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
471
|
+
// ONE LIST, and the game decides what is in it.
|
|
472
|
+
//
|
|
473
|
+
// Jump used to be built here on its own, before the loop, which is what
|
|
474
|
+
// made it the one button a game could not decline — it arrived with the
|
|
475
|
+
// thumbstick whether the game had a jump or not. It is an entry in the
|
|
476
|
+
// list now, present unless `jump: false`, and everything after it is
|
|
477
|
+
// whatever the game declared. What this class owns is PLACING them.
|
|
478
|
+
const buttons = [
|
|
479
|
+
...(this.hasJump ? [{ id: 'jump', label: '▲', icon: this.jumpIcon }] : []),
|
|
480
|
+
...this.actions.map((a) => ({ id: a.id, label: a.label ?? a.id.slice(0, 1).toUpperCase(), icon: a.icon })),
|
|
481
|
+
];
|
|
482
|
+
let jumpBtn = null;
|
|
483
|
+
for (const b of buttons) {
|
|
484
|
+
const el = makeButton(b.label, b.icon);
|
|
474
485
|
if (glyph)
|
|
475
|
-
this.glyphs.set(
|
|
486
|
+
this.glyphs.set(b.id, glyph);
|
|
476
487
|
cluster.append(el);
|
|
477
|
-
|
|
488
|
+
// Jump is the platform's own: its press is read through `get jump()`
|
|
489
|
+
// rather than through the action latch, so it is wired below instead.
|
|
490
|
+
if (b.id === 'jump')
|
|
491
|
+
jumpBtn = el;
|
|
492
|
+
else
|
|
493
|
+
this.wireButton(el, b.id);
|
|
478
494
|
}
|
|
479
495
|
root.append(zone, lookZone, pad, cluster);
|
|
480
496
|
container.appendChild(root);
|
|
@@ -613,13 +629,16 @@ export class Input3D {
|
|
|
613
629
|
on(grip, ev, (e) => { if (e.pointerId === stickId)
|
|
614
630
|
reset(); });
|
|
615
631
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
632
|
+
if (jumpBtn) {
|
|
633
|
+
const jb = jumpBtn;
|
|
634
|
+
on(jb, 'pointerdown', (e) => {
|
|
635
|
+
jb.setPointerCapture(e.pointerId);
|
|
636
|
+
this.touchJump = true;
|
|
637
|
+
this.beginPress('jump');
|
|
638
|
+
});
|
|
639
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
|
640
|
+
on(jb, ev, () => { this.touchJump = false; });
|
|
641
|
+
}
|
|
623
642
|
}
|
|
624
643
|
}
|
|
625
644
|
wireButton(el, id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|