Spaces:
Runtime error
Runtime error
| const formatMessage = require('format-message'); | |
| const BlockType = require('../../extension-support/block-type'); | |
| const ArgumentType = require('../../extension-support/argument-type'); | |
| const Cast = require('../../util/cast'); | |
| const Icon = require('./icon.png'); | |
| const IconController = require('./controller.png'); | |
| const SESSION_TYPE = "immersive-vr"; | |
| // thanks to twoerner94 for quaternion-to-euler on npm | |
| function quaternionToEuler(quat) { | |
| const q0 = quat[0]; | |
| const q1 = quat[1]; | |
| const q2 = quat[2]; | |
| const q3 = quat[3]; | |
| const Rx = Math.atan2(2 * (q0 * q1 + q2 * q3), 1 - (2 * (q1 * q1 + q2 * q2))); | |
| const Ry = Math.asin(2 * (q0 * q2 - q3 * q1)); | |
| const Rz = Math.atan2(2 * (q0 * q3 + q1 * q2), 1 - (2 * (q2 * q2 + q3 * q3))); | |
| return [Rx, Ry, Rz]; | |
| }; | |
| function toRad(deg) { | |
| return deg * (Math.PI / 180); | |
| } | |
| function toDeg(rad) { | |
| return rad * (180 / Math.PI); | |
| } | |
| function toDegRounding(rad) { | |
| const result = toDeg(rad); | |
| if (!String(result).includes('.')) return result; | |
| const split = String(result).split('.'); | |
| const endingDecimals = split[1].substring(0, 3); | |
| if ((endingDecimals === '999') && (split[1].charAt(3) === '9')) return Number(split[0]) + 1; | |
| return Number(split[0] + '.' + endingDecimals); | |
| } | |
| /** | |
| * Class for 3D VR blocks | |
| */ | |
| class Jg3DVrBlocks { | |
| constructor(runtime) { | |
| /** | |
| * The runtime instantiating this block package. | |
| */ | |
| this.runtime = runtime; | |
| this.open = false; | |
| this._3d = {}; | |
| this.three = {}; | |
| // We'll store a wake lock reference here: | |
| this.wakeLock = null; | |
| if (!this.runtime.ext_jg3d) { | |
| vm.extensionManager.loadExtensionURL('jg3d') | |
| .then(() => { | |
| this._3d = this.runtime.ext_jg3d; | |
| this.three = this._3d.three; | |
| }); | |
| } else { | |
| this._3d = this.runtime.ext_jg3d; | |
| this.three = this._3d.three; | |
| } | |
| } | |
| /** | |
| * Metadata for this extension and its blocks. | |
| * @returns {object} | |
| */ | |
| getInfo() { | |
| return { | |
| id: 'jg3dVr', | |
| name: '3D VR', | |
| color1: '#B100FE', | |
| color2: '#8000BC', | |
| blockIconURI: Icon, | |
| blocks: [ | |
| // CORE | |
| { | |
| opcode: 'isSupported', | |
| text: 'is vr supported?', | |
| blockType: BlockType.BOOLEAN, | |
| disableMonitor: true | |
| }, | |
| { | |
| opcode: 'createSession', | |
| text: 'create vr session', | |
| blockType: BlockType.COMMAND | |
| }, | |
| { | |
| opcode: 'closeSession', | |
| text: 'close vr session', | |
| blockType: BlockType.COMMAND | |
| }, | |
| { | |
| opcode: 'isOpened', | |
| text: 'is vr open?', | |
| blockType: BlockType.BOOLEAN, | |
| disableMonitor: true | |
| }, | |
| '---', | |
| { | |
| opcode: 'attachObject', | |
| text: 'attach camera to object named [OBJECT]', | |
| blockType: BlockType.COMMAND, | |
| arguments: { | |
| OBJECT: { | |
| type: ArgumentType.STRING, | |
| defaultValue: "Object1" | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'detachObject', | |
| text: 'detach camera from object', | |
| blockType: BlockType.COMMAND | |
| }, | |
| '---', | |
| { | |
| opcode: 'getControllerPosition', | |
| text: 'controller #[INDEX] position [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| }, | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'getControllerRotation', | |
| text: 'controller #[INDEX] rotation [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| }, | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'getControllerSide', | |
| text: 'side of controller #[INDEX]', | |
| blockType: BlockType.REPORTER, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| } | |
| } | |
| }, | |
| '---', | |
| { | |
| opcode: 'getControllerStick', | |
| text: 'joystick axis [XY] of controller #[INDEX]', | |
| blockType: BlockType.REPORTER, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| XY: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector2' | |
| }, | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'getControllerTrig', | |
| text: 'analog value of [TRIGGER] trigger on controller #[INDEX]', | |
| blockType: BlockType.REPORTER, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| TRIGGER: { | |
| type: ArgumentType.STRING, | |
| menu: 'trig' | |
| }, | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'getControllerButton', | |
| text: 'button [BUTTON] on controller #[INDEX] pressed?', | |
| blockType: BlockType.BOOLEAN, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| BUTTON: { | |
| type: ArgumentType.STRING, | |
| menu: 'butt' | |
| }, | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'getControllerTouching', | |
| text: '[BUTTON] on controller #[INDEX] touched?', | |
| blockType: BlockType.BOOLEAN, | |
| blockIconURI: IconController, | |
| disableMonitor: true, | |
| arguments: { | |
| BUTTON: { | |
| type: ArgumentType.STRING, | |
| menu: 'buttAll' | |
| }, | |
| INDEX: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| } | |
| } | |
| }, | |
| ], | |
| menus: { | |
| vector3: { | |
| acceptReporters: true, | |
| items: [ | |
| "x", | |
| "y", | |
| "z", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| vector2: { | |
| acceptReporters: true, | |
| items: [ | |
| "x", | |
| "y", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| butt: { | |
| acceptReporters: true, | |
| items: [ | |
| "a", | |
| "b", | |
| "x", | |
| "y", | |
| "joystick", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| trig: { | |
| acceptReporters: true, | |
| items: [ | |
| "back", | |
| "side", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| buttAll: { | |
| acceptReporters: true, | |
| items: [ | |
| "a button", | |
| "b button", | |
| "x button", | |
| "y button", | |
| "joystick", | |
| "back trigger", | |
| "side trigger", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| count: { | |
| acceptReporters: true, | |
| items: [ | |
| "1", | |
| "2", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| } | |
| }; | |
| } | |
| // util | |
| _getRenderer() { | |
| if (!this._3d) return; | |
| return this._3d.renderer; | |
| } | |
| _getGamepad(indexFrom1) { | |
| const index = Cast.toNumber(indexFrom1) - 1; | |
| const three = this._3d; | |
| if (!three.scene) return; | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| const session = renderer.xr.getSession(); | |
| if (!session) return; | |
| const sources = session.inputSources; | |
| const controller = sources[index]; | |
| if (!controller) return; | |
| const gamepad = controller.gamepad; | |
| return gamepad; | |
| } | |
| _getController(index) { | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return null; | |
| // try to use grip first (which typically has position/quaternion) | |
| const grip = renderer.xr.getControllerGrip(index); | |
| return grip || renderer.xr.getController(index); | |
| } | |
| _getInputSource(index) { | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return null; | |
| const session = renderer.xr.getSession(); | |
| if (!session) return null; | |
| const sources = session.inputSources; | |
| return sources[index] || null; | |
| } | |
| _disposeImmersive() { | |
| this.session = null; | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| renderer.xr.enabled = false; | |
| // Clear the animation loop so Three.js stops calling it | |
| renderer.setAnimationLoop(null); | |
| } | |
| async _requestWakeLock() { | |
| if ('wakeLock' in navigator) { | |
| try { | |
| // Request a screen wake lock to prevent idling | |
| this.wakeLock = await navigator.wakeLock.request('screen'); | |
| this.wakeLock.addEventListener('release', () => { | |
| console.log('Wake Lock was released'); | |
| }); | |
| console.log('Wake Lock is active'); | |
| } catch (err) { | |
| console.error('Failed to acquire wake lock:', err); | |
| } | |
| } | |
| } | |
| _releaseWakeLock() { | |
| if (this.wakeLock) { | |
| this.wakeLock.release(); | |
| this.wakeLock = null; | |
| } | |
| } | |
| async _createImmersive() { | |
| if (!('xr' in navigator)) return false; | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return false; | |
| const sessionInit = { | |
| optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking', 'layers'] | |
| }; | |
| const session = await navigator.xr.requestSession(SESSION_TYPE, sessionInit); | |
| this.session = session; | |
| this.open = true; | |
| renderer.xr.enabled = true; | |
| await renderer.xr.setSession(session); | |
| // Request the wake lock so that the session keeps updating even when idle | |
| await this._requestWakeLock(); | |
| // When session ends, reset state and release wake lock. | |
| session.addEventListener("end", () => { | |
| this.open = false; | |
| this._disposeImmersive(); | |
| this._releaseWakeLock(); | |
| }); | |
| // Request a reference space (store it so we can use it for the poses) | |
| session.requestReferenceSpace("local").then(space => { | |
| this.localSpace = space; | |
| }); | |
| // Use Three.js's setAnimationLoop to drive the render loop | |
| renderer.setAnimationLoop((time, frame) => { | |
| if (!this.open) return; | |
| const threed = this._3d; | |
| if (!threed.camera || !threed.scene) return; | |
| // Render the scene | |
| renderer.render(threed.scene, threed.camera); | |
| // Update controller poses if available | |
| if (this.localSpace && frame) { | |
| this.controllerPoses = {}; | |
| const sources = session.inputSources; | |
| for (let i = 0; i < sources.length; i++) { | |
| const inputSource = sources[i]; | |
| const pose = frame.getPose(inputSource.targetRaySpace, this.localSpace); | |
| if (pose) { | |
| this.controllerPoses[i] = { | |
| position: pose.transform.position, // {x, y, z} | |
| orientation: pose.transform.orientation // {x, y, z, w} | |
| }; | |
| } | |
| } | |
| } | |
| }); | |
| return session; | |
| } | |
| // blocks | |
| isSupported() { | |
| if (!('xr' in navigator)) return false; | |
| return navigator.xr.isSessionSupported(SESSION_TYPE); | |
| } | |
| isOpened() { | |
| return this.open; | |
| } | |
| createSession() { | |
| if (this.open) return; | |
| if (this.session) return; | |
| return this._createImmersive(); | |
| } | |
| closeSession() { | |
| this.open = false; | |
| if (!this.session) return; | |
| return this.session.end(); | |
| } | |
| // extra: attach/detach camera to/from an object in the scene | |
| attachObject(args) { | |
| const three = this._3d; | |
| if (!three.scene) return; | |
| if (!three.camera) return; | |
| const name = Cast.toString(args.OBJECT); | |
| const object = three.scene.getObjectByName(name); | |
| if (!object) return; | |
| object.add(three.camera); | |
| } | |
| detachObject() { | |
| const three = this._3d; | |
| if (!three.scene) return; | |
| if (!three.camera) return; | |
| three.scene.add(three.camera); | |
| } | |
| // Controller input blocks follow | |
| getControllerPosition(args) { | |
| if (!this._3d || !this._3d.scene) return ""; | |
| const index = Cast.toNumber(args.INDEX) - 1; | |
| const v = args.VECTOR3; | |
| if (!v || !["x", "y", "z"].includes(v)) return ""; | |
| // Use stored pose information if available | |
| if (this.controllerPoses && this.controllerPoses[index]) { | |
| return Cast.toNumber(this.controllerPoses[index].position[v]); | |
| } | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return ""; | |
| const controller = this._getController(index); | |
| if (!controller) return ""; | |
| controller.updateMatrixWorld(true); | |
| // Fallback: get world position via Three.js | |
| const Vector3 = (this.three && this.three.three && this.three.three.Vector3) | |
| ? this.three.three.Vector3 | |
| : this.three.Vector3; | |
| const position = new Vector3(); | |
| controller.getWorldPosition(position); | |
| return Cast.toNumber(position[v]); | |
| } | |
| getControllerRotation(args) { | |
| if (!this._3d || !this._3d.scene) return ""; | |
| const index = Cast.toNumber(args.INDEX) - 1; | |
| const v = args.VECTOR3; | |
| if (!v || !["x", "y", "z"].includes(v)) return ""; | |
| // Use stored orientation if available | |
| if (this.controllerPoses && this.controllerPoses[index]) { | |
| const o = this.controllerPoses[index].orientation; | |
| const Quaternion = (this.three && this.three.three && this.three.three.Quaternion) | |
| ? this.three.three.Quaternion | |
| : this.three.Quaternion; | |
| const quaternion = new Quaternion(o.x, o.y, o.z, o.w); | |
| const Euler = (this.three && this.three.three && this.three.three.Euler) | |
| ? this.three.three.Euler | |
| : this.three.Euler; | |
| const euler = new Euler(0, 0, 0, 'YXZ'); | |
| euler.setFromQuaternion(quaternion, 'YXZ'); | |
| return toDegRounding(euler[v]); | |
| } | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return ""; | |
| const controller = this._getController(index); | |
| if (!controller) return ""; | |
| controller.updateMatrixWorld(true); | |
| const Quaternion = (this.three && this.three.three && this.three.three.Quaternion) | |
| ? this.three.three.Quaternion | |
| : this.three.Quaternion; | |
| const quaternion = new Quaternion(); | |
| controller.getWorldQuaternion(quaternion); | |
| const Euler = (this.three && this.three.three && this.three.three.Euler) | |
| ? this.three.three.Euler | |
| : this.three.Euler; | |
| const euler = new Euler(0, 0, 0, 'YXZ'); | |
| euler.setFromQuaternion(quaternion, 'YXZ'); | |
| return toDegRounding(euler[v]); | |
| } | |
| getControllerSide(args) { | |
| const three = this._3d; | |
| if (!three.scene) return ""; | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return ""; | |
| const session = renderer.xr.getSession(); | |
| if (!session) return ""; | |
| const sources = session.inputSources; | |
| const index = Cast.toNumber(args.INDEX) - 1; | |
| const controller = sources[index]; | |
| if (!controller) return ""; | |
| return controller.handedness; | |
| } | |
| getControllerStick(args) { | |
| const gamepad = this._getGamepad(args.INDEX); | |
| if (!gamepad) return 0; | |
| // For 'y', use axis index 3, otherwise default to index 2. | |
| if (Cast.toString(args.XY) === "y") { | |
| return gamepad.axes[3]; | |
| } else { | |
| return gamepad.axes[2]; | |
| } | |
| } | |
| getControllerTrig(args) { | |
| const gamepad = this._getGamepad(args.INDEX); | |
| if (!gamepad) return 0; | |
| if (Cast.toString(args.TRIGGER) === "side") { | |
| return gamepad.buttons[1] ? gamepad.buttons[1].value : 0; | |
| } else { | |
| return gamepad.buttons[0] ? gamepad.buttons[0].value : 0; | |
| } | |
| } | |
| getControllerButton(args) { | |
| const gamepad = this._getGamepad(args.INDEX); | |
| if (!gamepad) return false; | |
| const inputSource = this._getInputSource(Cast.toNumber(args.INDEX) - 1); | |
| let handedness = 'right'; | |
| if (inputSource && inputSource.handedness) { | |
| handedness = inputSource.handedness; | |
| } | |
| const button = Cast.toString(args.BUTTON).toLowerCase(); | |
| if (handedness === 'right') { | |
| switch (button) { | |
| case 'a': | |
| return gamepad.buttons[4] && gamepad.buttons[4].pressed; | |
| case 'b': | |
| return gamepad.buttons[5] && gamepad.buttons[5].pressed; | |
| case 'joystick': | |
| return gamepad.buttons[3] && gamepad.buttons[3].pressed; | |
| } | |
| } else if (handedness === 'left') { | |
| switch (button) { | |
| case 'x': | |
| return gamepad.buttons[4] && gamepad.buttons[4].pressed; | |
| case 'y': | |
| return gamepad.buttons[5] && gamepad.buttons[5].pressed; | |
| case 'joystick': | |
| return gamepad.buttons[3] && gamepad.buttons[3].pressed; | |
| } | |
| } | |
| return false; | |
| } | |
| getControllerTouching(args) { | |
| const gamepad = this._getGamepad(args.INDEX); | |
| if (!gamepad) return false; | |
| const inputSource = this._getInputSource(Cast.toNumber(args.INDEX) - 1); | |
| let handedness = 'right'; | |
| if (inputSource && inputSource.handedness) { | |
| handedness = inputSource.handedness; | |
| } | |
| const button = Cast.toString(args.BUTTON).toLowerCase(); | |
| if (handedness === 'right') { | |
| switch (button) { | |
| case 'a button': | |
| return gamepad.buttons[4] && gamepad.buttons[4].touched; | |
| case 'b button': | |
| return gamepad.buttons[5] && gamepad.buttons[5].touched; | |
| case 'joystick': | |
| return gamepad.buttons[3] && gamepad.buttons[3].touched; | |
| case 'back trigger': | |
| return gamepad.buttons[0] && gamepad.buttons[0].touched; | |
| case 'side trigger': | |
| return gamepad.buttons[1] && gamepad.buttons[1].touched; | |
| } | |
| } else if (handedness === 'left') { | |
| switch (button) { | |
| case 'x button': | |
| return gamepad.buttons[4] && gamepad.buttons[4].touched; | |
| case 'y button': | |
| return gamepad.buttons[5] && gamepad.buttons[5].touched; | |
| case 'joystick': | |
| return gamepad.buttons[3] && gamepad.buttons[3].touched; | |
| case 'back trigger': | |
| return gamepad.buttons[0] && gamepad.buttons[0].touched; | |
| case 'side trigger': | |
| return gamepad.buttons[1] && gamepad.buttons[1].touched; | |
| } | |
| } | |
| return false; | |
| } | |
| } | |
| module.exports = Jg3DVrBlocks; | |