├── .gitignore ├── package.json ├── server.js ├── LICENSE ├── README.md ├── lib └── fast-sync.js └── dist └── fast-sync-component.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | demos/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fast-sync", 3 | "version": "1.2.0", 4 | "description": "A-Frame component for Syncing over Web Sockets", 5 | "main": "./lib/fast-sync.js", 6 | "dependencies": { 7 | "eslint": "^4.1.1", 8 | "express": "^4.15.3", 9 | "uws": "^8.14.0" 10 | }, 11 | "devDependencies": {}, 12 | "scripts": { 13 | "start": "node server.js" 14 | }, 15 | "author": "Ada Rose Edwards", 16 | "license": "MIT", 17 | "prettier": { 18 | "useTabs": true, 19 | "singleQuote": true, 20 | "bracketSpacing": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /* eslint-env es6 */ 2 | /* eslint no-console: 0 */ 3 | 'use strict'; 4 | 5 | const server = require('http').createServer(); 6 | const express = require('express'); 7 | const app = express(); 8 | const port = process.env.PORT || 3000; 9 | const fastSync = require('./'); 10 | 11 | // Options from https://github.com/websockets/ws/blob/master/doc/ws.md 12 | // Set up the WebSocket Server; 13 | const wss = fastSync(server, { 14 | path: '/fast-sync/', 15 | debug: true 16 | }); 17 | 18 | // Make the client side script available on /fast-sync/ 19 | app.use(wss.dist); 20 | 21 | app.use(express.static('demos', { 22 | maxAge: 3600 * 1000 * 24 23 | })); 24 | 25 | server.on('request', app); 26 | 27 | server.listen(port, function () { 28 | console.log('Listening on ' + server.address().port) 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Samsung Internet Dev Rel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A-Frame fast-sync 2 | 3 | Used to efficiently sync the position and rotation of 100s of A-Frame objects at 30fps, written to be as fast and light as possible. 4 | 5 | # Usage 6 | 7 | Requires a Node server, 8 | 9 | ``` 10 | npm install --save fast-sync 11 | ``` 12 | 13 | ## In your server: 14 | 15 | There is a complete example in [the demo](https://glitch.com/edit/#!/fast-sync?path=server.js:1:0) 16 | 17 | ``` 18 | const server = require('http').createServer(); 19 | 20 | // Options from https://github.com/websockets/ws/blob/master/doc/ws.md 21 | // Set up the WebSocket Server; 22 | const wss = fastSync(server, { 23 | path: '/fast-sync/', 24 | debug: true 25 | }); 26 | ``` 27 | 28 | ## On the client 29 | 30 | Include the dist file in your client: 31 | 32 | ``` 33 | 34 | 35 | 36 | 37 | ``` 38 | ## Configure the aframe system: 39 | 40 | By default it uses the room 'demo' and the url as the url of the page + '/fast-sync/' 41 | 42 | ``` 43 | 44 | ... 45 | 46 | 47 | ``` 48 | 49 | ## Simple syncing 50 | 51 | When it is initiated it will clone itself onto any remote users with the following components copied: 52 | 53 | material, color, shadow, id, class, geometry, scale 54 | 55 | As well as any components defined in components. 56 | 57 | ``` 58 | 59 | 60 | 61 | ``` 62 | 63 | 30 times a second it will sync it's position with the server. 64 | -------------------------------------------------------------------------------- /lib/fast-sync.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Refactor as a node module. 3 | */ 4 | 5 | /* eslint-env es6 */ 6 | /* eslint no-console: 0 */ 7 | 'use strict'; 8 | 9 | const WebSocketServer = require('uws').Server; 10 | const SYNC_INTERVAL = 1000 / 30; 11 | let wsServers = new Map(); 12 | const extend = require('util')._extend; 13 | const express = require('express'); 14 | const path = require('path'); 15 | 16 | // 0th entry is always filled 17 | let ids = [true]; 18 | 19 | function wsHandleErr(e) { 20 | if (e) { 21 | console.log('Fast-Sync error:' + Date.now() + ' ' + e.message); 22 | } 23 | } 24 | 25 | function getWebSocketServer(server, options) { 26 | const rooms = {}; 27 | if (wsServers.has(server)) { 28 | return wsServers.get(server); 29 | } 30 | const wss = new WebSocketServer(extend({ server: server }, options)); 31 | wsServers.set(server, wss); 32 | wss.on('connection', function connection(ws) { 33 | let id = ids.indexOf(false); 34 | if (id === -1) { 35 | id = ids.push(true) - 1; 36 | } 37 | ws.id = id; 38 | ws._size = 0; 39 | 40 | ws.on('close', function close() { 41 | const roomies = []; 42 | wss.clients.forEach( 43 | function(ws) { 44 | if (ws._room === this._room) roomies.push(ws); 45 | }.bind(this) 46 | ); 47 | 48 | const roomiesIds = roomies.map(ws => ws.id); 49 | 50 | roomies.forEach(function(ws) { 51 | ws.send(JSON.stringify(['UPDATE_USERS', roomiesIds]), wsHandleErr); 52 | }); 53 | 54 | if (!!options.debug) 55 | console.log('user with id', id, 'left room', ws._room); 56 | if (rooms[ws._room]) rooms[ws._room].removeWs(ws); 57 | }); 58 | 59 | ws.on('message', function incoming(message) { 60 | // Rebroadcast any string messages 61 | if (typeof message === 'string') { 62 | if (message === '__ping__') { 63 | return ws.send('__pong__', wsHandleErr); 64 | } 65 | let data; 66 | try { 67 | data = JSON.parse(message); 68 | } catch (e) { 69 | if (!!options.debug) console.log('INVALID JSON:' + message); 70 | return; 71 | } 72 | if (data[0] === 'HANDSHAKE') { 73 | if (!!options.debug) 74 | console.log('user with id', id, 'joined room', data[1]); 75 | 76 | ws._room = data[1]; 77 | const room = rooms[ws._room] || new Room(options.debug); 78 | rooms[ws._room] = room; 79 | if (!room.hasWs()) { 80 | room.addWs(ws); 81 | } 82 | 83 | const roomies = []; 84 | wss.clients.forEach(function(ws) { 85 | if (ws._room === data[1]) roomies.push(ws); 86 | }); 87 | 88 | const roomiesIds = roomies.map(ws => ws.id); 89 | roomiesIds.push(id); 90 | roomies.forEach(function(ws) { 91 | ws.send(JSON.stringify(['UPDATE_USERS', roomiesIds]), wsHandleErr); 92 | }); 93 | 94 | return; 95 | } 96 | 97 | // By default rebroadcast 98 | 99 | // send to specific user 100 | if (data.length === 3) { 101 | message = JSON.stringify([data[0], id, data[2]]); 102 | wss.clients.forEach(function(otherWs) { 103 | otherWs.send(message); 104 | }); 105 | return; 106 | } 107 | 108 | // send to everyone 109 | if (data.length === 2) { 110 | message = JSON.stringify([data[0], id, data[1]]); 111 | wss.clients.forEach(function(otherWs) { 112 | otherWs.send(message); 113 | }); 114 | return; 115 | } 116 | } else { 117 | if (!ws._room) return; 118 | 119 | const room = rooms[ws._room]; 120 | 121 | // if the size of the data from this ws grows then update it 122 | if (ws._size < message.byteLength) { 123 | ws._size = message.byteLength; 124 | room.updateSize(); 125 | } 126 | 127 | room.set(ws, Buffer.from(message)); 128 | } 129 | }); 130 | 131 | ws.isAlive = true; 132 | ws.on('pong', heartbeat); 133 | 134 | ws.send(JSON.stringify(['HANDSHAKE', id]), wsHandleErr); 135 | }); 136 | 137 | setInterval(function ping() { 138 | wss.clients.forEach(function each(ws) { 139 | if (ws.isAlive === false) { 140 | return ws.terminate(); 141 | } 142 | 143 | ws.isAlive = false; 144 | ws.ping('', false, true); 145 | }); 146 | }, 5000); 147 | 148 | setInterval(function() { 149 | const roomKeys = Object.keys(rooms); 150 | for (const roomKey of roomKeys) { 151 | const room = rooms[roomKey]; 152 | if (room._clean) continue; 153 | for (const ws of room.webSockets) { 154 | ws.send(new Buffer(room._buffer.buffer, room._start), function() { 155 | room.clean(); 156 | }); 157 | } 158 | } 159 | }, SYNC_INTERVAL); 160 | 161 | wss.dist = express(); 162 | 163 | wss.dist.use( 164 | options.path, 165 | express.static(path.resolve(path.join(__dirname, '../', 'dist'))) 166 | ); 167 | 168 | return wss; 169 | } 170 | 171 | /* 172 | * Used to maintain a Buffer for the room which grows as needed 173 | */ 174 | class Room { 175 | constructor(debug) { 176 | this.webSockets = []; 177 | this._buffer = Buffer.alloc(1); 178 | this.clean(); 179 | this.debug = !!debug; 180 | } 181 | 182 | hasWs(ws) { 183 | return this.webSockets.indexOf(ws) !== -1; 184 | } 185 | 186 | addWs(ws) { 187 | if (this.webSockets.indexOf(ws) === -1) { 188 | this.webSockets.push(ws); 189 | this.updateSize(); 190 | } 191 | } 192 | 193 | removeWs(ws) { 194 | if (this.webSockets.indexOf(ws) !== -1) { 195 | // Pull out the old websockets and filter to remove the one that left 196 | // Then put them back 197 | const old = this.webSockets.splice(0).filter(testWs => testWs !== ws); 198 | this.webSockets.push(...old); 199 | this.updateSize(); 200 | } 201 | } 202 | 203 | updateSize() { 204 | this._size = this.webSockets.reduce((a, b) => a + b._size, 0); 205 | this._buffer = Buffer.alloc(this._size); 206 | if (this.debug) console.log('Buffer Update', this._size); 207 | this.clean(); 208 | } 209 | 210 | clean() { 211 | this._buffer.fill(0); 212 | this._clean = true; 213 | this._start = Infinity; 214 | } 215 | 216 | set(ws, data) { 217 | let offset = 0; 218 | for (let i = 0; i < this.webSockets.length; i++) { 219 | const testWs = this.webSockets[i]; 220 | if (ws === testWs) break; 221 | offset += testWs._size; 222 | } 223 | if (this._start > offset) this._start = offset; 224 | this._clean = false; 225 | data.copy(this._buffer, offset); 226 | } 227 | } 228 | 229 | function heartbeat() { 230 | this.isAlive = true; 231 | } 232 | 233 | module.exports = getWebSocketServer; 234 | -------------------------------------------------------------------------------- /dist/fast-sync-component.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* global AFRAME, Promise, Uint32Array, Map, Set, THREE, Float32Array */ 3 | /* eslint no-var: 0 */ 4 | 5 | var localObjectTracker = []; 6 | var connectedUsersIds = new Set(); 7 | var isLocal = 8 | location.hostname === 'localhost' || location.hostname === '127.0.0.1'; 9 | var radToDeg = 180 / Math.PI; 10 | 11 | // Connection opened 12 | function getNewWS(url, callbacks) { 13 | return new Promise(function(resolve, reject) { 14 | var interval = -1; 15 | var ws; 16 | try { 17 | ws = new WebSocket(url); 18 | } catch (e) { 19 | return reject(e); 20 | } 21 | ws.binaryType = 'arraybuffer'; 22 | 23 | ws.isAlive = true; 24 | 25 | ws.addEventListener('message', function m(e) { 26 | if (typeof e.data === 'string') { 27 | if (e.data === '__pong__') { 28 | ws.isAlive = true; 29 | return; 30 | } 31 | var data = JSON.parse(e.data); 32 | if (ws.id === undefined && data[0] === 'HANDSHAKE') { 33 | ws.id = data[1]; 34 | return resolve(ws); 35 | } 36 | if (data[0] === 'UPDATE_USERS') { 37 | var newUsers = new Set(data[1]); 38 | newUsers.delete(ws.id); 39 | newUsers.forEach(function(id) { 40 | if (!connectedUsersIds.has(id)) { 41 | connectedUsersIds.add(id); 42 | callbacks.userJoinCallback(ws, id); 43 | } 44 | }); 45 | Array.from(connectedUsersIds).forEach(function(id) { 46 | if (!newUsers.has(id)) { 47 | callbacks.userLeaveCallback(id); 48 | connectedUsersIds.delete(ws, id); 49 | } 50 | }); 51 | return; 52 | } 53 | if (data[0] === 'UPDATE_REMOTE_EL') { 54 | if (data[1] === ws.id) return; 55 | callbacks.createForeignEl(ws, data[1], data[2]); 56 | return; 57 | } 58 | if (data[0] === 'REMOVE_REMOTE_EL') { 59 | if (data[1] === ws.id) return; 60 | callbacks.removeForeignEl(ws, data[1], data[2]); 61 | return; 62 | } 63 | if (data[0] === 'UPDATE_HTML') { 64 | if (data[1] === ws.id) return; 65 | callbacks.onUpdateHTML(ws, data[1], data[2]); 66 | return; 67 | } 68 | if (data[0] === 'STEAL_EL') { 69 | callbacks.stealEl(ws, data[1], data[2]); 70 | return; 71 | } 72 | } else { 73 | var temp = new Uint32Array(e.data); 74 | callbacks.messageCallback(ws, temp); 75 | } 76 | }); 77 | 78 | ws.addEventListener('close', terminate); 79 | 80 | ws.addEventListener('open', function firstOpen() { 81 | /* eslint-disable no-console */ 82 | console.log('Connected to the server...'); 83 | 84 | interval = setInterval(function ping() { 85 | if (ws.isAlive === false) { 86 | console.log('Timeout...'); 87 | terminate(); 88 | } 89 | ws.isAlive = false; 90 | ws.send('__ping__'); 91 | }, 3000); 92 | 93 | ws.removeEventListener('open', firstOpen); 94 | /* eslint-enable no-console */ 95 | }); 96 | 97 | function terminate() { 98 | ws.close(); 99 | clearInterval(interval); 100 | } 101 | }); 102 | } 103 | 104 | function checkForSyncId(el) { 105 | if (el.components['fast-sync'].syncId === undefined) return false; 106 | return true; 107 | } 108 | 109 | function checkSyncDataChanged(el) { 110 | var syncDataObj = el.components['fast-sync'].getSyncData(); 111 | var oldData = el._cachedSyncData; 112 | var dirty = false; 113 | var oldValue; 114 | oldValue = oldData[0]; 115 | oldData[0] = syncDataObj.rotation.x; 116 | if (oldData[0] !== oldValue) dirty = true; 117 | 118 | oldValue = oldData[1]; 119 | oldData[1] = syncDataObj.rotation.y; 120 | if (oldData[1] !== oldValue) dirty = true; 121 | 122 | oldValue = oldData[2]; 123 | oldData[2] = syncDataObj.rotation.z; 124 | if (oldData[2] !== oldValue) dirty = true; 125 | 126 | oldValue = oldData[3]; 127 | oldData[3] = syncDataObj.position.x; 128 | if (oldData[3] !== oldValue) dirty = true; 129 | 130 | oldValue = oldData[4]; 131 | oldData[4] = syncDataObj.position.y; 132 | if (oldData[4] !== oldValue) dirty = true; 133 | 134 | oldValue = oldData[5]; 135 | oldData[5] = syncDataObj.position.z; 136 | if (oldData[5] !== oldValue) dirty = true; 137 | 138 | return dirty; 139 | } 140 | 141 | AFRAME.registerSystem('fast-sync-controller', { 142 | schema: { 143 | room: { 144 | default: 'demo' 145 | }, 146 | url: { 147 | default: (isLocal ? 'ws://' : 'wss://') + location.host + '/fast-sync/' 148 | } 149 | }, 150 | init: function() { 151 | this.objects = new Map(); 152 | this.foreignObjects = new Map(); 153 | this._wsPromise = getNewWS(this.data.url, { 154 | messageCallback: this.onbinary.bind(this), 155 | createForeignEl: this.onupdate.bind(this), 156 | removeForeignEl: this.onremove.bind(this), 157 | userJoinCallback: this.onuserjoin.bind(this), 158 | onUpdateHTML: this.onUpdateHTML.bind(this), 159 | stealEl: this.onstealel.bind(this), 160 | userLeaveCallback: function(id) { 161 | /* eslint-disable no-console */ 162 | 163 | // Find and clean up any elements belonging to that user. 164 | var els = Array.from( 165 | this.el.querySelectorAll( 166 | '[fast-sync-listener^="original-creator: ' + id + ';"]' 167 | ) 168 | ); 169 | els.forEach(function(el) { 170 | el.parentNode.removeChild(el); 171 | }); 172 | 173 | this.foreignObjects.delete(id); 174 | 175 | this.el.emit('userleave', { 176 | id: id, 177 | count: this.foreignObjects.size 178 | }); 179 | 180 | console.log('User left', id); 181 | /* eslint-enable no-console */ 182 | }.bind(this) 183 | }); 184 | this.getWs().then(ws => { 185 | ws.send(JSON.stringify(['HANDSHAKE', this.data.room])); 186 | this._ws = ws; 187 | }); 188 | this.tick = AFRAME.utils.throttleTick(this.throttledTick, 1000 / 30, this); 189 | }, 190 | throttledTick: function() { 191 | if (!this._ws) return; 192 | var toSerial = Array.from(this.objects.values()).filter(checkForSyncId); 193 | var filtered = this.forceSync 194 | ? toSerial 195 | : toSerial.filter(checkSyncDataChanged); 196 | this.forceSync = false; 197 | var count = toSerial.length; 198 | if (!filtered.length) { 199 | return; 200 | } 201 | var bindata = new Uint32Array(2 + 7 * count); 202 | var index = 2; 203 | bindata[0] = this._ws.id; 204 | bindata[1] = count; 205 | 206 | toSerial.forEach(function(el) { 207 | var accessFloatAsInt = new Uint32Array(el._cachedSyncData.buffer); 208 | bindata[0 + index] = el.components['fast-sync'].syncId; 209 | bindata[1 + index] = accessFloatAsInt[0]; 210 | bindata[2 + index] = accessFloatAsInt[1]; 211 | bindata[3 + index] = accessFloatAsInt[2]; 212 | bindata[4 + index] = accessFloatAsInt[3]; 213 | bindata[5 + index] = accessFloatAsInt[4]; 214 | bindata[6 + index] = accessFloatAsInt[5]; 215 | index += 7; 216 | }); 217 | 218 | this._ws.send(bindata); 219 | }, 220 | onuserjoin: function(ws, id) { 221 | /* eslint-disable */ 222 | console.log('User joined: ' + ws.id, id, location.pathname); 223 | /*eslint-enable */ 224 | // Update newly joined user 225 | Array.from(this.objects.values()).forEach(function(el) { 226 | if (ws.id !== id) 227 | el.components['fast-sync'] 228 | .getSyncTemplate(ws.id) 229 | .then(function(template) { 230 | ws.send(JSON.stringify(['UPDATE_REMOTE_EL', id, template])); 231 | }); 232 | }); 233 | 234 | this.el.emit('userjoin', { 235 | id: id, 236 | count: this.foreignObjects.size 237 | }); 238 | 239 | // Make sure the new user gets the position and rotation information too 240 | this.forceSync = true; 241 | }, 242 | onbinary: function(ws, message) { 243 | var index = 0; 244 | while (index < message.length) { 245 | var id = message[index]; 246 | 247 | // Skip long sections of zeros 248 | if (id === 0) while (id === 0) id = message[++index]; 249 | 250 | var count = message[index + 1]; 251 | 252 | // skip self 253 | if (id === ws.id) { 254 | index += 2 + count * 7; 255 | continue; 256 | } 257 | 258 | if (count > 1024) { 259 | // Throw away the data. 260 | throw Error('Something probably went wrong'); 261 | } 262 | 263 | // iterate over all the data 264 | while (count--) { 265 | var syncId = message[index + 2]; 266 | if (this.foreignObjects.has(id + ',' + syncId)) { 267 | // sync rotation and position 268 | var el = this.foreignObjects.get(id + ',' + syncId); 269 | var accessIntAsFloat = new Float32Array( 270 | message.buffer, 271 | (index + 3) * 4, 272 | 6 273 | ); 274 | el.setAttribute('rotation', { 275 | x: accessIntAsFloat[0], 276 | y: accessIntAsFloat[1], 277 | z: accessIntAsFloat[2] 278 | }); 279 | el.setAttribute('position', { 280 | x: accessIntAsFloat[3], 281 | y: accessIntAsFloat[4], 282 | z: accessIntAsFloat[5] 283 | }); 284 | } 285 | index += 7; 286 | } 287 | index += 2; 288 | } 289 | }, 290 | onupdate: function(ws, id, details) { 291 | if (id === ws.id) return; 292 | var fOId = id + ',' + details.syncId; 293 | var el; 294 | if (details.was) { 295 | var wasFOId = details.was.originalCreator + ',' + details.was.syncId; 296 | var el = this.foreignObjects.get(wasFOId); 297 | if (!el) 298 | throw Error('No element with that Id in Foreign Objects', wasFOId); 299 | this.foreignObjects.delete(wasFOId); 300 | this.foreignObjects.set(fOId, el); 301 | } 302 | if (details.html) { 303 | var oldEl = this.foreignObjects.get(fOId); 304 | if (oldEl) oldEl.parentNode.removeChild(oldEl); 305 | this.sceneEl.insertAdjacentHTML('beforeend', details.html); 306 | var el = this.sceneEl.lastElementChild; 307 | this.foreignObjects.set(fOId, el); 308 | } 309 | 310 | Element.prototype.setAttribute.call( 311 | el, 312 | 'fast-sync-listener', 313 | 'original-creator: ' + id + '; sync-id: ' + details.syncId + ';' 314 | ); 315 | el._fastSyncConfig = details.config; 316 | el.transferables = details.transferables; 317 | }, 318 | onremove: function(ws, id, details) { 319 | var el = this.foreignObjects.get(id + ',' + details.syncId); 320 | el.parentNode.remove(el); 321 | }, 322 | onUpdateHTML: function(ws, id, details) { 323 | var el = this.foreignObjects.get(id + ',' + details.syncId); 324 | 325 | if (el) { 326 | el.innerHTML = details.htmlString; 327 | } else { 328 | // If el is not there yet then wait a second to see if it appears. 329 | setTimeout(() => { 330 | var el = this.foreignObjects.get(id + ',' + details.syncId); 331 | el.innerHTML = details.htmlString; 332 | }, 1000); 333 | } 334 | }, 335 | onstealel: function(ws, id, o) { 336 | var details = o.idData; 337 | var options = o.options; 338 | var fOId = details.originalCreator + ',' + details.syncId; 339 | if (id === ws.id) { 340 | this.foreignObjects 341 | .get(fOId) 342 | .components['fast-sync-listener']._stealComplete(); 343 | this.foreignObjects.delete(fOId); 344 | } else { 345 | if (details.originalCreator === ws.id) { 346 | var el = this.objects.get(details.syncId); 347 | 348 | // My item has been stolen, give it up 349 | el.components['fast-sync'].teardown(); 350 | el.removeAttribute('fast-sync'); 351 | 352 | if (options.transfer) { 353 | options.transfer.forEach(function(attr) { 354 | el.removeAttribute(attr); 355 | }); 356 | } 357 | 358 | // Assign it as a foreign object so it can be found updated later 359 | this.foreignObjects.set(fOId, el); 360 | 361 | el.emit('stolen'); 362 | } else { 363 | // Someone's object has been stolen, transfer ownership from old user to new user 364 | } 365 | } 366 | }, 367 | register: function(el) { 368 | var id; 369 | return this.getWs() 370 | .then(ws => { 371 | var idIndex = localObjectTracker.includes(false) 372 | ? localObjectTracker.indexOf(false) 373 | : localObjectTracker.length; 374 | localObjectTracker[idIndex] = el; 375 | var actualId = 1024 * ws.id + idIndex; 376 | this.objects.set(actualId, el); 377 | id = actualId; 378 | return actualId; 379 | }) 380 | .then(function() { 381 | return id; 382 | }); 383 | }, 384 | updateEl: function(el) { 385 | this.getWs(ws => { 386 | return el.components['fast-sync'] 387 | .getSyncTemplate(ws.id) 388 | .then(function(template) { 389 | ws.send(JSON.stringify(['UPDATE_REMOTE_EL', template])); 390 | }); 391 | }); 392 | }, 393 | stealEl: function(data, options) { 394 | this.getWs().then(ws => { 395 | ws.send( 396 | JSON.stringify([ 397 | 'STEAL_EL', 398 | { 399 | idData: data, 400 | options: options 401 | } 402 | ]) 403 | ); 404 | }); 405 | }, 406 | 407 | syncHTML: function(syncId, string) { 408 | this.getWs().then(ws => { 409 | ws.send( 410 | JSON.stringify([ 411 | 'UPDATE_HTML', 412 | { 413 | syncId: syncId, 414 | htmlString: string 415 | } 416 | ]) 417 | ); 418 | }); 419 | }, 420 | 421 | removeEl: function(syncId) { 422 | this.objects.delete(syncId); 423 | this.getWs().then(ws => { 424 | ws.send( 425 | JSON.stringify([ 426 | 'REMOVE_REMOTE_EL', 427 | { 428 | syncId: syncId 429 | } 430 | ]) 431 | ); 432 | }); 433 | }, 434 | teardownEl: function(syncId) { 435 | this.objects.delete(syncId); 436 | }, 437 | 438 | getWs: function getWs(callback) { 439 | // If it's not blocking on the websocket then just return the promise 440 | if (!typeof callback !== 'function') { 441 | return this._wsPromise; 442 | } 443 | 444 | // If it is blocking on the websocket then wait for that promise to resolve and update _wsPromise 445 | this._wsPromise = this._wsPromise.then( 446 | function(ws) { 447 | var maybePromise = callback && callback.bind(this)(ws); 448 | return maybePromise.constructor === Promise 449 | ? maybePromise.then(function() { 450 | return ws; 451 | }) 452 | : ws; 453 | }.bind(this) 454 | ); 455 | return this._wsPromise; 456 | }, 457 | 458 | getId: function() { 459 | return this.getWs().then(function(ws) { 460 | return ws.id; 461 | }); 462 | } 463 | }); 464 | 465 | AFRAME.registerComponent('fast-sync-listener', { 466 | schema: { 467 | originalCreator: { 468 | type: 'number' 469 | }, 470 | syncId: { 471 | type: 'number' 472 | } 473 | }, 474 | steal: function(options) { 475 | options = options || {}; 476 | if (this.stealPromise) return this.stealPromise; 477 | this.el.sceneEl.systems['fast-sync-controller'].stealEl( 478 | this.data, 479 | options || {} 480 | ); 481 | this.stealPromise = new Promise( 482 | function(resolve) { 483 | this.stealResolve = resolve; 484 | }.bind(this) 485 | ).then( 486 | function() { 487 | this.el.removeAttribute('fast-sync-listener'); 488 | this.el.setAttribute('fast-sync', this.el._fastSyncConfig); 489 | this.el._fastSyncWas = this.data; 490 | if (options.transfer) 491 | options.transfer.forEach( 492 | function(attr) { 493 | this.el.setAttribute(attr, this.el.transferables[attr]); 494 | }.bind(this) 495 | ); 496 | }.bind(this) 497 | ); 498 | return this.stealPromise; 499 | }, 500 | _stealComplete: function() { 501 | if (this.stealResolve) this.stealResolve(); 502 | } 503 | }); 504 | 505 | // configuration of the observer: 506 | var config = { 507 | attributes: true, 508 | childList: true, 509 | characterData: true, 510 | subtree: true 511 | }; 512 | 513 | AFRAME.registerComponent('fast-sync', { 514 | schema: { 515 | // Instead of copying self, copy another element 516 | copy: { 517 | type: 'selector' 518 | }, 519 | 520 | components: { 521 | default: '' 522 | }, 523 | 524 | transferables: { 525 | default: '' 526 | }, 527 | 528 | world: { 529 | default: false 530 | }, 531 | 532 | syncContents: { 533 | default: false 534 | } 535 | }, 536 | init: function() { 537 | this.el._cachedSyncData = new Float32Array(6); 538 | this._registerPromise = this.el.sceneEl.systems['fast-sync-controller'] 539 | .register(this.el) 540 | .then( 541 | function(syncId) { 542 | this.syncId = syncId; 543 | return syncId; 544 | }.bind(this) 545 | ); 546 | 547 | this.syncHTML = ''; 548 | 549 | // create an observer instance 550 | this.observer = new MutationObserver(() => { 551 | this.syncHTML = this.el.innerHTML; 552 | }); 553 | }, 554 | update: function() { 555 | this.observer.disconnect(); 556 | if (this.data.syncContents) { 557 | this.observer.observe(this.el, config); 558 | this.syncHTML = this.el.innerHTML; 559 | } 560 | this.el.sceneEl.systems['fast-sync-controller'].updateEl(this.el); 561 | }, 562 | tick: function() { 563 | if (this.syncHTML) { 564 | var syncHTML = this.syncHTML; 565 | this.syncHTML = ''; 566 | 567 | this._registerPromise.then( 568 | function(syncId) { 569 | this.el.sceneEl.systems['fast-sync-controller'].syncHTML( 570 | syncId, 571 | syncHTML 572 | ); 573 | }.bind(this) 574 | ); 575 | } 576 | }, 577 | getSyncData: (function() { 578 | var converter; 579 | return function() { 580 | if (this.data.world) { 581 | var worldRot = this.el.object3D.getWorldRotation(); 582 | worldRot.x *= radToDeg; 583 | worldRot.y *= radToDeg; 584 | worldRot.z *= radToDeg; 585 | return { 586 | position: this.el.object3D.getWorldPosition(), 587 | rotation: worldRot 588 | }; 589 | } 590 | 591 | var el = this.el; 592 | var pos = el.components.position.data; 593 | var rot; 594 | if (el.components.quaternion) { 595 | var data = el.components.quaternion.data; 596 | converter = converter || new THREE.Euler(); 597 | converter.setFromQuaternion(data, 'YXZ'); 598 | converter.x *= radToDeg; 599 | converter.y *= radToDeg; 600 | converter.z *= radToDeg; 601 | rot = converter; 602 | } else { 603 | rot = el.components.rotation.data; 604 | } 605 | 606 | return { 607 | rotation: rot, 608 | position: pos 609 | }; 610 | }; 611 | })(), 612 | getSyncTemplate: function() { 613 | return this._registerPromise.then( 614 | function(syncId) { 615 | var config = { 616 | components: this.data.components, 617 | transferables: this.data.transferables, 618 | world: this.data.world 619 | }; 620 | var components = [ 621 | 'material', 622 | 'color', 623 | 'shadow', 624 | 'id', 625 | 'class', 626 | 'geometry', 627 | 'scale' 628 | ].concat( 629 | this.data.components.split(',').map(function(s) { 630 | return s.toLowerCase().trim(); 631 | }) 632 | ); 633 | 634 | var transferables = {}; 635 | if (this.data.transferables) 636 | this.data.transferables.split(',').forEach( 637 | function(s) { 638 | var attr = s.toLowerCase().trim(); 639 | transferables[attr] = Element.prototype.getAttribute.call( 640 | this.el, 641 | attr 642 | ); 643 | }.bind(this) 644 | ); 645 | 646 | if (this.el._fastSyncWas) { 647 | var was = this.el._fastSyncWas; 648 | delete this.el._fastSyncWas; 649 | return { 650 | was: was, 651 | syncId: syncId, 652 | config: config, 653 | transferables: transferables 654 | }; 655 | } 656 | 657 | var newEl; 658 | 659 | if (this.data.copy !== null) { 660 | newEl = this.data.copy.cloneNode(); 661 | } else { 662 | newEl = this.el.cloneNode(); 663 | } 664 | 665 | Array.from(newEl.attributes).forEach(function(a) { 666 | if (components.includes(a.name.toLowerCase())) return; 667 | newEl.removeAttribute(a.name); 668 | }); 669 | 670 | return { 671 | html: newEl.outerHTML, 672 | syncId: syncId, 673 | config: config, 674 | transferables: transferables 675 | }; 676 | }.bind(this) 677 | ); 678 | }, 679 | remove: function() { 680 | if (this._tornDown === true) return; 681 | this._registerPromise.then( 682 | function(syncId) { 683 | this.el.sceneEl.systems['fast-sync-controller'].removeEl(syncId); 684 | }.bind(this) 685 | ); 686 | }, 687 | teardown: function() { 688 | this._tornDown = true; 689 | this.observer.disconnect(); 690 | this._registerPromise.then( 691 | function(syncId) { 692 | this.el.sceneEl.systems['fast-sync-controller'].teardownEl(syncId); 693 | }.bind(this) 694 | ); 695 | } 696 | }); 697 | --------------------------------------------------------------------------------