soiz1's picture
Upload 811 files
30c32c8 verified
raw
history blame
13.8 kB
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;