@umicat/three-sdk 0.4.0 → 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/dist/Input3D.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /** An extra on-screen button, and the keys that do the same thing. */
2
+ export interface Input3DAction {
3
+ /** What the game calls it: `input.held('attack')`, `input.consume('attack')`. */
4
+ id: string;
5
+ /** What the button shows. Keep it one glyph. */
6
+ label?: string;
7
+ /** Keyboard equivalents, e.g. `['KeyJ']`. */
8
+ keys?: string[];
9
+ }
1
10
  export interface Input3DOptions {
2
11
  /** Where key events are read from. */
3
12
  target?: Window;
@@ -14,6 +23,16 @@ export interface Input3DOptions {
14
23
  * this layer belongs to the platform.
15
24
  */
16
25
  container?: HTMLElement | null;
26
+ /**
27
+ * Extra action buttons — attack, interact, whatever the game has.
28
+ *
29
+ * These exist because a game that mounts its own button has no idea where
30
+ * the jump button is, and the first one to try landed exactly on top of it:
31
+ * same corner, and this layer sits above, so every tap in that area jumped
32
+ * and the attack button could not be pressed at all. Nothing errored. The
33
+ * platform places the controls, so the platform has to place ALL of them.
34
+ */
35
+ actions?: Input3DAction[];
17
36
  }
18
37
  /**
19
38
  * Movement and jump, from a keyboard or a thumb, behind one interface.
@@ -31,13 +50,17 @@ export interface Input3DOptions {
31
50
  * has to branch on input source.
32
51
  */
33
52
  export declare class Input3D {
34
- private readonly held;
53
+ private readonly keysDown;
35
54
  private readonly target;
36
55
  private readonly onDown;
37
56
  private readonly onUp;
38
57
  private readonly onBlur;
39
58
  private readonly stick;
40
59
  private touchJump;
60
+ private actions;
61
+ private readonly touchHeld;
62
+ private readonly pressedAt;
63
+ private readonly unconsumed;
41
64
  private root;
42
65
  private readonly cleanups;
43
66
  constructor(options?: Input3DOptions | Window);
@@ -51,9 +74,21 @@ export declare class Input3D {
51
74
  /** Whether the jump control is held right now. Pass it to the controller —
52
75
  * coyote time and buffering live there, not here. */
53
76
  get jump(): boolean;
77
+ /** Whether an action is held right now — button or bound key, same answer. */
78
+ held(id: string): boolean;
79
+ /** True exactly once per press: one tap is one swing.
80
+ *
81
+ * Edge detection done in the game reads the button's STATE and compares it
82
+ * to last frame's, which means a press that begins and ends between two
83
+ * frames never happened. This latches at the event, so it cannot be missed
84
+ * — and it clears on read, so holding the button does not chain. */
85
+ consume(id: string): boolean;
86
+ private held_;
87
+ private beginPress;
54
88
  /** Test seam: drive the controller without synthesising DOM events. */
55
89
  press(code: string): void;
56
90
  release(code: string): void;
57
91
  dispose(): void;
58
92
  private mountTouch;
93
+ private wireButton;
59
94
  }
package/dist/Input3D.js CHANGED
@@ -1,3 +1,11 @@
1
+ /** How long a press stays readable, however briefly it was actually made.
2
+ *
3
+ * A tap whose down and up both land between two frames is invisible to a
4
+ * loop that polls state — the game samples `false`, `false`, and the player
5
+ * swears the button did nothing. Holding it for a few frames costs nothing
6
+ * and makes a quick tap always count. */
7
+ const MIN_PRESS_MS = 80;
8
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
1
9
  const isTouchDevice = () => typeof matchMedia === 'function' &&
2
10
  matchMedia('(pointer: coarse)').matches &&
3
11
  !matchMedia('(pointer: fine)').matches;
@@ -18,18 +26,35 @@ const isTouchDevice = () => typeof matchMedia === 'function' &&
18
26
  */
19
27
  export class Input3D {
20
28
  constructor(options = {}) {
21
- this.held = new Set();
29
+ this.keysDown = new Set();
22
30
  this.onDown = (e) => {
23
- this.held.add(e.code);
31
+ this.keysDown.add(e.code);
32
+ if (!e.repeat) {
33
+ if (e.code === 'Space')
34
+ this.beginPress('jump');
35
+ for (const a of this.actions)
36
+ if (a.keys?.includes(e.code))
37
+ this.beginPress(a.id);
38
+ }
24
39
  // Space scrolls the page, which in an embedded game scrolls the HOST page
25
40
  // out from under the player.
26
41
  if (e.code === 'Space')
27
42
  e.preventDefault();
28
43
  };
29
- this.onUp = (e) => { this.held.delete(e.code); };
30
- this.onBlur = () => { this.held.clear(); this.stick.x = 0; this.stick.z = 0; this.touchJump = false; };
44
+ this.onUp = (e) => { this.keysDown.delete(e.code); };
45
+ this.onBlur = () => {
46
+ this.keysDown.clear();
47
+ this.stick.x = 0;
48
+ this.stick.z = 0;
49
+ this.touchJump = false;
50
+ this.touchHeld.clear();
51
+ };
31
52
  this.stick = { x: 0, z: 0 };
32
53
  this.touchJump = false;
54
+ this.actions = [];
55
+ this.touchHeld = new Set();
56
+ this.pressedAt = new Map();
57
+ this.unconsumed = new Set();
33
58
  this.root = null;
34
59
  this.cleanups = [];
35
60
  // A Window here is the pre-touch signature; keep it working.
@@ -40,12 +65,13 @@ export class Input3D {
40
65
  this.target.addEventListener('keyup', this.onUp);
41
66
  // Losing focus mid-press would otherwise leave the character walking.
42
67
  this.target.addEventListener('blur', this.onBlur);
68
+ this.actions = opts.actions ?? [];
43
69
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
44
70
  if (wantTouch && typeof document !== 'undefined') {
45
71
  this.mountTouch(opts.container ?? document.body);
46
72
  }
47
73
  }
48
- isDown(...codes) { return codes.some((c) => this.held.has(c)); }
74
+ isDown(...codes) { return codes.some((c) => this.keysDown.has(c)); }
49
75
  /** Camera-relative would need the camera; this is world-axis movement.
50
76
  * Keyboard and stick are merged, so both work on a device with both. */
51
77
  direction() {
@@ -64,10 +90,32 @@ export class Input3D {
64
90
  }
65
91
  /** Whether the jump control is held right now. Pass it to the controller —
66
92
  * coyote time and buffering live there, not here. */
67
- get jump() { return this.touchJump || this.isDown('Space'); }
93
+ get jump() { return this.held_('jump', this.touchJump || this.isDown('Space')); }
94
+ /** Whether an action is held right now — button or bound key, same answer. */
95
+ held(id) {
96
+ const a = this.actions.find((x) => x.id === id);
97
+ return this.held_(id, this.touchHeld.has(id) || (a?.keys ? this.isDown(...a.keys) : false));
98
+ }
99
+ /** True exactly once per press: one tap is one swing.
100
+ *
101
+ * Edge detection done in the game reads the button's STATE and compares it
102
+ * to last frame's, which means a press that begins and ends between two
103
+ * frames never happened. This latches at the event, so it cannot be missed
104
+ * — and it clears on read, so holding the button does not chain. */
105
+ consume(id) { return this.unconsumed.delete(id); }
106
+ held_(id, physical) {
107
+ if (physical)
108
+ return true;
109
+ const t = this.pressedAt.get(id);
110
+ return t !== undefined && now() - t < MIN_PRESS_MS;
111
+ }
112
+ beginPress(id) {
113
+ this.pressedAt.set(id, now());
114
+ this.unconsumed.add(id);
115
+ }
68
116
  /** Test seam: drive the controller without synthesising DOM events. */
69
- press(code) { this.held.add(code); }
70
- release(code) { this.held.delete(code); }
117
+ press(code) { this.keysDown.add(code); }
118
+ release(code) { this.keysDown.delete(code); }
71
119
  dispose() {
72
120
  this.target.removeEventListener('keydown', this.onDown);
73
121
  this.target.removeEventListener('keyup', this.onUp);
@@ -77,7 +125,7 @@ export class Input3D {
77
125
  this.cleanups.length = 0;
78
126
  this.root?.remove();
79
127
  this.root = null;
80
- this.held.clear();
128
+ this.keysDown.clear();
81
129
  }
82
130
  // ── on-screen controls ────────────────────────────────────────────────────
83
131
  mountTouch(container) {
@@ -106,17 +154,38 @@ export class Input3D {
106
154
  transform: 'translate(-50%, -50%)', pointerEvents: 'none',
107
155
  });
108
156
  pad.appendChild(knob);
109
- const btn = document.createElement('div');
110
- Object.assign(btn.style, {
157
+ // ONE cluster holds every button, laid out by flexbox, because the bug this
158
+ // replaces was two buttons independently choosing "bottom right" and
159
+ // landing on top of each other. Jump sits at the corner where it always
160
+ // was; actions stack to its left and wrap upward.
161
+ const cluster = document.createElement('div');
162
+ Object.assign(cluster.style, {
111
163
  position: 'absolute', right: '6vmin', bottom: '7vmin',
112
- width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
113
- borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
114
- border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
115
- display: 'flex', alignItems: 'center', justifyContent: 'center',
116
- color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
164
+ display: 'flex', flexDirection: 'row-reverse', alignItems: 'flex-end',
165
+ flexWrap: 'wrap-reverse', justifyContent: 'flex-start',
166
+ gap: '3vmin', maxWidth: '52vmin', pointerEvents: 'none',
117
167
  });
118
- btn.textContent = '▲';
119
- root.append(pad, btn);
168
+ const makeButton = (label) => {
169
+ const el = document.createElement('div');
170
+ Object.assign(el.style, {
171
+ width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
172
+ flex: '0 0 auto',
173
+ borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
174
+ border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
175
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
176
+ color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
177
+ });
178
+ el.textContent = label;
179
+ return el;
180
+ };
181
+ const btn = makeButton('▲');
182
+ cluster.append(btn);
183
+ for (const a of this.actions) {
184
+ const el = makeButton(a.label ?? a.id.slice(0, 1).toUpperCase());
185
+ cluster.append(el);
186
+ this.wireButton(el, a.id);
187
+ }
188
+ root.append(pad, cluster);
120
189
  container.appendChild(root);
121
190
  this.root = root;
122
191
  // Track by pointerId so a thumb on the stick and a thumb on the button do
@@ -158,9 +227,31 @@ export class Input3D {
158
227
  on(pad, ev, (e) => { if (e.pointerId === stickId)
159
228
  reset(); });
160
229
  }
161
- on(btn, 'pointerdown', (e) => { btn.setPointerCapture(e.pointerId); this.touchJump = true; });
230
+ on(btn, 'pointerdown', (e) => {
231
+ btn.setPointerCapture(e.pointerId);
232
+ this.touchJump = true;
233
+ this.beginPress('jump');
234
+ });
162
235
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
163
236
  on(btn, ev, () => { this.touchJump = false; });
164
237
  }
165
238
  }
239
+ wireButton(el, id) {
240
+ const on = (ev, fn) => {
241
+ const h = fn;
242
+ el.addEventListener(ev, h);
243
+ this.cleanups.push(() => el.removeEventListener(ev, h));
244
+ };
245
+ on('pointerdown', (e) => {
246
+ e.preventDefault();
247
+ el.setPointerCapture(e.pointerId);
248
+ this.touchHeld.add(id);
249
+ this.beginPress(id);
250
+ });
251
+ // pointercancel included: a system gesture steals the pointer with no `up`,
252
+ // and the button would read as held for the rest of the run.
253
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
254
+ on(ev, () => { this.touchHeld.delete(id); });
255
+ }
256
+ }
166
257
  }
@@ -21,6 +21,33 @@ export class ThreeUmicat extends UmicatCore {
21
21
  this.dialogue = new DialogueModule(this.saves, () => transport.locale ?? 'en', () => transport.user?.name ?? null);
22
22
  }
23
23
  static async init(options = {}) {
24
- return new ThreeUmicat(await UmicatCore.connect(SDK_VERSION, options));
24
+ const game = new ThreeUmicat(await UmicatCore.connect(SDK_VERSION, options));
25
+ announceScaleMode();
26
+ return game;
27
+ }
28
+ }
29
+ /**
30
+ * Tell the host this game fills the frame.
31
+ *
32
+ * The host letterboxes to the game's authored aspect UNLESS the game says
33
+ * otherwise, and it reads silence as "built before the SDK announced anything"
34
+ * — which every 3D game was, because only the Phaser SDK ever sent this. So a
35
+ * 3D game on a phone rendered into a 16:9 box with black down the side, losing
36
+ * a third of the screen, and the only symptom was a black band nobody could
37
+ * attribute to anything.
38
+ *
39
+ * A 3D game is always `resize`: the camera's aspect follows the window, so
40
+ * there is no authored pixel canvas to preserve. That is a property of drawing
41
+ * in 3D, not a per-game preference, which is why this is not an option.
42
+ */
43
+ function announceScaleMode() {
44
+ try {
45
+ // Safe no-op when not embedded — the same game runs standalone in dev.
46
+ if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
47
+ window.parent.postMessage({ type: 'umicat:scaleMode', mode: 'resize' }, '*');
48
+ }
49
+ }
50
+ catch {
51
+ /* cross-origin parent access can throw in odd embeds — non-fatal */
25
52
  }
26
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",