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; | |