feat: gamepad haptics engine, roller-mode ghost rigs, sound re-enable
Browse files- app/src/game/audio.js +1 -1
- app/src/game/controls/gamepad.js +13 -6
- app/src/game/game.js +55 -9
- app/src/game/ghosts.js +32 -15
- app/src/game/haptics.js +139 -0
- app/src/ui/Hud.jsx +1 -3
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 =
|
| 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 |
-
//
|
| 86 |
-
//
|
| 87 |
-
//
|
| 88 |
-
|
| 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 |
-
|
| 775 |
-
|
| 776 |
-
|
| 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:
|
| 2003 |
-
//
|
| 2004 |
-
// rig
|
| 2005 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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).
|
| 107 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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
|
| 282 |
-
//
|
| 283 |
-
//
|
|
|
|
|
|
|
| 284 |
if (state.l !== g.loco) {
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
}
|