diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index a6c86baa..a326bb1b 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -36,6 +36,10 @@ jobs: run: yarn run lint working-directory: "front" + - name: "Jasmine" + run: yarn test + working-directory: "front" + continuous-integration-back: name: "Continuous Integration Back" diff --git a/front/jasmine.json b/front/jasmine.json new file mode 100644 index 00000000..b51ed79d --- /dev/null +++ b/front/jasmine.json @@ -0,0 +1,5 @@ +{ + "spec_dir": "tests", + "spec_files": ["**/*[tT]est.ts"], + "stopSpecOnExpectationFailure": false +} \ No newline at end of file diff --git a/front/package.json b/front/package.json index 257126d0..c05bd8ba 100644 --- a/front/package.json +++ b/front/package.json @@ -4,11 +4,14 @@ "main": "index.js", "license": "AGPL", "devDependencies": { + "@types/jasmine": "^3.5.10", "@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/parser": "^2.26.0", "eslint": "^6.8.0", "html-webpack-plugin": "^4.3.0", + "jasmine": "^3.5.0", "ts-loader": "^6.2.2", + "ts-node": "^8.10.2", "typescript": "^3.8.3", "webpack": "^4.42.1", "webpack-cli": "^3.3.11", @@ -25,6 +28,7 @@ "scripts": { "start": "webpack-dev-server --open", "build": "webpack", + "test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json", "lint": "node_modules/.bin/eslint src/ . --ext .ts", "fix": "node_modules/.bin/eslint --fix src/ . --ext .ts" } diff --git a/front/src/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 30250d5e..2fbf7979 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -1,13 +1,15 @@ const DEBUG_MODE: boolean = process.env.DEBUG_MODE as any === true; const API_URL = process.env.API_URL || "http://api.workadventure.localhost"; -const ROOM = [process.env.ROOM || "THECODINGMACHINE"]; const RESOLUTION = 3; const ZOOM_LEVEL = 1/*3/4*/; +const POSITION_DELAY = 200; // Wait 200ms between sending position events +const MAX_EXTRAPOLATION_TIME = 250; // Extrapolate a maximum of 250ms if no new movement is sent by the player export { DEBUG_MODE, API_URL, RESOLUTION, ZOOM_LEVEL, - ROOM + POSITION_DELAY, + MAX_EXTRAPOLATION_TIME } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 41f6f212..60acf98d 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -5,7 +5,7 @@ import { MessageUserPositionInterface, PointInterface, PositionInterface } from "../../Connection"; import {CurrentGamerInterface, GamerInterface, hasMovedEventName, Player} from "../Player/Player"; -import { DEBUG_MODE, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable"; +import { DEBUG_MODE, ZOOM_LEVEL, POSITION_DELAY } from "../../Enum/EnvironmentVariable"; import {ITiledMap, ITiledMapLayer, ITiledTileSet} from "../Map/ITiledMap"; import {PLAYER_RESOURCES} from "../Entity/PlayableCaracter"; import Texture = Phaser.Textures.Texture; @@ -13,6 +13,8 @@ import Sprite = Phaser.GameObjects.Sprite; import CanvasTexture = Phaser.Textures.CanvasTexture; import {AddPlayerInterface} from "./AddPlayerInterface"; import {PlayerAnimationNames} from "../Player/Animation"; +import {PlayerMovement} from "./PlayerMovement"; +import {PlayersPositionInterpolator} from "./PlayersPositionInterpolator"; export enum Textures { Player = "male1" @@ -37,12 +39,22 @@ export class GameScene extends Phaser.Scene { startY = 32; // 1 case circleTexture: CanvasTexture; initPosition: PositionInterface; + private playersPositionInterpolator = new PlayersPositionInterpolator(); MapKey: string; MapUrlFile: string; RoomId: string; instance: string; + currentTick: number; + lastSentTick: number; // The last tick at which a position was sent. + lastMoveEventSent: HasMovedEvent = { + direction: '', + moving: false, + x: -1000, + y: -1000 + } + PositionNextScene: Array<any> = new Array<any>(); static createFromUrl(mapUrlFile: string, instance: string): GameScene { @@ -323,6 +335,34 @@ export class GameScene extends Phaser.Scene { } pushPlayerPosition(event: HasMovedEvent) { + if (this.lastMoveEventSent === event) { + return; + } + + // If the player is not moving, let's send the info right now. + if (event.moving === false) { + this.doPushPlayerPosition(event); + return; + } + + // If the player is moving, and if it changed direction, let's send an event + if (event.direction !== this.lastMoveEventSent.direction) { + this.doPushPlayerPosition(event); + return; + } + + // If more than 200ms happened since last event sent + if (this.currentTick - this.lastSentTick >= POSITION_DELAY) { + this.doPushPlayerPosition(event); + return; + } + + // Otherwise, do nothing. + } + + private doPushPlayerPosition(event: HasMovedEvent): void { + this.lastMoveEventSent = event; + this.lastSentTick = this.currentTick; this.GameManager.pushPlayerPosition(event); } @@ -342,7 +382,19 @@ export class GameScene extends Phaser.Scene { * @param delta The delta time in ms since the last frame. This is a smoothed and capped value based on the FPS rate. */ update(time: number, delta: number) : void { + this.currentTick = time; this.CurrentPlayer.moveUser(delta); + + // Let's move all users + let updatedPlayersPositions = this.playersPositionInterpolator.getUpdatedPositions(time); + updatedPlayersPositions.forEach((moveEvent: HasMovedEvent, userId: string) => { + let player : GamerInterface | undefined = this.MapPlayersByKey.get(userId); + if (player === undefined) { + throw new Error('Cannot find player with ID "' + userId +'"'); + } + player.updatePosition(moveEvent); + }); + let nextSceneKey = this.checkToExit(); if(nextSceneKey){ this.scene.start(nextSceneKey.key); @@ -386,15 +438,6 @@ export class GameScene extends Phaser.Scene { }); } - private findPlayerInMap(UserId : string) : GamerInterface | null{ - return this.MapPlayersByKey.get(UserId); - /*let player = this.MapPlayers.getChildren().find((player: Player) => UserId === player.userId); - if(!player){ - return null; - } - return (player as GamerInterface);*/ - } - /** * Create new player */ @@ -437,6 +480,7 @@ export class GameScene extends Phaser.Scene { player.destroy(); this.MapPlayers.remove(player); this.MapPlayersByKey.delete(userId); + this.playersPositionInterpolator.removePlayer(userId); } updatePlayerPosition(message: MessageUserMovedInterface): void { @@ -444,7 +488,11 @@ export class GameScene extends Phaser.Scene { if (player === undefined) { throw new Error('Cannot find player with ID "' + message.userId +'"'); } - player.updatePosition(message.position); + + // We do not update the player position directly (because it is sent only every 200ms). + // Instead we use the PlayersPositionInterpolator that will do a smooth animation over the next 200ms. + let playerMovement = new PlayerMovement({ x: player.x, y: player.y }, this.currentTick, message.position, this.currentTick + POSITION_DELAY); + this.playersPositionInterpolator.updatePlayerPosition(player.userId, playerMovement); } shareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) { diff --git a/front/src/Phaser/Game/PlayerMovement.ts b/front/src/Phaser/Game/PlayerMovement.ts new file mode 100644 index 00000000..1ed2b745 --- /dev/null +++ b/front/src/Phaser/Game/PlayerMovement.ts @@ -0,0 +1,36 @@ +import {HasMovedEvent} from "./GameManager"; +import {MAX_EXTRAPOLATION_TIME} from "../../Enum/EnvironmentVariable"; +import {PositionInterface} from "../../Connection"; + +export class PlayerMovement { + public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) { + } + + public isOutdated(tick: number): boolean { + //console.log(tick, this.endTick, MAX_EXTRAPOLATION_TIME) + + // If the endPosition is NOT moving, no extrapolation needed. + if (this.endPosition.moving === false && tick > this.endTick) { + return true; + } + + return tick > this.endTick + MAX_EXTRAPOLATION_TIME; + } + + public getPosition(tick: number): HasMovedEvent { + // Special case: end position reached and end position is not moving + if (tick >= this.endTick && this.endPosition.moving === false) { + return this.endPosition; + } + + let x = (this.endPosition.x - this.startPosition.x) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.x; + let y = (this.endPosition.y - this.startPosition.y) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.y; + + return { + x, + y, + direction: this.endPosition.direction, + moving: true + } + } +} diff --git a/front/src/Phaser/Game/PlayersPositionInterpolator.ts b/front/src/Phaser/Game/PlayersPositionInterpolator.ts new file mode 100644 index 00000000..19e0f7bc --- /dev/null +++ b/front/src/Phaser/Game/PlayersPositionInterpolator.ts @@ -0,0 +1,31 @@ +/** + * This class is in charge of computing the position of all players. + * Player movement is delayed by 200ms so position depends on ticks. + */ +import {PlayerMovement} from "./PlayerMovement"; +import {HasMovedEvent} from "./GameManager"; + +export class PlayersPositionInterpolator { + playerMovements: Map<string, PlayerMovement> = new Map<string, PlayerMovement>(); + + updatePlayerPosition(userId: string, playerMovement: PlayerMovement) : void { + this.playerMovements.set(userId, playerMovement); + } + + removePlayer(userId: string): void { + this.playerMovements.delete(userId); + } + + getUpdatedPositions(tick: number) : Map<string, HasMovedEvent> { + let positions = new Map<string, HasMovedEvent>(); + this.playerMovements.forEach((playerMovement: PlayerMovement, userId: string) => { + if (playerMovement.isOutdated(tick)) { + //console.log("outdated") + this.playerMovements.delete(userId); + } + //console.log("moving") + positions.set(userId, playerMovement.getPosition(tick)) + }); + return positions; + } +} diff --git a/front/tests/Phaser/Game/PlayerMovementTest.ts b/front/tests/Phaser/Game/PlayerMovementTest.ts new file mode 100644 index 00000000..e65dbec8 --- /dev/null +++ b/front/tests/Phaser/Game/PlayerMovementTest.ts @@ -0,0 +1,76 @@ +import "jasmine"; +import {PlayerMovement} from "../../../src/Phaser/Game/PlayerMovement"; + +describe("Interpolation / Extrapolation", () => { + it("should interpolate", () => { + let playerMovement = new PlayerMovement({ + x: 100, y: 200 + }, 42000, + { + x: 200, y: 100, moving: true, direction: "up" + }, + 42200 + ); + + + expect(playerMovement.isOutdated(42100)).toBe(false); + expect(playerMovement.isOutdated(43000)).toBe(true); + + expect(playerMovement.getPosition(42100)).toEqual({ + x: 150, + y: 150, + direction: 'up', + moving: true + }); + + expect(playerMovement.getPosition(42200)).toEqual({ + x: 200, + y: 100, + direction: 'up', + moving: true + }); + + expect(playerMovement.getPosition(42300)).toEqual({ + x: 250, + y: 50, + direction: 'up', + moving: true + }); + }); + + it("should not extrapolate if we stop", () => { + let playerMovement = new PlayerMovement({ + x: 100, y: 200 + }, 42000, + { + x: 200, y: 100, moving: false, direction: "up" + }, + 42200 + ); + + expect(playerMovement.getPosition(42300)).toEqual({ + x: 200, + y: 100, + direction: 'up', + moving: false + }); + }); + + it("should should keep moving until it stops", () => { + let playerMovement = new PlayerMovement({ + x: 100, y: 200 + }, 42000, + { + x: 200, y: 100, moving: false, direction: "up" + }, + 42200 + ); + + expect(playerMovement.getPosition(42100)).toEqual({ + x: 150, + y: 150, + direction: 'up', + moving: true + }); + }); +}) diff --git a/front/tsconfig.json b/front/tsconfig.json index e30c2f81..c34c1dd2 100644 --- a/front/tsconfig.json +++ b/front/tsconfig.json @@ -4,7 +4,7 @@ "sourceMap": true, "moduleResolution": "node", "noImplicitAny": true, - "module": "es6", + "module": "CommonJS", "target": "es5", "jsx": "react", "allowJs": true diff --git a/front/yarn.lock b/front/yarn.lock index b39a4caf..7ed8c19a 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -62,6 +62,11 @@ resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" integrity sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA== +"@types/jasmine@^3.5.10": + version "3.5.10" + resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.5.10.tgz#a1a41012012b5da9d4b205ba9eba58f6cce2ab7b" + integrity sha512-3F8qpwBAiVc5+HPJeXJpbrl+XjawGmciN5LgiO7Gv1pl1RHtjoMNqZpqEksaPJW05ViKe8snYInRs6xB25Xdew== + "@types/json-schema@^7.0.3": version "7.0.4" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" @@ -403,6 +408,11 @@ aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1204,6 +1214,11 @@ detect-node@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -2500,6 +2515,19 @@ isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" +jasmine-core@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.5.0.tgz#132c23e645af96d85c8bca13c8758b18429fc1e4" + integrity sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA== + +jasmine@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-3.5.0.tgz#7101eabfd043a1fc82ac24e0ab6ec56081357f9e" + integrity sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ== + dependencies: + glob "^7.1.4" + jasmine-core "~3.5.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -2629,6 +2657,11 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + map-age-cleaner@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" @@ -3812,6 +3845,14 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" +source-map-support@^0.5.17: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map-support@~0.5.12: version "0.5.16" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042" @@ -4169,6 +4210,17 @@ ts-loader@^6.2.2: micromatch "^4.0.0" semver "^6.0.0" +ts-node@^8.10.2: + version "8.10.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d" + integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA== + dependencies: + arg "^4.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + tslib@^1.10.0: version "1.13.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" @@ -4605,3 +4657,8 @@ yeast@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==