diff --git a/.eslintrc.js b/.eslintrc.js index 9f0a11b7..575e2c3b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -90,6 +90,7 @@ module.exports = { Guilds: 'writable', Items: 'writable', Quests: 'writable', + Streamy: 'readable', canAccessQuest: 'writable', remote: 'writable', _: 'readable', diff --git a/app/.meteor/packages b/app/.meteor/packages index 272f9f53..14aeb58a 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -49,3 +49,5 @@ accounts-github@1.5.0 accounts-google@1.4.0 accounts-facebook@1.3.3 accounts-passwordless@2.1.3 +yuukan:streamy +yuukan:streamy-rooms diff --git a/app/.meteor/versions b/app/.meteor/versions index b63c60a7..638256dd 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -114,3 +114,5 @@ underscore@1.0.10 url@1.3.2 webapp@1.13.1 webapp-hashing@1.1.0 +yuukan:streamy@1.4.2 +yuukan:streamy-rooms@1.2.5 diff --git a/core/client/lemverse.js b/core/client/lemverse.js index e1c619f9..180780a3 100644 --- a/core/client/lemverse.js +++ b/core/client/lemverse.js @@ -4,6 +4,13 @@ import audioManager from './audio-manager'; import { setReaction } from './helpers'; import initSentryClient from './sentry'; +// Override Meteor._debug to filter custom messages +Meteor._debug = (function (superMeteorDebug) { + return function (error, info) { + if (!info?.msg) superMeteorDebug(error, info); + }; +}(Meteor._debug)); + initSentryClient(); scopes = { diff --git a/core/client/level-manager.js b/core/client/level-manager.js index b7619e22..ea1a1c2f 100644 --- a/core/client/level-manager.js +++ b/core/client/level-manager.js @@ -1,5 +1,7 @@ import Phaser from 'phaser'; +import networkManager from './network-manager'; + const defaultMapConfig = { width: 100, height: 100, tileWidth: 48, tileHeight: 48 }; const defaultLayerCount = 9; const defaultLayerDepth = { 6: 10000, 7: 10001, 8: 10002 }; @@ -114,7 +116,7 @@ levelManager = { loadingScene.setText(''); loadingScene.show(() => { // Phaser sends the sleep event on the next frame which causes the client to overwrite the spawn position set by the server - userManager.onSleep(); + networkManager.onSleep(); this.scene.scene.sleep(); Meteor.call('teleportUserInLevel', levelId, (error, levelName) => { @@ -165,6 +167,9 @@ levelManager = { onLevelLoaded() { this.scene.scene.wake(); + // listen room messages + networkManager.joinRoom(Meteor.user().profile.levelId); + // simulate a first frame update to avoid weirds visual effects with characters animation and direction this.scene.update(0, 0); setTimeout(() => game.scene.keys.LoadingScene.hide(() => this.scene.enableKeyboard(true)), 0); diff --git a/core/client/network-manager.js b/core/client/network-manager.js new file mode 100644 index 00000000..0156898c --- /dev/null +++ b/core/client/network-manager.js @@ -0,0 +1,115 @@ +const characterInterpolationInterval = 200; + +const networkEvents = Object.freeze({ + characterState: 'character:state', +}); + +const networkManager = { + currentRoom: undefined, + userStreamyId: undefined, + throttledSendCharacterState: undefined, + + init() { + this.userStreamyId = Streamy.id(); + this.throttledSendCharacterState = throttle(this._sendCharacterNewState.bind(this), characterInterpolationInterval, { leading: false }); + Streamy.on(networkEvents.characterState, this._onCharacterStateReceived.bind(this)); + }, + + onSleep() { + this.throttledSendCharacterState?.cancel(); + }, + + update() { + this.interpolateCharacterPositions(); + }, + + joinRoom(roomId) { + if (this.currentRoom) Streamy.leave(this.currentRoom.identifier); + + Streamy.join(roomId); + this.currentRoom = Streamy.rooms(roomId); + this.currentRoom.identifier = roomId; + }, + + /** + * Basic interpolation: + * - No rubber banding + * - No extrapolation + * + * Later we could add multiple states to predict positions, …. + * But since we work in tcp and on a simple simulation it doesn't seem necessary to me right now. + */ + interpolateCharacterPositions() { + const now = Date.now(); + const controlledCharacter = userManager.getControlledCharacter(); + + Object.values(userManager.characters).forEach(character => { + if (character === controlledCharacter) return; + + if (!character.lwTargetDate) { + character.setAnimationPaused(true); + return; + } + + character.playAnimation('run', character.direction); + + if (character.lwTargetDate <= now) { + character.x = character.lwTargetX; + character.y = character.lwTargetY; + character.setDepthFromPosition(); + delete character.lwTargetDate; + return; + } + + const elapsedTime = (now - character.lwOriginDate) / (character.lwTargetDate - character.lwOriginDate); + character.x = character.lwOriginX + (character.lwTargetX - character.lwOriginX) * elapsedTime; + character.y = character.lwOriginY + (character.lwTargetY - character.lwOriginY) * elapsedTime; + character.setDepthFromPosition(); + }); + }, + + _onCharacterStateReceived(state) { + if (state.__from === this.userStreamyId) return; + + const character = userManager.getCharacter(state.userId); + if (!character) return; + + character.direction = state.direction; + character.lwOriginX = character.x; + character.lwOriginY = character.y; + character.lwOriginDate = Date.now(); + character.lwTargetX = state.x; + character.lwTargetY = state.y; + character.lwTargetDate = character.lwOriginDate + characterInterpolationInterval; + }, + + sendCharacterNewState(state) { + this.throttledSendCharacterState(state); + }, + + _sendCharacterNewState(state) { + if (!state) return; + + this.currentRoom.emit(networkEvents.characterState, { + x: state.x, + y: state.y, + direction: state.direction, + userId: state.getData('userId'), + }); + }, + + saveCharacterState(state) { + if (!state) return; + + // No need to check that the userId really belongs to the user, Meteor does the check during the update + Meteor.users.update(state.getData('userId'), { + $set: { + 'profile.x': state.x, + 'profile.y': state.y, + 'profile.direction': state.direction, + }, + }); + }, +}; + +export default networkManager; diff --git a/core/client/scenes/scene-world.js b/core/client/scenes/scene-world.js index d278e57c..5d1083ae 100644 --- a/core/client/scenes/scene-world.js +++ b/core/client/scenes/scene-world.js @@ -1,6 +1,8 @@ import nipplejs from 'nipplejs'; import Phaser from 'phaser'; +import networkManager from '../network-manager'; + import { clamp } from '../helpers'; const fixedUpdateInterval = 200; @@ -110,6 +112,7 @@ WorldScene = new Phaser.Class({ entityManager.init(this); levelManager.init(this); + networkManager.init(this); userManager.init(this); zoneManager.init(this); }, @@ -152,6 +155,7 @@ WorldScene = new Phaser.Class({ update() { levelManager.update(); userManager.update(); + networkManager.update(); }, postUpdate(time, delta) { @@ -194,7 +198,7 @@ WorldScene = new Phaser.Class({ }, sleep() { - userManager.onSleep(); + networkManager.onSleep(); }, shutdown() { diff --git a/core/client/user-manager.js b/core/client/user-manager.js index d3832978..c28f373a 100644 --- a/core/client/user-manager.js +++ b/core/client/user-manager.js @@ -1,9 +1,9 @@ import Phaser from 'phaser'; import Character from './components/character'; import audioManager from './audio-manager'; +import networkManager from './network-manager'; import { guestSkin, textDirectionToVector, vectorToTextDirection } from './helpers'; -const userInterpolationInterval = 200; const defaultUserMediaColorError = '0xd21404'; const characterPopInOffset = { x: 0, y: -90 }; const characterAnimations = Object.freeze({ @@ -18,18 +18,6 @@ const messageReceived = { style: 'tooltip with-arrow fade-in', }; -savePlayer = player => { - Meteor.users.update(Meteor.userId(), { - $set: { - 'profile.x': player.x, - 'profile.y': player.y, - 'profile.direction': player.direction, - }, - }); -}; - -const throttledSavePlayer = throttle(savePlayer, userInterpolationInterval, { leading: false }); - userManager = { inputVector: undefined, characters: {}, @@ -48,15 +36,10 @@ userManager = { }, destroy() { - this.onSleep(); this.characters = {}; this.controlledCharacter = undefined; }, - onSleep() { - throttledSavePlayer.cancel(); - }, - onDocumentAdded(user) { if (this.characters[user._id]) return null; @@ -126,16 +109,9 @@ userManager = { const character = this.characters[user._id]; if (!character) return; - const { x, y, direction, reaction, shareAudio, guest, userMediaError, name, baseline, nameColor } = user.profile; + const { x, y, reaction, shareAudio, guest, userMediaError, name, baseline, nameColor } = user.profile; // update character instance - character.direction = direction; - character.lwOriginX = character.x; - character.lwOriginY = character.y; - character.lwOriginDate = Date.now(); - character.lwTargetX = user.profile.x; - character.lwTargetY = user.profile.y; - character.lwTargetDate = character.lwOriginDate + userInterpolationInterval; character.showMutedStateIndicator(!guest && !shareAudio); // is account transformed from guest to user? @@ -235,40 +211,12 @@ userManager = { } }, - interpolateCharacterPositions() { - const now = Date.now(); - Object.values(this.characters).forEach(player => { - if (player === this.controlledCharacter) return; - - if (!player.lwTargetDate) { - player.setAnimationPaused(true); - return; - } - - player.playAnimation(characterAnimations.run, player.direction); - - if (player.lwTargetDate <= now) { - player.x = player.lwTargetX; - player.y = player.lwTargetY; - player.setDepthFromPosition(); - delete player.lwTargetDate; - return; - } - - const elapsedTime = ((now - player.lwOriginDate) / (player.lwTargetDate - player.lwOriginDate)); - player.x = player.lwOriginX + (player.lwTargetX - player.lwOriginX) * elapsedTime; - player.y = player.lwOriginY + (player.lwTargetY - player.lwOriginY) * elapsedTime; - player.setDepthFromPosition(); - }); - }, - update() { if (this.checkZones) { zoneManager.checkDistances(this.controlledCharacter); this.checkZones = false; } - this.interpolateCharacterPositions(); this.controlledCharacter?.updateStep(); }, @@ -314,9 +262,12 @@ userManager = { if (direction) this.controlledCharacter.playAnimation(characterAnimations.run, direction); } else this.controlledCharacter.setAnimationPaused(true); - if (moving || this.controlledCharacter.wasMoving) { + // saves the position in base only when the user stops moving + if (!moving && this.controlledCharacter.wasMoving) { + networkManager.saveCharacterState(this.controlledCharacter); + } else if (moving || this.controlledCharacter.wasMoving) { this.scene.physics.world.update(time, delta); - throttledSavePlayer(this.controlledCharacter); + networkManager.sendCharacterNewState(this.controlledCharacter); } if (!peer.hasActiveStreams()) peer.enableSensor(!(this.controlledCharacter.running && moving)); @@ -327,7 +278,7 @@ userManager = { teleportMainUser(x, y) { this.controlledCharacter.x = x; this.controlledCharacter.y = y; - savePlayer(this.controlledCharacter); + networkManager.saveCharacterState(this.controlledCharacter); }, interact() {