diff --git a/app.html b/app.html
index 4241cb1..0a345c5 100644
--- a/app.html
+++ b/app.html
@@ -21,6 +21,7 @@
+
@@ -352,118 +353,79 @@
subtree: true,
});
-const _makePeerConnection = peerConnectionId => {
- const peerConnectionConfig = {
- iceServers: [
- {'urls': 'stun:stun.stunprotocol.org:3478'},
- {'urls': 'stun:stun.l.google.com:19302'},
- ],
- };
+let rtcWs = null;
+const peerConnections = [];
+const _rtcConnect = (type, userName, channelName, onInitState) => {
+ rtcWs = new XRChannelConnection(`${LAMBDA_URLS.presenceWs}?u=${encodeURIComponent(userName)}&c=${encodeURIComponent(channelName)}`);
- const peerConnection = new RTCPeerConnection(peerConnectionConfig);
- peerConnection.ontrack = e => {
- console.log('got track', e);
- };
+ rtcWs.addEventListener('peerconnection', e => {
+ const peerConnection = e.detail;
- let skinMesh = null;
- const sendChannel = peerConnection.createDataChannel('sendChannel');
- peerConnection.sendChannel = sendChannel;
- let pingInterval = 0;
- let updateInterval = 0;
- sendChannel.onopen = () => {
- // console.log('data channel local open');
+ let skinMesh = null;
+ peerConnection.addEventListener('open', () => {
+ // console.log('data channel local open');
- skinMesh = _makeSkinMesh();
- skinMesh.setSkinUrl(DEFAULT_SKIN_URL);
- skinMeshes.push(skinMesh);
+ peerConnections.push(peerConnection);
- peerConnection.open = true;
+ skinMesh = _makeSkinMesh();
+ skinMesh.setSkinUrl(DEFAULT_SKIN_URL);
+ skinMeshes.push(skinMesh);
- if (landState) {
- // emit owned xr-iframes
-
- const landXrIframe = root.childNodes[0];
- const ownedXrIframes = _getOwnedXrIframes(landXrIframe);
- let state = parse5.parseFragment(`${ownedXrIframes.map(xrIframe => xrIframe.outerHTML).join('')}`);
- state = _normalizeEl(state);
- sendChannel.send(JSON.stringify({
- method: 'initState',
- state,
- }));
- }
+ if (landState) {
+ // emit owned xr-iframes
- pingInterval = setInterval(() => {
- sendChannel.send(JSON.stringify({
- method: 'ping',
- }));
- }, 1000);
-
- updateInterval = setInterval(() => {
- const c = renderer.vr.enabled ? renderer.vr.getCamera(camera) : camera;
- const gamepads = navigator.getGamepads().slice(0, 2);
-
- sendChannel.send(JSON.stringify({
- method: 'pose',
- hmd: {
- position: c.position.toArray(),
- quaternion: c.quaternion.toArray(),
- },
- gamepads: gamepads.map(gamepad => {
- if (gamepad) {
- return {
- position: Array.from(gamepad.pose.position),
- quaternion: Array.from(gamepad.pose.orientation),
- visible: true,
- };
- } else {
- return {
- visible: false,
- };
+ const landXrIframe = root.childNodes[0];
+ const ownedXrIframes = _getOwnedXrIframes(landXrIframe);
+ let state = parse5.parseFragment(`${ownedXrIframes.map(xrIframe => xrIframe.outerHTML).join('')}`);
+ state = _normalizeEl(state);
+ sendChannel.send(JSON.stringify({
+ method: 'initState',
+ state,
+ }));
+ }
+ });
+ peerConnection.addEventListener('close', () => {
+ const index = peerConnections.indexOf(peerConnection);
+ if (index !== -1) {
+ peerConnections.splice(index, 1);
+ }
+
+ if (skinMesh) {
+ skinMesh.destroy();
+ skinMeshes.splice(skinMeshes.indexOf(skinMesh), 1);
+ skinMesh = null;
+ }
+
+ if (landState) {
+ // remove owned xr-iframes
+
+ const ownedXrIframes = Array.from(root.querySelectorAll('xr-iframe')).filter(xrIframe => /^owned:/.test(xrIframe.id));
+ for (let i = 0; i < ownedXrIframes.length; i++) {
+ const ownedXrIframe = ownedXrIframes[i];
+ const match = ownedXrIframe.id.match(/^owned:(.*?)-/);
+ if (match) {
+ const ownerId = match[1];
+ if (ownerId === peerConnectionId) {
+ ownedXrIframe.parentNode.removeChild(ownedXrIframe);
+ }
}
- }),
- }));
- }, 20);
- };
- sendChannel.onclose = () => {
- // console.log('data channel local close');
+ }
+ }
+ });
- _cleanup();
- };
- sendChannel.onerror = err => {
- // console.log('data channel local error', err);
- };
- let watchdogTimeout = 0;
- const _kick = () => {
- if (watchdogTimeout) {
- clearTimeout(watchdogTimeout);
- watchdogTimeout = 0;
- }
- watchdogTimeout = setTimeout(() => {
- peerConnection.close();
- }, 5000);
- };
- _kick();
- peerConnection.ondatachannel = e => {
- const {channel} = e;
- // console.log('data channel remote open', channel);
- channel.onclose = () => {
- // console.log('data channel remote close');
- peerConnection.close();
- };
- channel.onerror = err => {
- // console.log('data channel remote error', err);
- };
- channel.onmessage = e => {
+ peerConnection.addEventListener('pose', e => {
+ if (skinMesh) {
+ const {detail: data} = e;
+ const {hmd, gamepads} = data;
+ skinMesh.setState(hmd, gamepads);
+ }
+ });
+ peerConnection.addEventListener('message', e => {
// console.log('data channel message', e.data);
const data = JSON.parse(e.data);
const {method} = data;
- if (method === 'pose') {
- if (skinMesh) {
- const {hmd, gamepads} = data;
- skinMesh.setState(hmd, gamepads);
- }
- } else if (landState && method === 'initState') {
+ if (landState && method === 'initState') {
const {state} = data;
const stateXrSite = state.childNodes.find(node => node.tagName === 'xr-site');
const stateXrIframes = stateXrSite ? stateXrSite.childNodes.filter(node => node.tagName === 'xr-iframe') : [];
@@ -476,63 +438,80 @@
}
} else if (landState && method === 'editState') {
_applyEditState(data.spec);
- } else if (method === 'ping') {
- // nothing
} else {
- console.warn('unknown method', {method});
+ console.warn('unknown peer connection method', {method});
}
+ });
+ });
+ rtcWs.addEventListener('message', e => {
+ const data = JSON.parse(e.data);
+ const {method} = data;
+ if (method === 'initState') {
+ onInitState(data.state);
+ } else if (method === 'editState') {
+ _applyEditState(data.spec);
+ } else {
+ console.warn('unknown presence connection method', {method});
+ }
+ });
- _kick();
- };
- peerConnection.recvChannel = channel;
+ rtcWs.type = type;
+ rtcWs.userName = userName;
+ rtcWs.channelName = channelName;
+ rtcWs.pushScene = () => {
+ const html = root.getHTML();
+ rtcWs.send(JSON.stringify({
+ method: 'setHtml',
+ html,
+ }));
};
- peerConnection.close = (close => function() {
- _cleanup();
-
- return close.apply(this, arguments);
- })(peerConnection.close);
- const _cleanup = () => {
- if (peerConnection.open) {
- peerConnection.open = false;
- }
- if (pingInterval) {
- clearInterval(pingInterval);
- pingInterval = 0;
- }
+ rtcWs.pushAssets = () => {
+ const landXrIframe = root.childNodes[0];
+ const extentXrIframes = _getUnownedXrIframes(landXrIframe);
+ const assetXrIframes = extentXrIframes.map(extentXrIframe => _getUnownedXrIframes(extentXrIframe)).flat();
+ const html = `\n${assetXrIframes.filter(xrIframe => !!xrIframe.id).map(xrIframe => ' ' + xrIframe.outerHTML).join('\n')}\n\n`;
+ rtcWs.send(JSON.stringify({
+ method: 'setInitialHtml',
+ html,
+ }));
+ };
+
+ let updateInterval = setInterval(() => {
+ const c = renderer.vr.enabled ? renderer.vr.getCamera(camera) : camera;
+ const hmd = {
+ position: c.position.toArray(),
+ quaternion: c.quaternion.toArray(),
+ };
+ const navigatorGamepads = navigator.getGamepads().slice(0, 2);
+ const gamepads = navigatorGamepads.map(navigatorGamepad => {
+ if (navigatorGamepad) {
+ return {
+ position: Array.from(navigatorGamepad.pose.position),
+ quaternion: Array.from(navigatorGamepad.pose.orientation),
+ visible: true,
+ };
+ } else {
+ return {
+ visible: false,
+ };
+ }
+ });
+ rtcWs.update(hmd, gamepads);
+ }, 20);
+ rtcWs.addEventListener('close', () => {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = 0;
}
- const index = peerConnections.indexOf(peerConnection);
- if (index !== -1) {
- peerConnections.splice(index, 1);
- }
- if (skinMesh) {
- skinMesh.destroy();
- skinMeshes.splice(skinMeshes.indexOf(skinMesh), 1);
- skinMesh = null;
- }
- if (landState) {
- // remove owned xr-iframes
-
- const ownedXrIframes = Array.from(root.querySelectorAll('xr-iframe')).filter(xrIframe => /^owned:/.test(xrIframe.id));
- for (let i = 0; i < ownedXrIframes.length; i++) {
- const ownedXrIframe = ownedXrIframes[i];
- const match = ownedXrIframe.id.match(/^owned:(.*?)-/);
- if (match) {
- const ownerId = match[1];
- if (ownerId === peerConnectionId) {
- ownedXrIframe.parentNode.removeChild(ownedXrIframe);
- }
- }
- }
- }
- };
- peerConnection.connectionId = peerConnectionId;
- peerConnection.open = false;
-
- return peerConnection;
+ });
};
+const _rtcDisconnect = () => {
+ if (rtcWs) {
+ rtcWs.disconnectt();
+ rtcWs = null;
+ }
+};
+
const _applyEditState = spec => {
const {method, keyPath, key, value, values} = spec;
const el = _findElByKeyPath(root, keyPath.slice(1));
@@ -581,167 +560,6 @@
}
};
-let rtcWs = null;
-const connectionId = _randomString();
-const peerConnections = [];
-const _rtcConnect = (type, userName, channelName, onInitState) => {
- rtcWs = new WebSocket(`${LAMBDA_URLS.presenceWs}?u=${encodeURIComponent(userName)}&c=${encodeURIComponent(channelName)}`);
- rtcWs.onopen = () => {
- // console.log('presence socket open');
-
- rtcWs.send(JSON.stringify({
- method: 'init',
- connectionId,
- }));
- };
- const _addPeerConnection = peerConnectionId => {
- let peerConnection = peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
- if (peerConnection && !peerConnection.open) {
- peerConnection.close();
- peerConnection = null;
- }
- if (!peerConnection) {
- peerConnection = _makePeerConnection(peerConnectionId);
- peerConnection.onicecandidate = e => {
- // console.log('ice candidate', e.candidate);
-
- rtcWs.send(JSON.stringify({
- dst: peerConnectionId,
- src: connectionId,
- method: 'iceCandidate',
- candidate: e.candidate,
- }));
- };
- peerConnections.push(peerConnection);
-
- if (connectionId < peerConnectionId) {
- peerConnection
- .createOffer()
- .then(offer => {
- peerConnection.setLocalDescription(offer);
-
- rtcWs.send(JSON.stringify({
- dst: peerConnectionId,
- src: connectionId,
- method: 'offer',
- offer,
- }));
- });
- }
- }
- };
- const _removePeerConnection = peerConnectionId => {
- const index = peerConnections.findIndex(peerConnection => peerConnection.connectionId === peerConnectionId);
- if (index !== -1) {
- peerConnections.splice(index, 1)[0].close();
- } else {
- console.warn('no such peer connection', peerConnectionId, peerConnections.map(peerConnection => peerConnection.connectionId));
- }
- };
- rtcWs.onmessage = e => {
- // console.log('got message', e.data);
-
- const data = JSON.parse(e.data);
- const {method} = data;
- if (method === 'join') {
- const {connectionId: peerConnectionId} = data;
- _addPeerConnection(peerConnectionId);
- } else if (method === 'offer') {
- const {src: peerConnectionId, offer} = data;
-
- const peerConnection = peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
- if (peerConnection) {
- peerConnection.setRemoteDescription(offer)
- .then(() => peerConnection.createAnswer())
- .then(answer => {
- peerConnection.setLocalDescription(answer);
-
- rtcWs.send(JSON.stringify({
- dst: peerConnectionId,
- src: connectionId,
- method: 'answer',
- answer,
- }));
- });
- } else {
- console.warn('no such peer connection', peerConnectionId, peerConnections.map(peerConnection => peerConnection.connectionId));
- }
- } else if (method === 'answer') {
- const {src: peerConnectionId, answer} = data;
-
- const peerConnection = peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
- if (peerConnection) {
- peerConnection.setRemoteDescription(answer);
- } else {
- console.warn('no such peer connection', peerConnectionId, peerConnections.map(peerConnection => peerConnection.connectionId));
- }
- } else if (method === 'iceCandidate') {
- const {src: peerConnectionId, candidate} = data;
-
- const peerConnection = peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
- if (peerConnection) {
- peerConnection.addIceCandidate(candidate)
- .catch(err => {
- // console.warn(err);
- });
- } else {
- console.warn('no such peer connection', peerConnectionId, peerConnections.map(peerConnection => peerConnection.connectionId));
- }
- } else if (method === 'leave') {
- const {connectionId: peerConnectionId} = data;
- _removePeerConnection(peerConnectionId);
- } else if (method === 'initState') {
- const {state} = data;
- onInitState(state);
- } else if (method === 'editState') {
- _applyEditState(data.spec);
- }
- };
- rtcWs.onclose = () => {
- clearInterval(pingInterval);
- console.log('rtc closed');
- };
- rtcWs.onerror = err => {
- console.warn('rtc error', err);
- clearInterval(pingInterval);
- };
- rtcWs.type = type;
- rtcWs.userName = userName;
- rtcWs.channelName = channelName;
- rtcWs.pushScene = () => {
- const html = root.getHTML();
- rtcWs.send(JSON.stringify({
- method: 'setHtml',
- html,
- }));
- };
- rtcWs.pushAssets = () => {
- const landXrIframe = root.childNodes[0];
- const extentXrIframes = _getUnownedXrIframes(landXrIframe);
- const assetXrIframes = extentXrIframes.map(extentXrIframe => _getUnownedXrIframes(extentXrIframe)).flat();
- const html = `\n${assetXrIframes.filter(xrIframe => !!xrIframe.id).map(xrIframe => ' ' + xrIframe.outerHTML).join('\n')}\n\n`;
- rtcWs.send(JSON.stringify({
- method: 'setInitialHtml',
- html,
- }));
- };
- const pingInterval = setInterval(() => {
- rtcWs.send(JSON.stringify({
- method: 'ping',
- }));
- }, 30*1000);
-};
-const _rtcDisconnect = () => {
- if (rtcWs) {
- rtcWs.close();
- rtcWs = null;
- }
- for (let i = 0; i < peerConnections[i]; i++) {
- peerConnections[i].close();
- }
- peerConnections.length = 0;
-};
-
let defaultGltf = new THREE.Object3D();
defaultGltf.visible = false;
window.addEventListener('message', e => {
@@ -4435,7 +4253,7 @@
xrIframe.src = href;
xrIframe.name = name;
if (landState) {
- xrIframe.id = `owned:${connectionId}-${_randomString()}`;
+ xrIframe.id = `owned:${rtcWs.connectionId}-${_randomString()}`;
}
root.appendChild(xrIframe);
root.update(); // force mutation observer to run
diff --git a/multiplayer.js b/multiplayer.js
new file mode 100644
index 0000000..eab5403
--- /dev/null
+++ b/multiplayer.js
@@ -0,0 +1,290 @@
+const defaultIceServers = [
+ {'urls': 'stun:stun.stunprotocol.org:3478'},
+ {'urls': 'stun:stun.l.google.com:19302'},
+];
+
+function _randomString() {
+ return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5);
+}
+
+class XRChannelConnection extends EventTarget {
+ constructor(url) {
+ super();
+
+ this.rtcWs = new WebSocket(url);
+ this.connectionId = _randomString();
+ this.peerConnections = [];
+
+ this.rtcWs.onopen = () => {
+ // console.log('presence socket open');
+
+ this.rtcWs.send(JSON.stringify({
+ method: 'init',
+ connectionId: this.connectionId,
+ }));
+ };
+ const _addPeerConnection = peerConnectionId => {
+ let peerConnection = this.peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
+ if (peerConnection && !peerConnection.open) {
+ peerConnection.close();
+ peerConnection = null;
+ }
+ if (!peerConnection) {
+ peerConnection = new XRPeerConnection(peerConnectionId);
+ this.dispatchEvent(new CustomEvent('peerconnection', {
+ detail: peerConnection,
+ }));
+ peerConnection.addEventListener('close', () => {
+ const index = this.peerConnections.indexOf(peerConnection);
+ if (index !== -1) {
+ this.peerConnections.splice(index, 1);
+ }
+ });
+ peerConnection.peerConnection.onicecandidate = e => {
+ // console.log('ice candidate', e.candidate);
+
+ this.rtcWs.send(JSON.stringify({
+ dst: peerConnectionId,
+ src: this.connectionId,
+ method: 'iceCandidate',
+ candidate: e.candidate,
+ }));
+ };
+ this.peerConnections.push(peerConnection);
+
+ if (this.connectionId < peerConnectionId) {
+ peerConnection.peerConnection
+ .createOffer()
+ .then(offer => {
+ peerConnection.peerConnection.setLocalDescription(offer);
+
+ this.rtcWs.send(JSON.stringify({
+ dst: peerConnectionId,
+ src: this.connectionId,
+ method: 'offer',
+ offer,
+ }));
+ });
+ }
+ }
+ };
+ const _removePeerConnection = peerConnectionId => {
+ const index = this.peerConnections.findIndex(peerConnection => peerConnection.connectionId === peerConnectionId);
+ if (index !== -1) {
+ this.peerConnections.splice(index, 1)[0].close();
+ } else {
+ console.warn('no such peer connection', peerConnectionId, this.peerConnections.map(peerConnection => peerConnection.connectionId));
+ }
+ };
+ this.rtcWs.onmessage = e => {
+ // console.log('got message', e.data);
+
+ const data = JSON.parse(e.data);
+ const {method} = data;
+ if (method === 'join') {
+ const {connectionId: peerConnectionId} = data;
+ _addPeerConnection(peerConnectionId);
+ } else if (method === 'offer') {
+ const {src: peerConnectionId, offer} = data;
+
+ const peerConnection = this.peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
+ if (peerConnection) {
+ peerConnection.peerConnection.setRemoteDescription(offer)
+ .then(() => peerConnection.peerConnection.createAnswer())
+ .then(answer => {
+ peerConnection.peerConnection.setLocalDescription(answer);
+
+ this.rtcWs.send(JSON.stringify({
+ dst: peerConnectionId,
+ src: this.connectionId,
+ method: 'answer',
+ answer,
+ }));
+ });
+ } else {
+ console.warn('no such peer connection', peerConnectionId, this.peerConnections.map(peerConnection => peerConnection.connectionId));
+ }
+ } else if (method === 'answer') {
+ const {src: peerConnectionId, answer} = data;
+
+ const peerConnection = this.peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
+ if (peerConnection) {
+ peerConnection.peerConnection.setRemoteDescription(answer);
+ } else {
+ console.warn('no such peer connection', peerConnectionId, this.peerConnections.map(peerConnection => peerConnection.connectionId));
+ }
+ } else if (method === 'iceCandidate') {
+ const {src: peerConnectionId, candidate} = data;
+
+ const peerConnection = this.peerConnections.find(peerConnection => peerConnection.connectionId === peerConnectionId);
+ if (peerConnection) {
+ peerConnection.peerConnection.addIceCandidate(candidate)
+ .catch(err => {
+ // console.warn(err);
+ });
+ } else {
+ console.warn('no such peer connection', peerConnectionId, this.peerConnections.map(peerConnection => peerConnection.connectionId));
+ }
+ } else if (method === 'leave') {
+ const {connectionId: peerConnectionId} = data;
+ _removePeerConnection(peerConnectionId);
+ } else {
+ this.dispatchEvent(new MessageEvent('message', {
+ data: e.data,
+ }));
+ }
+ };
+ this.rtcWs.onclose = () => {
+ clearInterval(pingInterval);
+ console.log('rtc closed');
+ };
+ this.rtcWs.onerror = err => {
+ console.warn('rtc error', err);
+ clearInterval(pingInterval);
+ };
+ const pingInterval = setInterval(() => {
+ this.rtcWs.send(JSON.stringify({
+ method: 'ping',
+ }));
+ }, 30*1000);
+ }
+
+ disconect() {
+ this.rtcWs.close();
+ this.rtcWs = null;
+
+ for (let i = 0; i < this.peerConnections[i]; i++) {
+ this.peerConnections[i].close();
+ }
+ this.peerConnections.length = 0;
+ }
+
+ send(s) {
+ this.rtcWs.send(s);
+ }
+
+ update(hmd, gamepads) {
+ for (let i = 0; i < this.peerConnections.length; i++) {
+ const peerConnection = this.peerConnections[i];
+ if (peerConnection.open) {
+ peerConnection.update(hmd, gamepads);
+ }
+ }
+ }
+}
+window.XRChannelConnection = XRChannelConnection;
+
+class XRPeerConnection extends EventTarget {
+ constructor(peerConnectionId) {
+ super();
+
+ this.connectionId = peerConnectionId;
+
+ this.peerConnection = new RTCPeerConnection({
+ iceServers: defaultIceServers,
+ });
+ this.open = false;
+
+ this.peerConnection.ontrack = e => {
+ console.log('got track', e);
+ };
+
+ const sendChannel = this.peerConnection.createDataChannel('sendChannel');
+ this.peerConnection.sendChannel = sendChannel;
+ let pingInterval = 0;
+ sendChannel.onopen = () => {
+ // console.log('data channel local open');
+
+ this.open = true;
+ this.dispatchEvent(new CustomEvent('open'));
+
+ pingInterval = setInterval(() => {
+ sendChannel.send(JSON.stringify({
+ method: 'ping',
+ }));
+ }, 1000);
+ };
+ sendChannel.onclose = () => {
+ // console.log('data channel local close');
+
+ _cleanup();
+ };
+ sendChannel.onerror = err => {
+ // console.log('data channel local error', err);
+ };
+ let watchdogTimeout = 0;
+ const _kick = () => {
+ if (watchdogTimeout) {
+ clearTimeout(watchdogTimeout);
+ watchdogTimeout = 0;
+ }
+ watchdogTimeout = setTimeout(() => {
+ this.peerConnection.close();
+ }, 5000);
+ };
+ _kick();
+ this.peerConnection.ondatachannel = e => {
+ const {channel} = e;
+ // console.log('data channel remote open', channel);
+ channel.onclose = () => {
+ // console.log('data channel remote close');
+ this.peerConnection.close();
+ };
+ channel.onerror = err => {
+ // console.log('data channel remote error', err);
+ };
+ channel.onmessage = e => {
+ // console.log('data channel message', e.data);
+
+ const data = JSON.parse(e.data);
+ const {method} = data;
+ if (method === 'pose') {
+ this.dispatchEvent(new CustomEvent('pose', {
+ detail: data,
+ }))
+ } else if (method === 'ping') {
+ // nothing
+ } else {
+ this.dispatchEvent(new MessageEvent('message', {
+ data,
+ }));
+ }
+
+ _kick();
+ };
+ this.peerConnection.recvChannel = channel;
+ };
+ this.peerConnection.close = (close => function() {
+ _cleanup();
+
+ return close.apply(this, arguments);
+ })(this.peerConnection.close);
+ const _cleanup = () => {
+ if (this.open) {
+ this.open = false;
+ this.dispatchEvent(new CustomEvent('close'));
+ }
+ if (pingInterval) {
+ clearInterval(pingInterval);
+ pingInterval = 0;
+ }
+ };
+ }
+
+ close() {
+ this.peerConnection.close();
+ }
+
+ send(s) {
+ this.peerConnection.sendChannel.send(s);
+ }
+
+ update(hmd, gamepads) {
+ this.send(JSON.stringify({
+ method: 'pose',
+ hmd,
+ gamepads,
+ }));
+ }
+}
+window.XRPeerConnection = XRPeerConnection;
\ No newline at end of file