diff --git a/api/xr.js b/api/xr.js index 45cb3d58..9e0051aa 100644 --- a/api/xr.js +++ b/api/xr.js @@ -51,6 +51,194 @@ export const flockXR = { color: "white", }); }, + async setControllerLedColor(controllerIndex, color) { + console.log("[setControllerLedColor] called", { controllerIndex, color }); + const index = Math.max(0, Math.trunc(Number(controllerIndex))); + const xrGamepads = + flock.xrHelper?.baseExperience?.input?.inputSources + ?.map((inputSource) => inputSource?.gamepad) + ?.filter(Boolean) ?? []; + const browserGamepads = Array.from(navigator?.getGamepads?.() ?? []).filter( + Boolean, + ); + console.log("[setControllerLedColor] gamepad pools", { + index, + xrGamepadCount: xrGamepads.length, + browserGamepadCount: browserGamepads.length, + xrGamepads: xrGamepads.map((gp) => ({ + id: gp?.id, + mapping: gp?.mapping, + })), + browserGamepads: browserGamepads.map((gp) => ({ + id: gp?.id, + mapping: gp?.mapping, + })), + }); + + const gamepad = xrGamepads[index] ?? browserGamepads[index]; + console.log("[setControllerLedColor] selected gamepad", { + index, + selectedFrom: xrGamepads[index] ? "xrGamepads" : "browserGamepads", + gamepadId: gamepad?.id, + hasLightIndicator: typeof gamepad?.lightIndicator?.setColor === "function", + hasLedsArray: Array.isArray(gamepad?.leds), + hasFirstLedSetter: typeof gamepad?.leds?.[0]?.setColor === "function", + }); + + if (!gamepad) { + console.log("[setControllerLedColor] no gamepad found for index", index); + return; + } + + const hexToRgb = (hex) => { + const trimmed = String(hex).trim(); + const match = trimmed.match(/^#?([0-9a-fA-F]{6})$/); + if (!match) return null; + const value = match[1]; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; + }; + + const rgb = hexToRgb(color); + if (!rgb) { + console.log("[setControllerLedColor] invalid color format", { color }); + return; + } + console.log("[setControllerLedColor] parsed color", { color, rgb }); + + try { + if (typeof gamepad.lightIndicator?.setColor === "function") { + gamepad.lightIndicator.setColor(rgb.r, rgb.g, rgb.b); + console.log("[setControllerLedColor] used lightIndicator.setColor"); + return; + } + + if (typeof gamepad.leds?.[0]?.setColor === "function") { + gamepad.leds[0].setColor(rgb.r, rgb.g, rgb.b); + console.log("[setControllerLedColor] used leds[0].setColor"); + return; + } + console.log( + "[setControllerLedColor] no supported LED API on selected gamepad", + ); + + const hidResult = await this.setPlayStationControllerLedViaHid?.( + gamepad, + rgb, + ); + if (hidResult) { + console.log("[setControllerLedColor] used WebHID fallback"); + } else { + console.log("[setControllerLedColor] WebHID fallback unavailable/failed"); + } + } catch { + console.log("[setControllerLedColor] LED set failed"); + // silent by design + } + }, + async setPlayStationControllerLedViaHid(gamepad, rgb) { + try { + if (!navigator?.hid) return false; + const id = String(gamepad?.id ?? ""); + const vendorMatch = id.match(/Vendor:\s*([0-9a-fA-F]{4})/); + const productMatch = id.match(/Product:\s*([0-9a-fA-F]{4})/); + const vendorId = vendorMatch ? parseInt(vendorMatch[1], 16) : null; + const productId = productMatch ? parseInt(productMatch[1], 16) : null; + if (vendorId !== 0x054c || !productId) return false; + + const paired = navigator.hid + .getDevices() + .then((devices) => + devices.find( + (d) => d.vendorId === vendorId && d.productId === productId, + ), + ); + let device = await paired; + if (!device) { + const requested = await navigator.hid.requestDevice({ + filters: [{ vendorId, productId, usagePage: 0x01, usage: 0x05 }], + }); + device = requested?.[0]; + } + if (!device) return false; + if (!device.opened) await device.open(); + + const outputReportIds = new Set( + device.collections.flatMap((collection) => + (collection.outputReports || []).map((report) => report.reportId), + ), + ); + console.log("[setControllerLedColor] output report IDs", [...outputReportIds]); + + const ds4ReportId = 0x05; + const ds4Data = new Uint8Array(31); + ds4Data[0] = 0xff; + ds4Data[1] = 0x00; + ds4Data[2] = 0x00; + ds4Data[5] = rgb.r; + ds4Data[6] = rgb.g; + ds4Data[7] = rgb.b; + + if (productId === 0x09cc || productId === 0x0ce6) { + const usbData = new Uint8Array(47); + usbData[0] = 0x03; // valid flag 1 (lightbar + player leds) + usbData[1] = 0x00; // motor right + usbData[2] = 0x00; // motor left + usbData[43] = rgb.r; + usbData[44] = rgb.g; + usbData[45] = rgb.b; + + const btData = new Uint8Array(77); + btData[0] = 0x03; // valid flag 1 + btData[1] = 0x00; // valid flag 2 + btData[2] = 0x00; // motor right + btData[3] = 0x00; // motor left + btData[44] = rgb.r; + btData[45] = rgb.g; + btData[46] = rgb.b; + + const attempts = []; + if (outputReportIds.has(0x05)) { + attempts.push({ + reportId: 0x05, + data: ds4Data, + label: "dualsense-ds4compat-0x05", + }); + } + attempts.push( + { reportId: 0x02, data: usbData, label: "dualsense-usb-0x02" }, + { reportId: 0x31, data: btData, label: "dualsense-bt-0x31" }, + ); + + for (const attempt of attempts) { + try { + await device.sendReport(attempt.reportId, attempt.data); + console.log( + "[setControllerLedColor] WebHID sendReport success", + attempt.label, + ); + return true; + } catch (error) { + console.log( + "[setControllerLedColor] WebHID sendReport failed", + attempt.label, + error, + ); + } + } + return false; + } + + await device.sendReport(ds4ReportId, ds4Data); + return true; + } catch (error) { + console.log("[setControllerLedColor] WebHID fallback error", error); + return false; + } + }, exportMesh(meshName, format) { //meshName = "scene"; diff --git a/blocks/xr.js b/blocks/xr.js index db7c1b7e..10a58761 100644 --- a/blocks/xr.js +++ b/blocks/xr.js @@ -175,4 +175,31 @@ export function defineXRBlocks() { this.setStyle("scene_blocks"); }, }; + + Blockly.Blocks["set_controller_led_color"] = { + init: function () { + this.jsonInit({ + type: "set_controller_led_color", + message0: translate("set_controller_led_color"), + args0: [ + { + type: "input_value", + name: "CONTROLLER_INDEX", + check: "Number", + }, + { + type: "input_value", + name: "COLOR", + check: "Colour", + }, + ], + previousStatement: null, + nextStatement: null, + colour: categoryColours["Scene"], + tooltip: getTooltip("set_controller_led_color"), + }); + this.setHelpUrl(getHelpUrlFor(this.type)); + this.setStyle("scene_blocks"); + }, + }; } diff --git a/flock.js b/flock.js index c884e556..509376dc 100644 --- a/flock.js +++ b/flock.js @@ -998,6 +998,7 @@ export const flock = { cameraControl: this.cameraControl?.bind(this), setCameraBackground: this.setCameraBackground?.bind(this), setXRMode: this.setXRMode?.bind(this), + setControllerLedColor: this.setControllerLedColor?.bind(this), applyForce: this.applyForce?.bind(this), moveByVector: this.moveByVector?.bind(this), glideTo: this.glideTo?.bind(this), @@ -1108,6 +1109,7 @@ export const flock = { "setSky", "setFog", "setCameraBackground", + "setControllerLedColor", "lightIntensity", "lightColor", "create3DText", diff --git a/generators/generators-scene.js b/generators/generators-scene.js index 4871d258..c27c1cac 100644 --- a/generators/generators-scene.js +++ b/generators/generators-scene.js @@ -675,4 +675,21 @@ export function registerSceneGenerators(javascriptGenerator) { // Generate the code that calls the helper function return `exportMesh(${meshVar}, "${format}");\n`; }; + + javascriptGenerator.forBlock["set_controller_led_color"] = function (block) { + const controllerIndex = + javascriptGenerator.valueToCode( + block, + "CONTROLLER_INDEX", + javascriptGenerator.ORDER_NONE, + ) || "0"; + const color = + javascriptGenerator.valueToCode( + block, + "COLOR", + javascriptGenerator.ORDER_NONE, + ) || '"#ffffff"'; + + return `await setControllerLedColor(${controllerIndex}, ${color});\n`; + }; } diff --git a/locale/en.js b/locale/en.js index 9614fc0e..2cea4866 100644 --- a/locale/en.js +++ b/locale/en.js @@ -338,6 +338,7 @@ export default { controller_rumble: "rumble %1 motor at strength %2 for %3 ms", controller_rumble_pattern: "rumble %1 motor strength %2 on %3 ms off %4 ms %5 times", + set_controller_led_color: "set controller %1 LED color to %2", // Blockly message overrides for English LISTS_CREATE_WITH_INPUT_WITH: "list", @@ -648,6 +649,8 @@ export default { "Make a connected game controller rumble. Choose all, left, or right motor, set the strength (0 to 1), and how long to rumble in milliseconds.\nKeyword: rumble", controller_rumble_pattern_tooltip: "Make a connected game controller rumble in a repeating pattern. Set the motor, strength (0 to 1), on time, off time, and number of repeats.\nKeyword: rumble pattern", + set_controller_led_color_tooltip: + "Set the LED color for a connected game controller by index. If LED control is unsupported, this block does nothing.\nKeyword: controller led", // Dropdown option translations AWAIT_option: "await", diff --git a/locale/es.js b/locale/es.js index f3852709..f6e7c0a4 100644 --- a/locale/es.js +++ b/locale/es.js @@ -337,6 +337,7 @@ export default { controller_rumble: "vibrar motor %1 con fuerza %2 durante %3 ms", // human controller_rumble_pattern: "vibrar %1 fuerza de motor %2 encendido %3 ms apagado %4 ms %5 veces", // human + set_controller_led_color: "establecer color del LED del mando %1 a %2", // human // Blockly message overrides for English LISTS_CREATE_WITH_INPUT_WITH: "lista", // human @@ -663,6 +664,8 @@ export default { "Hace vibrar un mando conectado. Elige todos, el izquierdo o el derecho motor, establece la fuerza (0 a 1) y cuánto tiempo para vibrar en milisegundos.\nPalabra clave: vibrar", // human controller_rumble_pattern_tooltip: "Hace vibrar un mando conectado en un patrón repetido. Establece el motor, la fuerza (0 a 1), el tiempo encendido, el tiempo apagado y el número de repeticiones.\nPalabra clave: patrón de vibrar", // human + set_controller_led_color_tooltip: + "Establece el color del LED de un mando conectado por índice. Si el control de LED no es compatible, este bloque no hace nada.\nPalabra clave: led del mando", // human // Dropdown option translations AWAIT_option: "esperar", // human diff --git a/toolbox.js b/toolbox.js index 5454716e..ee8fc509 100644 --- a/toolbox.js +++ b/toolbox.js @@ -607,6 +607,29 @@ const toolboxSceneXR = { type: "export_mesh", keyword: "export", }, + { + kind: "block", + type: "set_controller_led_color", + keyword: "controller led", + inputs: { + CONTROLLER_INDEX: { + shadow: { + type: "math_number", + fields: { + NUM: 0, + }, + }, + }, + COLOR: { + shadow: { + type: "colour", + fields: { + COLOR: "#ffffff", + }, + }, + }, + }, + }, /*{ kind: "block", type: "play_rumble_pattern",