Spaces:
Runtime error
Runtime error
| const BlockType = require('../../extension-support/block-type'); | |
| const ArgumentType = require('../../extension-support/argument-type'); | |
| const Cast = require('../../util/cast'); | |
| const SESSION_TYPE = "immersive-vr"; | |
| // WebXR unfortunately does not give us Euler angles easily | |
| // so lets do it ourselves | |
| // 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))); | |
| const euler = [Rx, Ry, Rz]; | |
| return euler; | |
| }; | |
| function toRad(deg) { | |
| return deg * (Math.PI / 180); | |
| } | |
| function toDeg(rad) { | |
| return rad * (180 / Math.PI); | |
| } | |
| /** | |
| * Class of 2025 | |
| * @constructor | |
| */ | |
| class jgVr { | |
| constructor(runtime) { | |
| /** | |
| * The runtime instantiating this block package. | |
| * @type {runtime} | |
| */ | |
| this.runtime = runtime; | |
| this.open = false; | |
| this.session = null; | |
| this.view = null; | |
| this.localSpace = null; | |
| /** | |
| * If true, VR sessions will begin split | |
| * If false, VR sessions will begin with no split | |
| */ | |
| this.splitState = false; | |
| } | |
| /** | |
| * @returns {object} metadata for this extension and its blocks. | |
| */ | |
| getInfo() { | |
| return { | |
| id: 'jgVr', | |
| name: 'Virtual Reality', | |
| color1: '#3888cf', | |
| color2: '#2f72ad', | |
| 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 | |
| }, | |
| '---', // SCREEN SPLITTING SETTINGS | |
| { | |
| opcode: 'enableDisableSplitting', | |
| text: 'turn auto-splitting [ONOFF]', | |
| blockType: BlockType.COMMAND, | |
| arguments: { | |
| ONOFF: { | |
| type: ArgumentType.STRING, | |
| menu: 'onoff' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'splittingOffset', | |
| text: 'set auto-split offset to [PX] pixels', | |
| blockType: BlockType.COMMAND, | |
| arguments: { | |
| PX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: 40 | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'placement169', | |
| text: '[SIDE] x placement', | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| arguments: { | |
| SIDE: { | |
| type: ArgumentType.STRING, | |
| menu: 'side' | |
| } | |
| } | |
| }, | |
| '---', // HEADSET POSITION | |
| { | |
| opcode: 'headsetPosition', | |
| text: 'headset position [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| arguments: { | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'headsetRotation', | |
| text: 'headset rotation [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| arguments: { | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| '---', // CONTROLLER INPUT | |
| { | |
| opcode: 'controllerPosition', | |
| text: 'controller #[COUNT] position [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| arguments: { | |
| COUNT: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| }, | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| { | |
| opcode: 'controllerRotation', | |
| text: 'controller #[COUNT] rotation [VECTOR3]', | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| arguments: { | |
| COUNT: { | |
| type: ArgumentType.NUMBER, | |
| menu: 'count' | |
| }, | |
| VECTOR3: { | |
| type: ArgumentType.STRING, | |
| menu: 'vector3' | |
| } | |
| } | |
| }, | |
| ], | |
| menus: { | |
| vector3: { | |
| acceptReporters: true, | |
| items: [ | |
| "x", | |
| "y", | |
| "z", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| count: { | |
| acceptReporters: true, | |
| items: [ | |
| "1", | |
| "2", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| side: { | |
| acceptReporters: false, | |
| items: [ | |
| "left", | |
| "right", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| onoff: { | |
| acceptReporters: false, | |
| items: [ | |
| "on", | |
| "off", | |
| ].map(item => ({ text: item, value: item })) | |
| }, | |
| } | |
| }; | |
| } | |
| // menus | |
| _isVector3Menu(option) { | |
| const normalized = Cast.toString(option).toLowerCase().trim(); | |
| return ['x', 'y', 'z'].includes(normalized); | |
| } | |
| _onOffBoolean(onoff) { | |
| const normalized = Cast.toString(onoff).toLowerCase().trim(); | |
| return normalized === 'on'; | |
| } | |
| // util | |
| _getCanvas() { | |
| if (!this.runtime) return; | |
| if (!this.runtime.renderer) return; | |
| return this.runtime.renderer.canvas; | |
| } | |
| _getContext() { | |
| if (!this.runtime) return; | |
| if (!this.runtime.renderer) return; | |
| return this.runtime.renderer.gl; | |
| } | |
| _getRenderer() { | |
| if (!this.runtime) return; | |
| return this.runtime.renderer; | |
| } | |
| _disposeImmersive() { | |
| this.session = null; | |
| const gl = this._getContext(); | |
| if (!gl) return; | |
| // bind frame buffer to canvas | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| // reset renderer info | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| renderer.xrEnabled = false; | |
| renderer.xrSplitting = false; | |
| renderer.xrLayer = null; | |
| } | |
| async _createImmersive() { | |
| if (!('xr' in navigator)) return false; | |
| const gl = this._getContext(); | |
| if (!gl) return; | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| await gl.makeXRCompatible(); | |
| const session = await navigator.xr.requestSession(SESSION_TYPE); | |
| this.session = session; | |
| this.open = true; | |
| renderer.xrEnabled = true; | |
| renderer.xrSplitting = this.splitState; | |
| // we need to make sure stuff is back to normal once the vr session is done | |
| // but this isnt always triggered by the close session block | |
| // the user can also close it themselves, so we need to handle that | |
| // this is also triggered by the close session block btw so we dont need | |
| // to repeat | |
| session.addEventListener("end", () => { | |
| this.open = false; | |
| this._disposeImmersive(); | |
| }); | |
| // set render state to use a new layer for the vr session | |
| // renderer will handle this | |
| const layer = new XRWebGLLayer(session, gl, { | |
| alpha: true, | |
| stencil: true, | |
| antialias: false, | |
| }); | |
| session.updateRenderState({ | |
| baseLayer: layer | |
| }); | |
| renderer.xrLayer = layer; | |
| // for debugging & other extensions, never used by the renderer | |
| renderer._xrSession = session; | |
| // setup render loop | |
| const drawFrame = (_, frame) => { | |
| // breaks the loop once the session has ended | |
| if (!this.open) return; | |
| // get view info | |
| const viewerPose = frame.getViewerPose(this.localSpace); | |
| const transform = viewerPose.transform; | |
| // set view info | |
| this.view = { | |
| position: [ | |
| transform.position.x, | |
| transform.position.y, | |
| transform.position.z | |
| ], | |
| quaternion: [ | |
| transform.orientation.w, | |
| transform.orientation.y, | |
| transform.orientation.x, | |
| transform.orientation.z | |
| ] | |
| } | |
| // force renderer to draw a new frame | |
| // otherwise we would only actually draw outside of this loop | |
| // which just ends up showing nothing | |
| // since rendering only happens in session.requestAnimationFrame | |
| renderer.dirty = true; | |
| renderer.draw(); | |
| // loop again | |
| session.requestAnimationFrame(drawFrame); | |
| } | |
| session.requestAnimationFrame(drawFrame); | |
| // reference space | |
| session.requestReferenceSpace("local").then(space => { | |
| this.localSpace = space; | |
| // TODO: add "when position reset" hat? | |
| // done with space.addEventListener("reset") | |
| }); | |
| 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(); | |
| } | |
| // splitting blocks | |
| enableDisableSplitting(args) { | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| const boolean = this._onOffBoolean(args.ONOFF); | |
| this.splitState = boolean; | |
| // setting xrSplitting outside of XR mode WILL work | |
| // so prevent this by just checking if we ARE in XR rendering mode | |
| if (!renderer.xrEnabled) return; | |
| renderer.xrSplitting = this.splitState; | |
| } | |
| splittingOffset(args) { | |
| const renderer = this._getRenderer(); | |
| if (!renderer) return; | |
| // pixels should be negative | |
| // otherwise we push away from the center | |
| const pixels = Cast.toNumber(args.PX); | |
| renderer.xrSplitOffset = 0 - pixels; | |
| } | |
| // inputs | |
| headsetPosition(args) { | |
| if (!this.open) return 0; | |
| if (!this.session) return 0; | |
| if (!this.view) return 0; | |
| const vector3 = Cast.toString(args.VECTOR3).toLowerCase().trim(); | |
| if (!this._isVector3Menu(vector3)) return 0; | |
| const axisArray = ['x', 'y', 'z']; | |
| const idx = axisArray.indexOf(vector3); | |
| return this.view.position[idx] * 100; | |
| } | |
| headsetRotation(args) { | |
| if (!this.open) return 0; | |
| if (!this.session) return 0; | |
| if (!this.view) return 0; | |
| const vector3 = Cast.toString(args.VECTOR3).toLowerCase().trim(); | |
| if (!this._isVector3Menu(vector3)) return 0; | |
| const axisArray = ['x', 'y', 'z']; | |
| const idx = axisArray.indexOf(vector3); | |
| const quaternion = this.view.quaternion; | |
| const euler = quaternionToEuler(quaternion); | |
| return toDeg(euler[idx]); | |
| } | |
| // helper | |
| placement169(args) { | |
| const side = Cast.toString(args.SIDE).toLowerCase().trim(); | |
| const width = this.runtime.stageWidth; | |
| const multX = width / 640; | |
| // this was found with experimentation | |
| // please tell me if stuff needs to be added for certain cases | |
| const valueR = ((640 / 4) - 40) * multX; | |
| const valueL = 0 - valueR; | |
| if (side === 'right') { | |
| return valueR; | |
| } | |
| return valueL; | |
| } | |
| } | |
| module.exports = jgVr; | |