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;