@umicat/three-sdk 0.12.0 → 0.14.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
@@ -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 + jump button. `'auto'` (the default) adds them when
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))
@@ -406,10 +409,9 @@ export class Input3D {
406
409
  transform: 'translate(-50%, -50%)', pointerEvents: 'none',
407
410
  });
408
411
  pad.appendChild(knob);
409
- // ONE cluster holds every button, laid out by flexbox, because the bug this
410
- // replaces was two buttons independently choosing "bottom right" and
411
- // landing on top of each other. Jump sits at the corner where it always
412
- // was; actions stack to its left and wrap upward.
412
+ // ONE cluster holds every button, because the bug this replaces was two of
413
+ // them independently choosing "bottom right" and landing on top of each
414
+ // other. What it does with them is `layOnArc` below.
413
415
  const cluster = document.createElement('div');
414
416
  Object.assign(cluster.style, {
415
417
  position: 'absolute', right: '6vmin', bottom: '7vmin',
@@ -417,6 +419,53 @@ export class Input3D {
417
419
  flexWrap: 'wrap-reverse', justifyContent: 'flex-start',
418
420
  gap: '3vmin', maxWidth: '52vmin', pointerEvents: 'none',
419
421
  });
422
+ /**
423
+ * Put the buttons on the arc a THUMB actually sweeps.
424
+ *
425
+ * They were a wrapping flex row, which is how they ended up in a straight
426
+ * line across the bottom of the screen the moment a game had exactly two —
427
+ * the third button had been the only thing forcing a wrap, so removing it
428
+ * silently changed the layout of the two that were left. A row is also the
429
+ * wrong shape regardless: a thumb is hinged at the bottom corner and moves
430
+ * on an arc around it, so buttons at the same HEIGHT are at different
431
+ * reaches, and the far one is the one that gets missed.
432
+ *
433
+ * So: a quarter circle centred on the corner. Index 0 sits low and to the
434
+ * left, and each one after it steps up and in toward the edge, every one of
435
+ * them the same distance from the thumb's hinge.
436
+ *
437
+ * The radius is DERIVED, not chosen: neighbours must be at least a button
438
+ * apart along the chord, so a wider sweep — which is what more buttons
439
+ * need — pushes the arc out. Past three the radius it asks for has the
440
+ * buttons halfway up the screen, so the old wrapping row is kept for that
441
+ * case rather than pretending an arc still fits.
442
+ */
443
+ const layOnArc = (els) => {
444
+ const S = 20; // vmin — the button size, below
445
+ const [A0, A1] = [16, 74]; // degrees, the comfortable sweep
446
+ if (els.length > 3)
447
+ return; // the flex row stays as it is
448
+ const step = els.length > 1 ? (A1 - A0) / (els.length - 1) : 0;
449
+ // Chord between neighbours = 2R·sin(step/2), and it may not be smaller
450
+ // than a button plus a little air.
451
+ const need = els.length > 1 ? (S * 1.12) / (2 * Math.sin((step * Math.PI) / 360)) : S * 1.3;
452
+ const R = Math.max(S * 1.3, need);
453
+ cluster.style.display = 'block';
454
+ cluster.style.width = '0';
455
+ cluster.style.height = '0';
456
+ els.forEach((el, i) => {
457
+ const t = ((A0 + step * i) * Math.PI) / 180;
458
+ // From the corner, in vmin, then back off by half a button so the
459
+ // ARC passes through each button's middle rather than its corner.
460
+ const dx = (R * Math.cos(t)).toFixed(2);
461
+ const dy = (R * Math.sin(t)).toFixed(2);
462
+ Object.assign(el.style, {
463
+ position: 'absolute',
464
+ right: `calc(${dx}vmin - min(${S / 2}vmin, 60px))`,
465
+ bottom: `calc(${dy}vmin - min(${S / 2}vmin, 60px))`,
466
+ });
467
+ });
468
+ };
420
469
  /** Returns the button; its glyph, if it has one, is left in `glyph`. */
421
470
  let glyph = null;
422
471
  const makeButton = (label, icon) => {
@@ -465,17 +514,33 @@ export class Input3D {
465
514
  return el;
466
515
  };
467
516
  this.glyphs.clear();
468
- const btn = makeButton('▲', this.jumpIcon);
469
- if (glyph)
470
- this.glyphs.set('jump', glyph);
471
- cluster.append(btn);
472
- for (const a of this.actions) {
473
- const el = makeButton(a.label ?? a.id.slice(0, 1).toUpperCase(), a.icon);
517
+ // ONE LIST, and the game decides what is in it.
518
+ //
519
+ // Jump used to be built here on its own, before the loop, which is what
520
+ // made it the one button a game could not decline — it arrived with the
521
+ // thumbstick whether the game had a jump or not. It is an entry in the
522
+ // list now, present unless `jump: false`, and everything after it is
523
+ // whatever the game declared. What this class owns is PLACING them.
524
+ const buttons = [
525
+ ...(this.hasJump ? [{ id: 'jump', label: '▲', icon: this.jumpIcon }] : []),
526
+ ...this.actions.map((a) => ({ id: a.id, label: a.label ?? a.id.slice(0, 1).toUpperCase(), icon: a.icon })),
527
+ ];
528
+ let jumpBtn = null;
529
+ const made = [];
530
+ for (const b of buttons) {
531
+ const el = makeButton(b.label, b.icon);
474
532
  if (glyph)
475
- this.glyphs.set(a.id, glyph);
533
+ this.glyphs.set(b.id, glyph);
476
534
  cluster.append(el);
477
- this.wireButton(el, a.id);
535
+ made.push(el);
536
+ // Jump is the platform's own: its press is read through `get jump()`
537
+ // rather than through the action latch, so it is wired below instead.
538
+ if (b.id === 'jump')
539
+ jumpBtn = el;
540
+ else
541
+ this.wireButton(el, b.id);
478
542
  }
543
+ layOnArc(made);
479
544
  root.append(zone, lookZone, pad, cluster);
480
545
  container.appendChild(root);
481
546
  this.root = root;
@@ -613,13 +678,16 @@ export class Input3D {
613
678
  on(grip, ev, (e) => { if (e.pointerId === stickId)
614
679
  reset(); });
615
680
  }
616
- on(btn, 'pointerdown', (e) => {
617
- btn.setPointerCapture(e.pointerId);
618
- this.touchJump = true;
619
- this.beginPress('jump');
620
- });
621
- for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
622
- on(btn, ev, () => { this.touchJump = false; });
681
+ if (jumpBtn) {
682
+ const jb = jumpBtn;
683
+ on(jb, 'pointerdown', (e) => {
684
+ jb.setPointerCapture(e.pointerId);
685
+ this.touchJump = true;
686
+ this.beginPress('jump');
687
+ });
688
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
689
+ on(jb, ev, () => { this.touchJump = false; });
690
+ }
623
691
  }
624
692
  }
625
693
  wireButton(el, id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",