From 42ae6ad7331bbccecf859d398058a3db26d0daa9 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 18:38:58 +0000 Subject: [PATCH 1/7] Add basic controller rumble block and XR API support --- API.md | 18 ++++++++++- api/xr.js | 68 ++++++++++++++++++++++++++++++++++++++++ blocks/xr.js | 38 +++++++++++++++++++++- flock.js | 2 ++ generators/generators.js | 18 +++++++++++ locale/en.js | 3 ++ toolbox.js | 23 ++++++++++++++ 7 files changed, 168 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index 2a8aa057..0dd87547 100644 --- a/API.md +++ b/API.md @@ -492,6 +492,22 @@ Broadcasts a custom event. #### `onTrigger(meshName, callback)` Sets up collision/trigger detection for a mesh. +#### `rumbleController(controller = "ANY", strength = 1, durationMs = 200)` +Triggers rumble/haptic feedback on supported gamepads and controllers. + +**Parameters:** +- `controller` (string): `"ANY"`, `"LEFT"`, or `"RIGHT"`. +- `strength` (number): Intensity from `0` to `1`. +- `durationMs` (number): Rumble duration in milliseconds. + +**Returns:** +- `Promise`: `true` when a rumble command was sent, otherwise `false` (for unsupported devices/browsers). + +**Example:** +```javascript +await rumbleController("ANY", 0.8, 250); +``` + ## Examples For a complete working example, see [example.html](example.html) in the repository, which demonstrates a full Flock XR application with character movement, physics, and camera controls. @@ -575,4 +591,4 @@ Most Flock functions are asynchronous and should be awaited. If a mesh or resour ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to Flock XR. \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to Flock XR. diff --git a/api/xr.js b/api/xr.js index a596b171..1397a228 100644 --- a/api/xr.js +++ b/api/xr.js @@ -49,6 +49,74 @@ export const flockXR = { color: "white", }); }, + async rumbleController(controller = "ANY", strength = 1, durationMs = 200) { + const normalizedController = String(controller || "ANY").toUpperCase(); + const normalizedStrength = Math.min( + 1, + Math.max(0, Number.isFinite(strength) ? strength : 1), + ); + const normalizedDuration = Math.max( + 0, + Math.floor(Number.isFinite(durationMs) ? durationMs : 200), + ); + + if (typeof navigator === "undefined" || !navigator.getGamepads) { + return false; + } + + const gamepads = Array.from(navigator.getGamepads() || []).filter(Boolean); + if (!gamepads.length) { + return false; + } + + const matchesController = (gamepad) => { + if (normalizedController === "ANY") { + return true; + } + + const hand = String(gamepad.hand || "").toLowerCase(); + const id = String(gamepad.id || "").toLowerCase(); + if (normalizedController === "LEFT") { + return hand === "left" || id.includes("left"); + } + if (normalizedController === "RIGHT") { + return hand === "right" || id.includes("right"); + } + return true; + }; + + const targetPad = gamepads.find(matchesController); + if (!targetPad) { + return false; + } + + const actuator = + targetPad.vibrationActuator || targetPad.hapticActuators?.[0] || null; + if (!actuator) { + return false; + } + + try { + if (typeof actuator.playEffect === "function") { + await actuator.playEffect("dual-rumble", { + startDelay: 0, + duration: normalizedDuration, + weakMagnitude: normalizedStrength, + strongMagnitude: normalizedStrength, + }); + return true; + } + + if (typeof actuator.pulse === "function") { + await actuator.pulse(normalizedStrength, normalizedDuration); + return true; + } + } catch { + return false; + } + + return false; + }, exportMesh(meshName, format) { //meshName = "scene"; diff --git a/blocks/xr.js b/blocks/xr.js index 02138e5d..b5c5e28c 100644 --- a/blocks/xr.js +++ b/blocks/xr.js @@ -58,5 +58,41 @@ export function defineXRBlocks() { }, }; -} + Blockly.Blocks["rumble_controller"] = { + init: function () { + this.jsonInit({ + type: "rumble_controller", + message0: translate("rumble_controller"), + args0: [ + { + type: "field_dropdown", + name: "CONTROLLER", + options: [ + getDropdownOption("ANY"), + getDropdownOption("LEFT"), + getDropdownOption("RIGHT"), + ], + }, + { + type: "input_value", + name: "STRENGTH", + check: "Number", + }, + { + type: "input_value", + name: "DURATION_MS", + check: "Number", + }, + ], + previousStatement: null, + nextStatement: null, + colour: categoryColours["Scene"], + tooltip: getTooltip("rumble_controller"), + }); + this.setHelpUrl(getHelpUrlFor(this.type)); + this.setStyle('scene_blocks'); + + }, + }; +} diff --git a/flock.js b/flock.js index d672e2e0..66070499 100644 --- a/flock.js +++ b/flock.js @@ -968,6 +968,7 @@ export const flock = { setCameraBackground: this.setCameraBackground?.bind(this), setXRMode: this.setXRMode?.bind(this), + rumbleController: this.rumbleController?.bind(this), applyForce: this.applyForce?.bind(this), moveByVector: this.moveByVector?.bind(this), glideTo: this.glideTo?.bind(this), @@ -1083,6 +1084,7 @@ export const flock = { "setSky", "setFog", "setCameraBackground", + "rumbleController", "lightIntensity", "lightColor", "create3DText", diff --git a/generators/generators.js b/generators/generators.js index 53f8f999..72fc20ae 100644 --- a/generators/generators.js +++ b/generators/generators.js @@ -3517,6 +3517,24 @@ export function defineGenerators() { return `await setXRMode("${mode}");\n`; }; + javascriptGenerator.forBlock["rumble_controller"] = function (block) { + const controller = block.getFieldValue("CONTROLLER"); + const strength = + javascriptGenerator.valueToCode( + block, + "STRENGTH", + javascriptGenerator.ORDER_NONE, + ) || "1"; + const durationMs = + javascriptGenerator.valueToCode( + block, + "DURATION_MS", + javascriptGenerator.ORDER_NONE, + ) || "200"; + + return `await rumbleController("${controller}", ${strength}, ${durationMs});\n`; + }; + javascriptGenerator.forBlock["camera_control"] = function (block) { const key = block.getFieldValue("KEY"); const action = block.getFieldValue("ACTION"); diff --git a/locale/en.js b/locale/en.js index c32dc275..f9fcf9cc 100644 --- a/locale/en.js +++ b/locale/en.js @@ -332,6 +332,7 @@ export default { // Custom block translations - XR blocks device_camera_background: "use %1 camera as background", set_xr_mode: "set XR mode to %1", + rumble_controller: "rumble %1 strength %2 for %3 ms", // Blockly message overrides for English LISTS_CREATE_WITH_INPUT_WITH: "list", @@ -633,6 +634,8 @@ export default { "Use the device camera as the background for the scene. Works on both mobile and desktop.", set_xr_mode_tooltip: "Set the XR mode for the scene.\nOptions: VR, AR, Magic Window.", + rumble_controller_tooltip: + "Trigger controller rumble on supported gamepads using strength from 0 to 1 and duration in milliseconds.", // Dropdown option translations AWAIT_option: "await", diff --git a/toolbox.js b/toolbox.js index ebd2c4b9..819a9390 100644 --- a/toolbox.js +++ b/toolbox.js @@ -603,6 +603,29 @@ const toolboxSceneXR = { type: "set_xr_mode", keyword: "xr", }, + { + kind: "block", + type: "rumble_controller", + keyword: "rumble", + inputs: { + STRENGTH: { + shadow: { + type: "math_number", + fields: { + NUM: 1, + }, + }, + }, + DURATION_MS: { + shadow: { + type: "math_number", + fields: { + NUM: 200, + }, + }, + }, + }, + }, { kind: "block", type: "export_mesh", From ddc22e8abc606e79b1af9de731d76788e31897cd Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 18:53:50 +0000 Subject: [PATCH 2/7] Fix rumble controller execution across XR and Gamepad inputs --- api/xr.js | 109 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 28 deletions(-) diff --git a/api/xr.js b/api/xr.js index 1397a228..cd67b441 100644 --- a/api/xr.js +++ b/api/xr.js @@ -1,3 +1,5 @@ +import { translate } from "../main/translation.js"; + let flock; export function setFlockReference(ref) { @@ -60,62 +62,113 @@ export const flockXR = { Math.floor(Number.isFinite(durationMs) ? durationMs : 200), ); - if (typeof navigator === "undefined" || !navigator.getGamepads) { - return false; - } + const xrSources = flock.xrHelper?.baseExperience?.input?.inputSources || []; + const xrCandidates = xrSources + .map((source) => ({ + gamepad: source.gamepad, + handedness: String(source.inputSource?.handedness || "").toLowerCase(), + })) + .filter((candidate) => Boolean(candidate.gamepad)); + + const navigatorCandidates = (() => { + if (typeof navigator === "undefined" || !navigator.getGamepads) { + return []; + } + + return Array.from(navigator.getGamepads() || []) + .filter(Boolean) + .map((gamepad) => ({ + gamepad, + handedness: String(gamepad.hand || "").toLowerCase(), + })); + })(); - const gamepads = Array.from(navigator.getGamepads() || []).filter(Boolean); - if (!gamepads.length) { + const allCandidates = [...xrCandidates, ...navigatorCandidates]; + if (!allCandidates.length) { return false; } - const matchesController = (gamepad) => { + const seen = new Set(); + const uniqueCandidates = allCandidates.filter(({ gamepad }) => { + const key = `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + const matchesController = ({ gamepad, handedness }) => { if (normalizedController === "ANY") { return true; } - const hand = String(gamepad.hand || "").toLowerCase(); const id = String(gamepad.id || "").toLowerCase(); + const effectiveHand = handedness || String(gamepad.hand || "").toLowerCase(); if (normalizedController === "LEFT") { - return hand === "left" || id.includes("left"); + return effectiveHand === "left" || id.includes("left"); } if (normalizedController === "RIGHT") { - return hand === "right" || id.includes("right"); + return effectiveHand === "right" || id.includes("right"); } return true; }; - const targetPad = gamepads.find(matchesController); - if (!targetPad) { + const targets = uniqueCandidates.filter(matchesController); + if (!targets.length) { return false; } - const actuator = - targetPad.vibrationActuator || targetPad.hapticActuators?.[0] || null; - if (!actuator) { - return false; - } + const tryActuator = async (actuator) => { + if (!actuator) { + return false; + } - try { if (typeof actuator.playEffect === "function") { - await actuator.playEffect("dual-rumble", { - startDelay: 0, - duration: normalizedDuration, - weakMagnitude: normalizedStrength, - strongMagnitude: normalizedStrength, - }); - return true; + const effectType = actuator.type || "dual-rumble"; + try { + await actuator.playEffect(effectType, { + startDelay: 0, + duration: normalizedDuration, + weakMagnitude: normalizedStrength, + strongMagnitude: normalizedStrength, + }); + return true; + } catch { + // Fallback to pulse when available. + } } if (typeof actuator.pulse === "function") { - await actuator.pulse(normalizedStrength, normalizedDuration); - return true; + try { + await actuator.pulse(normalizedStrength, normalizedDuration); + return true; + } catch { + return false; + } } - } catch { + return false; + }; + + let didRumble = false; + for (const { gamepad } of targets) { + const actuators = [ + gamepad.vibrationActuator, + ...(Array.isArray(gamepad.hapticActuators) + ? gamepad.hapticActuators + : gamepad.hapticActuators + ? [gamepad.hapticActuators] + : []), + ].filter(Boolean); + + for (const actuator of actuators) { + const success = await tryActuator(actuator); + didRumble = didRumble || success; + } } - return false; + return didRumble; }, exportMesh(meshName, format) { //meshName = "scene"; From 332962f9a6972386df61ad93cef7de9803e7d11f Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 18:59:47 +0000 Subject: [PATCH 3/7] Fix left/right rumble targeting in XR controller block --- api/xr.js | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/api/xr.js b/api/xr.js index cd67b441..b48585f9 100644 --- a/api/xr.js +++ b/api/xr.js @@ -88,23 +88,10 @@ export const flockXR = { return false; } - const seen = new Set(); - const uniqueCandidates = allCandidates.filter(({ gamepad }) => { - const key = `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); - - const matchesController = ({ gamepad, handedness }) => { - if (normalizedController === "ANY") { - return true; - } - - const id = String(gamepad.id || "").toLowerCase(); - const effectiveHand = handedness || String(gamepad.hand || "").toLowerCase(); + const matchesByHandedness = (candidate) => { + const id = String(candidate.gamepad.id || "").toLowerCase(); + const effectiveHand = + candidate.handedness || String(candidate.gamepad.hand || "").toLowerCase(); if (normalizedController === "LEFT") { return effectiveHand === "left" || id.includes("left"); } @@ -114,7 +101,14 @@ export const flockXR = { return true; }; - const targets = uniqueCandidates.filter(matchesController); + // Prefer XR input sources for left/right targeting because they provide + // reliable handedness metadata in immersive sessions. + const candidatesByPriority = + normalizedController === "ANY" + ? [allCandidates] + : [xrCandidates.filter(matchesByHandedness), allCandidates.filter(matchesByHandedness)]; + + const targets = candidatesByPriority.find((items) => items.length > 0) || []; if (!targets.length) { return false; } From 885a4198ccdb4b934e80cfd2aa85db25090cbd02 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 19:05:01 +0000 Subject: [PATCH 4/7] Fix rumble left/right target selection --- api/xr.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/api/xr.js b/api/xr.js index b48585f9..e6dc8cf5 100644 --- a/api/xr.js +++ b/api/xr.js @@ -63,10 +63,29 @@ export const flockXR = { ); const xrSources = flock.xrHelper?.baseExperience?.input?.inputSources || []; + const handednessByGamepadKey = new Map(); + for (const source of xrSources) { + const sourceGamepad = source?.gamepad; + if (!sourceGamepad) { + continue; + } + + const sourceHandedness = String( + source?.inputSource?.handedness || "", + ).toLowerCase(); + if (!sourceHandedness || sourceHandedness === "none") { + continue; + } + + const sourceKey = `${sourceGamepad.id || "unknown"}:${sourceGamepad.index ?? "na"}`; + handednessByGamepadKey.set(sourceKey, sourceHandedness); + } + const xrCandidates = xrSources .map((source) => ({ gamepad: source.gamepad, - handedness: String(source.inputSource?.handedness || "").toLowerCase(), + handedness: + String(source.inputSource?.handedness || "").toLowerCase() || "", })) .filter((candidate) => Boolean(candidate.gamepad)); @@ -78,8 +97,12 @@ export const flockXR = { return Array.from(navigator.getGamepads() || []) .filter(Boolean) .map((gamepad) => ({ + key: `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`, gamepad, - handedness: String(gamepad.hand || "").toLowerCase(), + handedness: + handednessByGamepadKey.get( + `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`, + ) || String(gamepad.hand || "").toLowerCase(), })); })(); @@ -88,6 +111,25 @@ export const flockXR = { return false; } + const dedupedCandidates = (() => { + const byKey = new Map(); + for (const candidate of allCandidates) { + const key = `${candidate.gamepad.id || "unknown"}:${candidate.gamepad.index ?? "na"}`; + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, { ...candidate, key }); + continue; + } + + const existingScore = existing.handedness ? 1 : 0; + const incomingScore = candidate.handedness ? 1 : 0; + if (incomingScore > existingScore) { + byKey.set(key, { ...candidate, key }); + } + } + return Array.from(byKey.values()); + })(); + const matchesByHandedness = (candidate) => { const id = String(candidate.gamepad.id || "").toLowerCase(); const effectiveHand = @@ -105,10 +147,16 @@ export const flockXR = { // reliable handedness metadata in immersive sessions. const candidatesByPriority = normalizedController === "ANY" - ? [allCandidates] - : [xrCandidates.filter(matchesByHandedness), allCandidates.filter(matchesByHandedness)]; - - const targets = candidatesByPriority.find((items) => items.length > 0) || []; + ? [dedupedCandidates] + : [ + xrCandidates.filter(matchesByHandedness), + dedupedCandidates.filter(matchesByHandedness), + ]; + + let targets = candidatesByPriority.find((items) => items.length > 0) || []; + if (normalizedController === "LEFT" || normalizedController === "RIGHT") { + targets = targets.slice(0, 1); + } if (!targets.length) { return false; } From 60da5f9a46aaa0391324d63b95d9aa21ef5be698 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 19:14:25 +0000 Subject: [PATCH 5/7] Use XR handedness-first targeting for rumble controllers --- api/xr.js | 123 ++++++++++++++---------------------------------------- 1 file changed, 31 insertions(+), 92 deletions(-) diff --git a/api/xr.js b/api/xr.js index e6dc8cf5..1220a9dd 100644 --- a/api/xr.js +++ b/api/xr.js @@ -53,110 +53,50 @@ export const flockXR = { }, async rumbleController(controller = "ANY", strength = 1, durationMs = 200) { const normalizedController = String(controller || "ANY").toUpperCase(); - const normalizedStrength = Math.min( - 1, - Math.max(0, Number.isFinite(strength) ? strength : 1), - ); + const normalizedStrength = Math.min(1, Math.max(0, Number(strength) || 1)); const normalizedDuration = Math.max( 0, - Math.floor(Number.isFinite(durationMs) ? durationMs : 200), + Math.floor(Number(durationMs) || 200), ); const xrSources = flock.xrHelper?.baseExperience?.input?.inputSources || []; - const handednessByGamepadKey = new Map(); - for (const source of xrSources) { - const sourceGamepad = source?.gamepad; - if (!sourceGamepad) { - continue; - } - - const sourceHandedness = String( - source?.inputSource?.handedness || "", - ).toLowerCase(); - if (!sourceHandedness || sourceHandedness === "none") { - continue; - } - - const sourceKey = `${sourceGamepad.id || "unknown"}:${sourceGamepad.index ?? "na"}`; - handednessByGamepadKey.set(sourceKey, sourceHandedness); - } - const xrCandidates = xrSources + const xrTargets = xrSources + .filter((source) => source?.gamepad) .map((source) => ({ + source, gamepad: source.gamepad, - handedness: - String(source.inputSource?.handedness || "").toLowerCase() || "", - })) - .filter((candidate) => Boolean(candidate.gamepad)); - - const navigatorCandidates = (() => { - if (typeof navigator === "undefined" || !navigator.getGamepads) { - return []; - } + handedness: String(source.inputSource?.handedness || "").toLowerCase(), + })); - return Array.from(navigator.getGamepads() || []) - .filter(Boolean) - .map((gamepad) => ({ - key: `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`, - gamepad, - handedness: - handednessByGamepadKey.get( - `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`, - ) || String(gamepad.hand || "").toLowerCase(), - })); - })(); - - const allCandidates = [...xrCandidates, ...navigatorCandidates]; - if (!allCandidates.length) { - return false; - } + const navigatorTargets = + typeof navigator !== "undefined" && navigator.getGamepads + ? Array.from(navigator.getGamepads() || []) + .filter(Boolean) + .map((gamepad) => ({ + gamepad, + handedness: String(gamepad.hand || "").toLowerCase(), + })) + : []; - const dedupedCandidates = (() => { - const byKey = new Map(); - for (const candidate of allCandidates) { - const key = `${candidate.gamepad.id || "unknown"}:${candidate.gamepad.index ?? "na"}`; - const existing = byKey.get(key); - if (!existing) { - byKey.set(key, { ...candidate, key }); - continue; - } + let targets = []; - const existingScore = existing.handedness ? 1 : 0; - const incomingScore = candidate.handedness ? 1 : 0; - if (incomingScore > existingScore) { - byKey.set(key, { ...candidate, key }); - } - } - return Array.from(byKey.values()); - })(); - - const matchesByHandedness = (candidate) => { - const id = String(candidate.gamepad.id || "").toLowerCase(); - const effectiveHand = - candidate.handedness || String(candidate.gamepad.hand || "").toLowerCase(); - if (normalizedController === "LEFT") { - return effectiveHand === "left" || id.includes("left"); - } - if (normalizedController === "RIGHT") { - return effectiveHand === "right" || id.includes("right"); + if (normalizedController === "LEFT" || normalizedController === "RIGHT") { + const wanted = normalizedController.toLowerCase(); + + // In XR sessions, this is the cleanest and most reliable path. + targets = xrTargets.filter((target) => target.handedness === wanted); + + // Optional non-XR fallback. + if (!targets.length) { + targets = navigatorTargets.filter((target) => target.handedness === wanted); } - return true; - }; - // Prefer XR input sources for left/right targeting because they provide - // reliable handedness metadata in immersive sessions. - const candidatesByPriority = - normalizedController === "ANY" - ? [dedupedCandidates] - : [ - xrCandidates.filter(matchesByHandedness), - dedupedCandidates.filter(matchesByHandedness), - ]; - - let targets = candidatesByPriority.find((items) => items.length > 0) || []; - if (normalizedController === "LEFT" || normalizedController === "RIGHT") { targets = targets.slice(0, 1); + } else { + targets = xrTargets.length ? xrTargets : navigatorTargets; } + if (!targets.length) { return false; } @@ -167,9 +107,8 @@ export const flockXR = { } if (typeof actuator.playEffect === "function") { - const effectType = actuator.type || "dual-rumble"; try { - await actuator.playEffect(effectType, { + await actuator.playEffect("dual-rumble", { startDelay: 0, duration: normalizedDuration, weakMagnitude: normalizedStrength, @@ -186,7 +125,7 @@ export const flockXR = { await actuator.pulse(normalizedStrength, normalizedDuration); return true; } catch { - return false; + // Ignore actuator pulse errors and continue checking others. } } From d3efa72e3a299f5cd1388c85716c0484648275ea Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 19:23:35 +0000 Subject: [PATCH 6/7] Add motor-channel fallback for LEFT/RIGHT rumble --- API.md | 2 +- api/xr.js | 32 ++++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 0dd87547..3e1abefe 100644 --- a/API.md +++ b/API.md @@ -496,7 +496,7 @@ Sets up collision/trigger detection for a mesh. Triggers rumble/haptic feedback on supported gamepads and controllers. **Parameters:** -- `controller` (string): `"ANY"`, `"LEFT"`, or `"RIGHT"`. +- `controller` (string): `"ANY"`, `"LEFT"`, or `"RIGHT"`. In XR this targets handed controllers when available; on single gamepads it targets left/right rumble motor channels when supported. - `strength` (number): Intensity from `0` to `1`. - `durationMs` (number): Rumble duration in milliseconds. diff --git a/api/xr.js b/api/xr.js index 1220a9dd..b8e74ab1 100644 --- a/api/xr.js +++ b/api/xr.js @@ -80,19 +80,20 @@ export const flockXR = { : []; let targets = []; + const wanted = normalizedController.toLowerCase(); if (normalizedController === "LEFT" || normalizedController === "RIGHT") { - const wanted = normalizedController.toLowerCase(); - - // In XR sessions, this is the cleanest and most reliable path. + // First try true handed targeting (two-controller XR setups). targets = xrTargets.filter((target) => target.handedness === wanted); - - // Optional non-XR fallback. if (!targets.length) { targets = navigatorTargets.filter((target) => target.handedness === wanted); } - targets = targets.slice(0, 1); + // If no handed controller exists (single gamepad), fallback to first + // connected target and treat LEFT/RIGHT as motor channels. + if (!targets.length) { + targets = xrTargets.length ? xrTargets.slice(0, 1) : navigatorTargets.slice(0, 1); + } } else { targets = xrTargets.length ? xrTargets : navigatorTargets; } @@ -101,6 +102,20 @@ export const flockXR = { return false; } + const getMotorMagnitudes = () => { + if (normalizedController === "LEFT") { + return { weakMagnitude: 0, strongMagnitude: normalizedStrength }; + } + if (normalizedController === "RIGHT") { + return { weakMagnitude: normalizedStrength, strongMagnitude: 0 }; + } + + return { + weakMagnitude: normalizedStrength, + strongMagnitude: normalizedStrength, + }; + }; + const tryActuator = async (actuator) => { if (!actuator) { return false; @@ -108,11 +123,12 @@ export const flockXR = { if (typeof actuator.playEffect === "function") { try { + const { weakMagnitude, strongMagnitude } = getMotorMagnitudes(); await actuator.playEffect("dual-rumble", { startDelay: 0, duration: normalizedDuration, - weakMagnitude: normalizedStrength, - strongMagnitude: normalizedStrength, + weakMagnitude, + strongMagnitude, }); return true; } catch { From 174f9aca344e78b7048237f80bec13bfcce4b37c Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Fri, 6 Mar 2026 19:28:21 +0000 Subject: [PATCH 7/7] Refine rumble candidate mapping for left/right hand targeting --- api/xr.js | 153 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 89 insertions(+), 64 deletions(-) diff --git a/api/xr.js b/api/xr.js index b8e74ab1..fdae225c 100644 --- a/api/xr.js +++ b/api/xr.js @@ -53,103 +53,128 @@ export const flockXR = { }, async rumbleController(controller = "ANY", strength = 1, durationMs = 200) { const normalizedController = String(controller || "ANY").toUpperCase(); - const normalizedStrength = Math.min(1, Math.max(0, Number(strength) || 1)); + const normalizedStrength = Math.min( + 1, + Math.max(0, Number.isFinite(strength) ? strength : 1), + ); const normalizedDuration = Math.max( 0, - Math.floor(Number(durationMs) || 200), + Math.floor(Number.isFinite(durationMs) ? durationMs : 200), ); const xrSources = flock.xrHelper?.baseExperience?.input?.inputSources || []; + const handednessByGamepadKey = new Map(); + + const xrCandidates = xrSources + .filter( + (source) => + (source && source.motionController?.handness) || + source.inputSource?.handedness, + ) + .map((source) => { + const handedness = String( + source.motionController?.handness || + source.inputSource?.handedness || + "", + ).toLowerCase(); + const gamepad = + source.motionController?.rootMesh?.deviceGamepad || + source.gamepad || + source.inputSource?.gamepad; + + if (gamepad) { + const key = `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`; + handednessByGamepadKey.set(key, handedness); + } - const xrTargets = xrSources - .filter((source) => source?.gamepad) - .map((source) => ({ - source, - gamepad: source.gamepad, - handedness: String(source.inputSource?.handedness || "").toLowerCase(), - })); + return { + gamepad, + handedness, + }; + }) + .filter((candidate) => !!candidate.gamepad); - const navigatorTargets = - typeof navigator !== "undefined" && navigator.getGamepads - ? Array.from(navigator.getGamepads() || []) - .filter(Boolean) - .map((gamepad) => ({ - gamepad, - handedness: String(gamepad.hand || "").toLowerCase(), - })) - : []; - - let targets = []; - const wanted = normalizedController.toLowerCase(); - - if (normalizedController === "LEFT" || normalizedController === "RIGHT") { - // First try true handed targeting (two-controller XR setups). - targets = xrTargets.filter((target) => target.handedness === wanted); - if (!targets.length) { - targets = navigatorTargets.filter((target) => target.handedness === wanted); + const navigatorCandidates = (() => { + if (typeof navigator === "undefined" || !navigator.getGamepads) { + return []; } - // If no handed controller exists (single gamepad), fallback to first - // connected target and treat LEFT/RIGHT as motor channels. - if (!targets.length) { - targets = xrTargets.length ? xrTargets.slice(0, 1) : navigatorTargets.slice(0, 1); + return Array.from(navigator.getGamepads() || []) + .filter(Boolean) + .map((gamepad) => { + const key = `${gamepad.id || "unknown"}:${gamepad.index ?? "na"}`; + return { + key, + gamepad, + handedness: + handednessByGamepadKey.get(key) || + String(gamepad.hand || "").toLowerCase(), + }; + }); + })(); + + const allCandidates = [...xrCandidates, ...navigatorCandidates]; + + const dedupedCandidates = (() => { + const byKey = new Map(); + for (const candidate of allCandidates) { + const key = `${candidate.gamepad.id || "unknown"}:${candidate.gamepad.index ?? "na"}`; + const existing = byKey.get(key); + if (!existing || (!existing.handedness && candidate.handedness)) { + byKey.set(key, { ...candidate, key }); + } } - } else { - targets = xrTargets.length ? xrTargets : navigatorTargets; - } + return Array.from(byKey.values()); + })(); - if (!targets.length) { + if (!dedupedCandidates.length) { return false; } - const getMotorMagnitudes = () => { - if (normalizedController === "LEFT") { - return { weakMagnitude: 0, strongMagnitude: normalizedStrength }; - } - if (normalizedController === "RIGHT") { - return { weakMagnitude: normalizedStrength, strongMagnitude: 0 }; - } + const matchesByHandedness = (candidate) => { + if (normalizedController === "ANY") return true; - return { - weakMagnitude: normalizedStrength, - strongMagnitude: normalizedStrength, - }; + const target = normalizedController.toLowerCase(); + const id = String(candidate.gamepad.id || "").toLowerCase(); + const hand = String(candidate.handedness || "").toLowerCase(); + + return hand === target || id.includes(target); }; + const targets = dedupedCandidates.filter(matchesByHandedness); + + if (!targets.length) { + return false; + } + + const finalTargets = normalizedController === "ANY" ? targets : [targets[0]]; + const tryActuator = async (actuator) => { - if (!actuator) { - return false; - } + if (!actuator) return false; - if (typeof actuator.playEffect === "function") { - try { - const { weakMagnitude, strongMagnitude } = getMotorMagnitudes(); - await actuator.playEffect("dual-rumble", { + try { + if (typeof actuator.playEffect === "function") { + await actuator.playEffect(actuator.type || "dual-rumble", { startDelay: 0, duration: normalizedDuration, - weakMagnitude, - strongMagnitude, + weakMagnitude: normalizedStrength, + strongMagnitude: normalizedStrength, }); return true; - } catch { - // Fallback to pulse when available. } - } - if (typeof actuator.pulse === "function") { - try { + if (typeof actuator.pulse === "function") { await actuator.pulse(normalizedStrength, normalizedDuration); return true; - } catch { - // Ignore actuator pulse errors and continue checking others. } + } catch { + return false; } - return false; }; let didRumble = false; - for (const { gamepad } of targets) { + for (const { gamepad } of finalTargets) { const actuators = [ gamepad.vibrationActuator, ...(Array.isArray(gamepad.hapticActuators)