diff --git a/API.md b/API.md index 2a8aa057..3e1abefe 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"`. 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. + +**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..fdae225c 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) { @@ -49,6 +51,147 @@ 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), + ); + + 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); + } + + return { + gamepad, + handedness, + }; + }) + .filter((candidate) => !!candidate.gamepad); + + const navigatorCandidates = (() => { + if (typeof navigator === "undefined" || !navigator.getGamepads) { + return []; + } + + 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 }); + } + } + return Array.from(byKey.values()); + })(); + + if (!dedupedCandidates.length) { + return false; + } + + const matchesByHandedness = (candidate) => { + if (normalizedController === "ANY") return true; + + 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; + + try { + if (typeof actuator.playEffect === "function") { + await actuator.playEffect(actuator.type || "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; + }; + + let didRumble = false; + for (const { gamepad } of finalTargets) { + 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 didRumble; + }, 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",