tfrere HF Staff Cursor commited on
Commit
013fb39
·
1 Parent(s): 183f99a

feat: gamepad haptics engine, roller-mode ghost rigs, sound re-enable

Browse files
app/src/game/audio.js CHANGED
@@ -21,7 +21,7 @@ import { signed } from "./signed.js";
21
  // point early-returns, master gain pinned to 0, context never resumed).
22
  // Node-returning helpers (busNode, createEmitter) keep building their
23
  // graph either way so callers stay untouched.
24
- const SOUND_DISABLED = true;
25
 
26
  // ── Context, master, buses ────────────────────────────────────────────
27
  const MASTER_LEVEL = 0.9;
 
21
  // point early-returns, master gain pinned to 0, context never resumed).
22
  // Node-returning helpers (busNode, createEmitter) keep building their
23
  // graph either way so callers stay untouched.
24
+ const SOUND_DISABLED = false;
25
 
26
  // ── Context, master, buses ────────────────────────────────────────────
27
  const MASTER_LEVEL = 0.9;
app/src/game/controls/gamepad.js CHANGED
@@ -46,6 +46,15 @@ const BTN_R3 = 11, BTN_DPAD_UP = 12, BTN_DPAD_DOWN = 13;
46
 
47
  const DPAD_UP_HOLD_MS = 1000; // hold-to-switch-loco duration
48
 
 
 
 
 
 
 
 
 
 
49
  export class GamepadSource {
50
  id = "gamepad";
51
  connected = false;
@@ -82,12 +91,10 @@ export class GamepadSource {
82
 
83
  poll() {
84
  const prev = this.pressed;
85
- // Prefer a standard-mapping pad: the button indices above are only
86
- // guaranteed by the "standard" layout. A non-standard pad (mapping "")
87
- // is a last-resort fallback - its indices are firmware-defined and
88
- // some mirror one combined trigger channel onto both trigger slots.
89
- const pads = [...(navigator.getGamepads?.() ?? [])].filter((p) => p && p.connected);
90
- const gp = pads.find((p) => p.mapping === "standard") ?? pads[0];
91
  this.connected = !!gp;
92
  if (!gp) {
93
  if (this.#active) {
 
46
 
47
  const DPAD_UP_HOLD_MS = 1000; // hold-to-switch-loco duration
48
 
49
+ // The one pad-picking rule, shared by poll() and the haptics engine
50
+ // (haptics.js): prefer a standard-mapping pad - the button indices above
51
+ // are only guaranteed by the "standard" layout - and fall back to
52
+ // whatever is connected.
53
+ export function pickPad() {
54
+ const pads = [...(navigator.getGamepads?.() ?? [])].filter((p) => p && p.connected);
55
+ return pads.find((p) => p.mapping === "standard") ?? pads[0];
56
+ }
57
+
58
  export class GamepadSource {
59
  id = "gamepad";
60
  connected = false;
 
91
 
92
  poll() {
93
  const prev = this.pressed;
94
+ // A non-standard pad (mapping "") is a last-resort fallback in
95
+ // pickPad - its indices are firmware-defined and some mirror one
96
+ // combined trigger channel onto both trigger slots.
97
+ const gp = pickPad();
 
 
98
  this.connected = !!gp;
99
  if (!gp) {
100
  if (this.#active) {
app/src/game/game.js CHANGED
@@ -45,6 +45,7 @@ import {
45
  import { Controller } from "./controls/controller.js";
46
  import { KeyboardSource } from "./controls/keyboard.js";
47
  import { GamepadSource } from "./controls/gamepad.js";
 
48
  import { TouchSource } from "./controls/touch.js";
49
  import * as fx from "./fx/fx-wireframe.js";
50
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
@@ -769,12 +770,12 @@ async function boot({ scene, camera, renderer }) {
769
  }
770
  }
771
  }
772
- // Roller rumble: gain and slight pitch follow ground speed.
 
773
  const speed = Math.hypot(data.qvel[0], data.qvel[1]);
774
- setRumble(
775
- loco === "rollers" && !inputLocked ? Math.min(1, speed / 0.5) : 0,
776
- duckEmitter,
777
- );
778
  // Body bumps: trunk |dv| against a nearby wall/prop. The proximity
779
  // gate keeps gait/kick jerks (which rival slow wall hits) silent.
780
  if (!inputLocked) {
@@ -790,6 +791,7 @@ async function boot({ scene, camera, renderer }) {
790
  rate: 0.55 + 0.15 * u + Math.random() * 0.06, // way below the ball's range
791
  out: duckEmitter.node,
792
  });
 
793
  }
794
  }
795
  bumpPrevV[0] = v[0]; bumpPrevV[1] = v[1]; bumpPrevV[2] = v[2];
@@ -813,6 +815,12 @@ async function boot({ scene, camera, renderer }) {
813
  rate: 1.25 - 0.45 * u + Math.random() * 0.08,
814
  out: ballEmitter.node,
815
  });
 
 
 
 
 
 
816
  }
817
  }
818
  ballPrevV[0] = v[b]; ballPrevV[1] = v[b + 1]; ballPrevV[2] = v[b + 2];
@@ -820,6 +828,9 @@ async function boot({ scene, camera, renderer }) {
820
  } else {
821
  ballPrevValid = false;
822
  }
 
 
 
823
  }
824
 
825
  async function controlStep() {
@@ -842,6 +853,7 @@ async function boot({ scene, camera, renderer }) {
842
 
843
  const death = poseIsDead();
844
  if (death === "exploded") {
 
845
  resetSim();
846
  } else if (recovery) {
847
  // Recovery state machine owns the duck: settle -> stand policy ->
@@ -859,6 +871,7 @@ async function boot({ scene, camera, renderer }) {
859
  recovery = null;
860
  mode = "walk";
861
  lastAction.fill(0);
 
862
  syncButtons();
863
  } else if (recovery.steps >= RECOVER_GIVEUP_STEPS) {
864
  resetSim();
@@ -873,12 +886,18 @@ async function boot({ scene, camera, renderer }) {
873
  fallDebounce = 0;
874
  exitHeadMode();
875
  recovery = { state: "fallen", steps: 0 };
 
 
 
876
  syncButtons();
877
  }
878
  } else {
879
  fallDebounce = 0;
880
  const now = performance.now();
881
  const graceMs = mode === "roll" ? 5000 : 1000;
 
 
 
882
  fallenSince ??= now;
883
  if (now - fallenSince > graceMs) resetSim();
884
  }
@@ -949,6 +968,7 @@ async function boot({ scene, camera, renderer }) {
949
  // Timed out mid-roll: don't hand a tipped duck to the walking
950
  // policy (it has no get-up skill).
951
  if (!upright) resetSim();
 
952
  syncButtons();
953
  }
954
  }
@@ -1065,6 +1085,27 @@ async function boot({ scene, camera, renderer }) {
1065
  return rollersLoading;
1066
  }
1067
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1068
  function activateLoco(name) {
1069
  const L = locos[name];
1070
  loco = name;
@@ -1878,6 +1919,7 @@ async function boot({ scene, camera, renderer }) {
1878
  mode = foot === "left" ? "kickL" : "kickR";
1879
  sitFlag = 0;
1880
  kickRun = { steps: 0 };
 
1881
  syncButtons();
1882
  stickers?.pop("kick");
1883
  return true;
@@ -1999,10 +2041,14 @@ async function boot({ scene, camera, renderer }) {
1999
  // default instead of letting applyVariant throw on a bad key.
2000
  variantNames: Object.keys(VARIANTS),
2001
  defaultVariant: DEFAULT_VARIANT,
2002
- // Ghost rig per locomotion flag: peers in roller mode clone the
2003
- // roller rig once this tab has built it, and fall back to the leg
2004
- // rig until then. Known v1 limitation, documented in the README.
2005
- getRigFor: (l) => (l && locos.rollers ? locos.rollers.rig : locos.legs.rig),
 
 
 
 
2006
  getLocalState: () => {
2007
  const qpos = data.qpos;
2008
  const j = new Array(NUM_JOINTS);
 
45
  import { Controller } from "./controls/controller.js";
46
  import { KeyboardSource } from "./controls/keyboard.js";
47
  import { GamepadSource } from "./controls/gamepad.js";
48
+ import { haptics } from "./haptics.js";
49
  import { TouchSource } from "./controls/touch.js";
50
  import * as fx from "./fx/fx-wireframe.js";
51
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
 
770
  }
771
  }
772
  }
773
+ // Roller rumble: gain and slight pitch follow ground speed - and the
774
+ // pad's haptic bed mirrors it (texture on the weak motor).
775
  const speed = Math.hypot(data.qvel[0], data.qvel[1]);
776
+ const rollLevel = loco === "rollers" && !inputLocked ? Math.min(1, speed / 0.5) : 0;
777
+ setRumble(rollLevel, duckEmitter);
778
+ haptics.setBed(rollLevel);
 
779
  // Body bumps: trunk |dv| against a nearby wall/prop. The proximity
780
  // gate keeps gait/kick jerks (which rival slow wall hits) silent.
781
  if (!inputLocked) {
 
791
  rate: 0.55 + 0.15 * u + Math.random() * 0.06, // way below the ball's range
792
  out: duckEmitter.node,
793
  });
794
+ haptics.pulse("bump", 0.5 + 0.5 * u); // shove felt in the hands
795
  }
796
  }
797
  bumpPrevV[0] = v[0]; bumpPrevV[1] = v[1]; bumpPrevV[2] = v[2];
 
815
  rate: 1.25 - 0.45 * u + Math.random() * 0.08,
816
  out: ballEmitter.node,
817
  });
818
+ // Haptics only when the duck plausibly caused/received the hit:
819
+ // a far bounce off a wall shouldn't shake the hands.
820
+ const bq = ballQposAdr;
821
+ const dBall = Math.hypot(
822
+ data.qpos[bq] - data.qpos[0], data.qpos[bq + 1] - data.qpos[1]);
823
+ if (dBall < 0.6) haptics.pulse("ballHit", 0.4 + 0.6 * u);
824
  }
825
  }
826
  ballPrevV[0] = v[b]; ballPrevV[1] = v[b + 1]; ballPrevV[2] = v[b + 2];
 
828
  } else {
829
  ballPrevValid = false;
830
  }
831
+ // Haptic channel scheduler: keeps the roller bed alive between pulses
832
+ // and cuts the motors when it falls silent. 50 Hz, like everything here.
833
+ haptics.tick();
834
  }
835
 
836
  async function controlStep() {
 
853
 
854
  const death = poseIsDead();
855
  if (death === "exploded") {
856
+ haptics.pulse("explode");
857
  resetSim();
858
  } else if (recovery) {
859
  // Recovery state machine owns the duck: settle -> stand policy ->
 
871
  recovery = null;
872
  mode = "walk";
873
  lastAction.fill(0);
874
+ haptics.pulse("recover"); // back on its feet: light double tap
875
  syncButtons();
876
  } else if (recovery.steps >= RECOVER_GIVEUP_STEPS) {
877
  resetSim();
 
886
  fallDebounce = 0;
887
  exitHeadMode();
888
  recovery = { state: "fallen", steps: 0 };
889
+ // Haptic thud on the confirmed fall (one-shot: this transition
890
+ // fires once per fall, the recovery machine owns the duck after).
891
+ haptics.pulse("fall");
892
  syncButtons();
893
  }
894
  } else {
895
  fallDebounce = 0;
896
  const now = performance.now();
897
  const graceMs = mode === "roll" ? 5000 : 1000;
898
+ // First frame of a non-recoverable fall (rollers, sit, one-shots):
899
+ // same haptic thud, once - fallenSince latches until reset/upright.
900
+ if (fallenSince == null) haptics.pulse("fall");
901
  fallenSince ??= now;
902
  if (now - fallenSince > graceMs) resetSim();
903
  }
 
968
  // Timed out mid-roll: don't hand a tipped duck to the walking
969
  // policy (it has no get-up skill).
970
  if (!upright) resetSim();
971
+ else haptics.pulse("land"); // rolled through and stuck the landing
972
  syncButtons();
973
  }
974
  }
 
1085
  return rollersLoading;
1086
  }
1087
 
1088
+ // Ghost-only roller rig: kinematics + THREE meshes, no physics model and
1089
+ // no ONNX sessions - just enough to render roller-mode PEERS correctly
1090
+ // for a visitor who never leaves legs mode (ensureRollers' full stack
1091
+ // stays lazy). Kept resident once built; if the player later switches
1092
+ // for real, getRigFor prefers the live locos.rollers rig and this one
1093
+ // quietly remains as a clone source.
1094
+ let ghostRollerRig = null;
1095
+ let ghostRollerRigLoading = null;
1096
+ function ensureGhostRollerRig() {
1097
+ if (locos.rollers || ghostRollerRig || rollersLoading) return;
1098
+ ghostRollerRigLoading ??= (async () => {
1099
+ const rk = await loadKinematics(`${MODEL_DIR}/kinematics_rollers.json`);
1100
+ ghostRollerRig = await buildRig(rk, {
1101
+ materialForMesh: materialHookFor(VARIANTS[currentVariant]),
1102
+ });
1103
+ })().catch((e) => {
1104
+ ghostRollerRigLoading = null; // next roller peer retries the load
1105
+ console.warn("[ghosts] roller ghost rig load failed", e);
1106
+ });
1107
+ }
1108
+
1109
  function activateLoco(name) {
1110
  const L = locos[name];
1111
  loco = name;
 
1919
  mode = foot === "left" ? "kickL" : "kickR";
1920
  sitFlag = 0;
1921
  kickRun = { steps: 0 };
1922
+ haptics.pulse("kick"); // swing launch; ball contact adds ballHit
1923
  syncButtons();
1924
  stickers?.pop("kick");
1925
  return true;
 
2041
  // default instead of letting applyVariant throw on a bad key.
2042
  variantNames: Object.keys(VARIANTS),
2043
  defaultVariant: DEFAULT_VARIANT,
2044
+ // Ghost rig per locomotion flag: roller peers clone the live roller
2045
+ // rig when this tab has it, else the lightweight ghost-only roller
2046
+ // rig. hasRigFor/prepareRigFor let ghosts.js render legs as a
2047
+ // stopgap while lazily loading the real thing, then rebuild.
2048
+ getRigFor: (l) =>
2049
+ (l ? (locos.rollers?.rig ?? ghostRollerRig ?? locos.legs.rig) : locos.legs.rig),
2050
+ hasRigFor: (l) => !l || !!(locos.rollers || ghostRollerRig),
2051
+ prepareRigFor: (l) => { if (l) ensureGhostRollerRig(); },
2052
  getLocalState: () => {
2053
  const qpos = data.qpos;
2054
  const j = new Array(NUM_JOINTS);
app/src/game/ghosts.js CHANGED
@@ -103,9 +103,15 @@ export async function initGhosts(env) {
103
  const knownVariants = env.variantNames ? new Set(env.variantNames) : null;
104
  const defaultVariant = env.defaultVariant ?? env.variantNames?.[0];
105
  // Locomotion-variant rig source: state.l 1 = rollers, 0/absent = legs
106
- // (old clients never send it). Falls back to the leg rig when the local
107
- // tab hasn't built the roller rig yet - known v1 limitation.
 
 
108
  const rigFor = (l) => (env.getRigFor ? env.getRigFor(l ?? 0) : env.rig);
 
 
 
 
109
  // trystero 0.25 (backed by @trystero-p2p): makeAction returns
110
  // { send, onMessage, onReceiveProgress } where onMessage is a SETTER -
111
  // the receive handler is registered by assignment, not by calling it.
@@ -189,7 +195,12 @@ export async function initGhosts(env) {
189
  at: Math.max(performance.now(), prevAt + RESPACE_MIN_MS),
190
  });
191
  const makeGhost = (state) => {
192
- const rig = cloneRig(rigFor(state.l));
 
 
 
 
 
193
  applyVariant(rig, state.v);
194
  ghostify(rig);
195
  scene.add(rig.placer);
@@ -199,7 +210,7 @@ export async function initGhosts(env) {
199
  trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
200
  return {
201
  rig, trunk, buf: [snapOf(state)],
202
- variant: state.v, loco: state.l ?? 0,
203
  gapAvg: 1000 / SEND_HZ, // measured arrival cadence, ms
204
  delay: INTERP_DELAY_MS, // current playback delay, ms
205
  };
@@ -278,17 +289,23 @@ export async function initGhosts(env) {
278
  g.revealed = true; // latched for the rest of this peer session
279
  g.rig.placer.visible = true;
280
  }
281
- // Peer switched legs <-> rollers: rebuild its ghost on the other rig
282
- // (cheap - cloneRig shares geometry). The snapshot buffer carries over
283
- // so the interpolated motion stays continuous across the swap.
 
 
284
  if (state.l !== g.loco) {
285
- const { lastSeen, spawn, revealed, buf, gapAvg, delay } = g;
286
- removeGhost(peerId);
287
- g = makeGhost(state);
288
- Object.assign(g, { lastSeen, spawn, revealed, buf, gapAvg, delay });
289
- g.rig.placer.visible = revealed;
290
- ghosts.set(peerId, g);
291
- return;
 
 
 
 
292
  }
293
  if (state.v !== g.variant) {
294
  g.variant = state.v;
@@ -392,7 +409,7 @@ export async function initGhosts(env) {
392
  meshes++; if (o.visible) visible++; op ??= o.material.opacity;
393
  });
394
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
395
- return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant, gapAvg: g.gapAvg, delay: g.delay, bufLen: g.buf.length };
396
  }),
397
  update() {
398
  const now = performance.now();
 
103
  const knownVariants = env.variantNames ? new Set(env.variantNames) : null;
104
  const defaultVariant = env.defaultVariant ?? env.variantNames?.[0];
105
  // Locomotion-variant rig source: state.l 1 = rollers, 0/absent = legs
106
+ // (old clients never send it). The host may not have the wanted rig yet
107
+ // (the roller stack is lazy): servedLoco says which flavor a ghost
108
+ // actually gets right now, and prepareRigFor warms the missing one in
109
+ // the background so a later packet can rebuild onto the real thing.
110
  const rigFor = (l) => (env.getRigFor ? env.getRigFor(l ?? 0) : env.rig);
111
+ const servedLoco = (l) => {
112
+ const want = l ?? 0;
113
+ return env.hasRigFor && !env.hasRigFor(want) ? 0 : want;
114
+ };
115
  // trystero 0.25 (backed by @trystero-p2p): makeAction returns
116
  // { send, onMessage, onReceiveProgress } where onMessage is a SETTER -
117
  // the receive handler is registered by assignment, not by calling it.
 
195
  at: Math.max(performance.now(), prevAt + RESPACE_MIN_MS),
196
  });
197
  const makeGhost = (state) => {
198
+ // g.loco records the rig the ghost actually GOT (the served flavor),
199
+ // not the peer's flag: while the roller rig is still loading the ghost
200
+ // walks on legs, and the mismatch on later packets drives the rebuild.
201
+ const served = servedLoco(state.l);
202
+ if (served !== (state.l ?? 0)) env.prepareRigFor?.(state.l);
203
+ const rig = cloneRig(rigFor(served));
204
  applyVariant(rig, state.v);
205
  ghostify(rig);
206
  scene.add(rig.placer);
 
210
  trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
211
  return {
212
  rig, trunk, buf: [snapOf(state)],
213
+ variant: state.v, loco: served,
214
  gapAvg: 1000 / SEND_HZ, // measured arrival cadence, ms
215
  delay: INTERP_DELAY_MS, // current playback delay, ms
216
  };
 
289
  g.revealed = true; // latched for the rest of this peer session
290
  g.rig.placer.visible = true;
291
  }
292
+ // Peer switched legs <-> rollers, or the rig its mode needs just
293
+ // finished lazy-loading: rebuild the ghost on the right rig (cheap -
294
+ // cloneRig shares geometry). The snapshot buffer carries over so the
295
+ // interpolated motion stays continuous across the swap. While the
296
+ // wanted rig is still loading, keep warming it and stay on legs.
297
  if (state.l !== g.loco) {
298
+ if (servedLoco(state.l) !== state.l) {
299
+ env.prepareRigFor?.(state.l);
300
+ } else {
301
+ const { lastSeen, spawn, revealed, buf, gapAvg, delay } = g;
302
+ removeGhost(peerId);
303
+ g = makeGhost(state);
304
+ Object.assign(g, { lastSeen, spawn, revealed, buf, gapAvg, delay });
305
+ g.rig.placer.visible = revealed;
306
+ ghosts.set(peerId, g);
307
+ return;
308
+ }
309
  }
310
  if (state.v !== g.variant) {
311
  g.variant = state.v;
 
409
  meshes++; if (o.visible) visible++; op ??= o.material.opacity;
410
  });
411
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
412
+ return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant, l: g.loco, gapAvg: g.gapAvg, delay: g.delay, bufLen: g.buf.length };
413
  }),
414
  update() {
415
  const now = performance.now();
app/src/game/haptics.js ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Gamepad haptics engine: named one-shot pulses plus a continuous rumble
2
+ // bed, multiplexed onto the single dual-rumble channel the Gamepad API
3
+ // exposes per pad (playEffect always REPLACES the running effect - there
4
+ // is no hardware mixing, so the mixing policy lives here).
5
+ //
6
+ // Model (the usual game-feel split):
7
+ // strongMagnitude = heavy low-frequency motor -> mass, impacts, thuds
8
+ // weakMagnitude = light high-frequency motor -> texture, ticks
9
+ //
10
+ // Pulses are named presets with a priority: while one is playing, a
11
+ // lower-priority pulse is dropped (a wall bump can never eat the fall
12
+ // thud) and an equal/higher one takes the channel. `scale` multiplies
13
+ // both magnitudes so impact-style cues can ride the physics (ball |dv|,
14
+ // bump strength). Presets may be sequences (multi-tap patterns) - taps
15
+ // are scheduled with timeouts and cancelled wholesale if another pulse
16
+ // takes over mid-pattern.
17
+ //
18
+ // The bed is a 0..1 level written every control step (roller ground
19
+ // speed); it keeps the motors alive between pulses by re-issuing a short
20
+ // overlapping effect, yields the instant a pulse fires, and resumes when
21
+ // the pulse expires. Everything is fire-and-forget and safe without a
22
+ // pad or actuator (Safari, some Firefox/driver stacks): silent no-op.
23
+
24
+ import { pickPad } from "./controls/gamepad.js";
25
+
26
+ // Kill switch, mirroring SOUND_DISABLED in audio.js: flip to true and
27
+ // every entry point below early-returns.
28
+ const HAPTICS_DISABLED = false;
29
+
30
+ // One-shot presets. d/gap in ms, s/w = strong/weak magnitudes (0..1
31
+ // before scale), prio decides who wins the single channel.
32
+ const EFFECTS = {
33
+ // Confirmed fall (recovery entry or non-recoverable tip-over).
34
+ fall: { prio: 3, seq: [{ d: 450, s: 1.0, w: 0.7 }] },
35
+ // Solver explosion -> instant reset: the hardest hit in the game.
36
+ explode: { prio: 4, seq: [{ d: 550, s: 1.0, w: 1.0 }] },
37
+ // Barrel roll finished upright: landing thump.
38
+ land: { prio: 2, seq: [{ d: 170, s: 0.7, w: 0.35 }] },
39
+ // Kick swing launch (the ball contact rides ballHit on top).
40
+ kick: { prio: 1, seq: [{ d: 90, s: 0.5, w: 0.2 }] },
41
+ // Ball impact felt through the body - scaled by the hit's |dv|.
42
+ ballHit: { prio: 2, seq: [{ d: 120, s: 0.85, w: 0.4 }] },
43
+ // Trunk bump against a wall or prop - scaled by |dv|.
44
+ bump: { prio: 1, seq: [{ d: 130, s: 0.55, w: 0.25 }] },
45
+ // Recovery success (the duck got back up): light double tap.
46
+ recover: { prio: 2, seq: [{ d: 70, s: 0.3, w: 0.45 }, { gap: 90 }, { d: 100, s: 0.5, w: 0.65 }] },
47
+ };
48
+
49
+ // Bed tuning: texture-forward (weak motor), a whisper of mass underneath.
50
+ const BED_REFRESH_MS = 240; // re-issue cadence; duration overlaps 2x
51
+ const BED_STRONG = 0.12;
52
+ const BED_WEAK = 0.4;
53
+ const BED_FLOOR = 0.03; // below this the bed is considered off
54
+
55
+ let current = { until: 0, prio: 0 }; // the pulse owning the channel
56
+ let seqGen = 0; // generation token: bumping it cancels pending seq taps
57
+ let bedLevel = 0;
58
+ let bedIssuedAt = 0;
59
+ let bedActive = false;
60
+
61
+ const actuator = () => pickPad()?.vibrationActuator ?? null;
62
+
63
+ const play = (act, step, scale) => {
64
+ act
65
+ .playEffect("dual-rumble", {
66
+ duration: step.d,
67
+ strongMagnitude: Math.min(1, step.s * scale),
68
+ weakMagnitude: Math.min(1, step.w * scale),
69
+ })
70
+ .catch(() => {});
71
+ };
72
+
73
+ function pulse(name, scale = 1) {
74
+ if (HAPTICS_DISABLED) return;
75
+ const fx = EFFECTS[name];
76
+ if (!fx) return;
77
+ const act = actuator();
78
+ if (!act?.playEffect) return;
79
+ const now = performance.now();
80
+ if (now < current.until && fx.prio < current.prio) return;
81
+ seqGen++; // cancel any in-flight sequence from the previous pulse
82
+ const gen = seqGen;
83
+ let at = 0;
84
+ let total = 0;
85
+ for (const step of fx.seq) total += (step.gap ?? 0) + (step.d ?? 0);
86
+ current = { until: now + total, prio: fx.prio };
87
+ bedActive = false; // the pulse owns the channel; tick() resumes the bed
88
+ for (const step of fx.seq) {
89
+ at += step.gap ?? 0;
90
+ if (!step.d) continue;
91
+ if (at === 0) {
92
+ play(act, step, scale);
93
+ } else {
94
+ setTimeout(() => {
95
+ if (gen !== seqGen) return; // a newer pulse took the channel
96
+ const a = actuator(); // pad may have been yanked meanwhile
97
+ if (a?.playEffect) play(a, step, scale);
98
+ }, at);
99
+ }
100
+ at += step.d;
101
+ }
102
+ }
103
+
104
+ // Continuous rumble level (0..1), meant to be written every control step
105
+ // (it only becomes motion through tick()).
106
+ function setBed(level) {
107
+ bedLevel = HAPTICS_DISABLED ? 0 : Math.max(0, Math.min(1, level));
108
+ }
109
+
110
+ // Channel scheduler, called once per control step: re-issues the bed
111
+ // while it's on and no pulse owns the channel, and cuts the motors when
112
+ // the bed falls silent (playEffect would otherwise finish the last
113
+ // overlap on its own, a ~0.5 s tail).
114
+ function tick() {
115
+ if (HAPTICS_DISABLED) return;
116
+ const now = performance.now();
117
+ if (now < current.until) return;
118
+ const act = actuator();
119
+ if (!act) return;
120
+ if (bedLevel > BED_FLOOR) {
121
+ if (now - bedIssuedAt >= BED_REFRESH_MS) {
122
+ bedIssuedAt = now;
123
+ bedActive = true;
124
+ act
125
+ .playEffect?.("dual-rumble", {
126
+ duration: BED_REFRESH_MS * 2,
127
+ strongMagnitude: Math.min(1, BED_STRONG * bedLevel),
128
+ weakMagnitude: Math.min(1, BED_WEAK * bedLevel),
129
+ })
130
+ .catch(() => {});
131
+ }
132
+ } else if (bedActive) {
133
+ bedActive = false;
134
+ bedIssuedAt = 0;
135
+ try { act.reset?.()?.catch?.(() => {}); } catch { /* older impls throw sync */ }
136
+ }
137
+ }
138
+
139
+ export const haptics = { pulse, setBed, tick };
app/src/ui/Hud.jsx CHANGED
@@ -403,9 +403,7 @@ function Quickbar() {
403
  })}
404
  </Box>
405
  </HudPlate>
406
- {/* Sound is fully disabled for now (SOUND_DISABLED in audio.js);
407
- re-enable this toggle together with that flag. */}
408
- {/* <SoundToggle /> */}
409
  </Box>
410
  );
411
  }
 
403
  })}
404
  </Box>
405
  </HudPlate>
406
+ <SoundToggle />
 
 
407
  </Box>
408
  );
409
  }