import {GameManager, gameManager, HasMovedEvent} from "./GameManager"; import { GroupCreatedUpdatedMessageInterface, MessageUserMovedInterface, MessageUserPositionInterface, PointInterface, PositionInterface } from "../../Connection"; import {CurrentGamerInterface, hasMovedEventName, Player} from "../Player/Player"; import { DEBUG_MODE, ZOOM_LEVEL, POSITION_DELAY } from "../../Enum/EnvironmentVariable"; import {ITiledMap, ITiledMapLayer, ITiledTileSet} from "../Map/ITiledMap"; import {PLAYER_RESOURCES} from "../Entity/Character"; import Texture = Phaser.Textures.Texture; 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"; import {RemotePlayer} from "../Entity/RemotePlayer"; import GameObject = Phaser.GameObjects.GameObject; export enum Textures { Player = "male1" } export interface GameSceneInitInterface { initPosition: PointInterface|null, startLayerName: string|undefined } export class GameScene extends Phaser.Scene { GameManager : GameManager; Terrains : Array; CurrentPlayer: CurrentGamerInterface; MapPlayers : Phaser.Physics.Arcade.Group; MapPlayersByKey : Map = new Map(); Map: Phaser.Tilemaps.Tilemap; Layers : Array; Objects : Array; mapFile: ITiledMap; groups: Map; startX: number; startY: number; circleTexture: CanvasTexture; private initPosition: PositionInterface|null = null; 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 = new Array(); private startLayerName: string|undefined; static createFromUrl(mapUrlFile: string, instance: string): GameScene { let key = GameScene.getMapKeyByUrl(mapUrlFile); return new GameScene(key, mapUrlFile, instance); } constructor(MapKey : string, MapUrlFile: string, instance: string) { super({ key: MapKey }); this.GameManager = gameManager; this.Terrains = []; this.groups = new Map(); this.instance = instance; this.MapKey = MapKey; this.MapUrlFile = MapUrlFile; this.RoomId = this.instance + '__' + this.MapKey; } //hook preload scene preload(): void { this.GameManager.setCurrentGameScene(this); this.load.on('filecomplete-tilemapJSON-'+this.MapKey, (key: string, type: string, data: any) => { this.onMapLoad(data); }); //TODO strategy to add access token this.load.tilemapTiledJSON(this.MapKey, this.MapUrlFile); // If the map has already been loaded as part of another GameScene, the "on load" event will not be triggered. // In this case, we check in the cache to see if the map is here and trigger the event manually. if (this.cache.tilemap.exists(this.MapKey)) { let data = this.cache.tilemap.get(this.MapKey); this.onMapLoad(data); } //add player png PLAYER_RESOURCES.forEach((playerResource: any) => { this.load.spritesheet( playerResource.name, playerResource.img, {frameWidth: 32, frameHeight: 32} ); }); this.load.bitmapFont('main_font', 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); } private onMapLoad(data: any): void { // Triggered when the map is loaded // Load tiles attached to the map recursively this.mapFile = data.data; let url = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf('/')); this.mapFile.tilesets.forEach((tileset) => { if (typeof tileset.name === 'undefined' || typeof tileset.image === 'undefined') { console.warn("Don't know how to handle tileset ", tileset) return; } //TODO strategy to add access token this.load.image(tileset.name, `${url}/${tileset.image}`); }) } //hook initialisation init(initData : GameSceneInitInterface) { if (initData.initPosition !== undefined) { this.initPosition = initData.initPosition; } else if (initData.startLayerName !== undefined) { this.startLayerName = initData.startLayerName; } } //hook create scene create(): void { //initalise map this.Map = this.add.tilemap(this.MapKey); this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => { this.Terrains.push(this.Map.addTilesetImage(tileset.name, tileset.name)); }); //permit to set bound collision this.physics.world.setBounds(0,0, this.Map.widthInPixels, this.Map.heightInPixels); //add layer on map this.Layers = new Array(); let depth = -2; for (let layer of this.mapFile.layers) { if (layer.type === 'tilelayer') { this.addLayer(this.Map.createStaticLayer(layer.name, this.Terrains, 0, 0).setDepth(depth)); } if (layer.type === 'tilelayer' && this.getExitSceneUrl(layer) !== undefined) { this.loadNextGame(layer, this.mapFile.width, this.mapFile.tilewidth, this.mapFile.tileheight); } if (layer.type === 'objectgroup' && layer.name === 'floorLayer') { depth = 10000; } } if (depth === -2) { throw new Error('Your map MUST contain a layer of type "objectgroup" whose name is "floorLayer" that represents the layer characters are drawn at.'); } // If there is an init position passed if (this.initPosition !== null) { this.startX = this.initPosition.x; this.startY = this.initPosition.y; } else { // Now, let's find the start layer if (this.startLayerName) { for (let layer of this.mapFile.layers) { if (this.startLayerName === layer.name && layer.type === 'tilelayer' && this.isStartLayer(layer)) { let startPosition = this.startUser(layer); this.startX = startPosition.x; this.startY = startPosition.y; } } } if (this.startX === undefined) { // If we have no start layer specified or if the hash passed does not exist, let's go with the default start position. for (let layer of this.mapFile.layers) { if (layer.type === 'tilelayer' && layer.name === "start") { let startPosition = this.startUser(layer); this.startX = startPosition.x; this.startY = startPosition.y; } } } } // Still no start position? Something is wrong with the map, we need a "start" layer. if (this.startX === undefined) { console.warn('This map is missing a layer named "start" that contains the available default start positions.'); // Let's start in the middle of the map this.startX = this.mapFile.width * 16; this.startY = this.mapFile.height * 16; } //add entities this.Objects = new Array(); //init event click this.EventToClickOnTile(); //initialise list of other player this.MapPlayers = this.physics.add.group({ immovable: true }); //notify game manager can to create currentUser in map this.createCurrentPlayer(); //initialise camera this.initCamera(); // Let's generate the circle for the group delimiter let circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === 'circleSprite'); if(circleElement) { this.textures.remove('circleSprite'); } this.circleTexture = this.textures.createCanvas('circleSprite', 96, 96); let context = this.circleTexture.context; context.beginPath(); context.arc(48, 48, 48, 0, 2 * Math.PI, false); // context.lineWidth = 5; context.strokeStyle = '#ffffff'; context.stroke(); this.circleTexture.refresh(); // Let's alter browser history let url = new URL(this.MapUrlFile); let path = '/_/'+this.instance+'/'+url.host+url.pathname; if (this.startLayerName) { path += '#'+this.startLayerName; } window.history.pushState({}, 'WorkAdventure', path); } private getExitSceneUrl(layer: ITiledMapLayer): string|undefined { return this.getProperty(layer, "exitSceneUrl") as string|undefined; } private getExitSceneInstance(layer: ITiledMapLayer): string|undefined { return this.getProperty(layer, "exitInstance") as string|undefined; } private isStartLayer(layer: ITiledMapLayer): boolean { return this.getProperty(layer, "startLayer") == true; } private getProperty(layer: ITiledMapLayer, name: string): string|boolean|number|undefined { let properties : any = layer.properties; if (!properties) { return undefined; } let obj = properties.find((property:any) => property.name === name); if (obj === undefined) { return undefined; } return obj.value; } /** * * @param layer * @param mapWidth * @param tileWidth * @param tileHeight */ private loadNextGame(layer: ITiledMapLayer, mapWidth: number, tileWidth: number, tileHeight: number){ let exitSceneUrl = this.getExitSceneUrl(layer); if (exitSceneUrl === undefined) { throw new Error('Layer is not an exit scene layer.'); } let instance = this.getExitSceneInstance(layer); if (instance === undefined) { instance = this.instance; } // TODO: eventually compute a relative URL let absoluteExitSceneUrl = new URL(exitSceneUrl, this.MapUrlFile).href; let exitSceneKey = gameManager.loadMap(absoluteExitSceneUrl, this.scene, instance); let tiles : number[] = layer.data as number[]; for (let key=0; key < tiles.length; key++) { let objectKey = tiles[key]; if(objectKey === 0){ continue; } //key + 1 because the start x = 0; let y : number = parseInt(((key + 1) / mapWidth).toString()); let x : number = key - (y * mapWidth); let hash = new URL(exitSceneUrl, this.MapUrlFile).hash; if (hash) { hash = hash.substr(1); } //push and save switching case // TODO: this is not efficient. We should refactor that to enable a search by key. For instance: this.PositionNextScene[y][x] = exitSceneKey this.PositionNextScene.push({ xStart: (x * tileWidth), yStart: (y * tileWidth), xEnd: ((x +1) * tileHeight), yEnd: ((y + 1) * tileHeight), key: exitSceneKey, hash }) } } /** * @param layer */ private startUser(layer: ITiledMapLayer): PositionInterface { let tiles : any = layer.data; let possibleStartPositions : PositionInterface[] = []; tiles.forEach((objectKey : number, key: number) => { if(objectKey === 0){ return; } let y = Math.floor(key / layer.width); let x = key % layer.width; possibleStartPositions.push({x: x*32, y: y*32}); }); // Get a value at random amongst allowed values if (possibleStartPositions.length === 0) { console.warn('The start layer "'+layer.name+'" for this map is empty.'); return { x: 0, y: 0 }; } // Choose one of the available start positions at random amongst the list of available start positions. return possibleStartPositions[Math.floor(Math.random() * possibleStartPositions.length)]; } //todo: in a dedicated class/function? initCamera() { this.cameras.main.setBounds(0,0, this.Map.widthInPixels, this.Map.heightInPixels); this.cameras.main.startFollow(this.CurrentPlayer); this.cameras.main.setZoom(ZOOM_LEVEL); } addLayer(Layer : Phaser.Tilemaps.StaticTilemapLayer){ this.Layers.push(Layer); } createCollisionWithPlayer() { //add collision layer this.Layers.forEach((Layer: Phaser.Tilemaps.StaticTilemapLayer) => { this.physics.add.collider(this.CurrentPlayer, Layer, (object1: GameObject, object2: GameObject) => { //this.CurrentPlayer.say("Collision with layer : "+ (object2 as Tile).layer.name) }); Layer.setCollisionByProperty({collides: true}); if (DEBUG_MODE) { //debug code to see the collision hitbox of the object in the top layer Layer.renderDebug(this.add.graphics(), { tileColor: null, //non-colliding tiles collidingTileColor: new Phaser.Display.Color(243, 134, 48, 200), // Colliding tiles, faceColor: new Phaser.Display.Color(40, 39, 37, 255) // Colliding face edges }); } }); } createCollisionObject(){ this.Objects.forEach((Object : Phaser.Physics.Arcade.Sprite) => { this.physics.add.collider(this.CurrentPlayer, Object, (object1: any, object2: any) => { //this.CurrentPlayer.say("Collision with object : " + (object2 as Phaser.Physics.Arcade.Sprite).texture.key) }); }) } createCurrentPlayer(){ //initialise player //TODO create animation moving between exit and start this.CurrentPlayer = new Player( this, this.startX, this.startY, this.GameManager.getPlayerName(), this.GameManager.getCharacterSelected(), PlayerAnimationNames.WalkDown, false ); //create collision this.createCollisionWithPlayer(); this.createCollisionObject(); //join room this.GameManager.joinRoom(this.RoomId, this.startX, this.startY, PlayerAnimationNames.WalkDown, false); //listen event to share position of user this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this)) } 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); } EventToClickOnTile(){ // debug code to get a tile properties by clicking on it this.input.on("pointerdown", (pointer: Phaser.Input.Pointer)=>{ //pixel position toz tile position let tile = this.Map.getTileAt(this.Map.worldToTileX(pointer.worldX), this.Map.worldToTileY(pointer.worldY)); if(tile){ this.CurrentPlayer.say("Your touch " + tile.layer.name); } }); } /** * @param time * @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 : RemotePlayer | 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){ // We are completely destroying the current scene to avoid using a half-backed instance when coming back to the same map. this.scene.remove(this.scene.key); this.scene.start(nextSceneKey.key, { startLayerName: nextSceneKey.hash }); } } /** * */ checkToExit(){ if(this.PositionNextScene.length === 0){ return null; } return this.PositionNextScene.find((position : any) => { return position.xStart <= this.CurrentPlayer.x && this.CurrentPlayer.x <= position.xEnd && position.yStart <= this.CurrentPlayer.y && this.CurrentPlayer.y <= position.yEnd }) } public initUsersPosition(usersPosition: MessageUserPositionInterface[]): void { if(!this.CurrentPlayer){ console.error('Cannot initiate users list because map is not loaded yet') return; } let currentPlayerId = this.GameManager.getPlayerId(); // clean map this.MapPlayersByKey.forEach((player: RemotePlayer) => { player.destroy(); this.MapPlayers.remove(player); }); this.MapPlayersByKey = new Map(); // load map usersPosition.forEach((userPosition : MessageUserPositionInterface) => { if(userPosition.userId === currentPlayerId){ return; } this.addPlayer(userPosition); }); } /** * Create new player */ public addPlayer(addPlayerData : AddPlayerInterface) : void{ //check if exist player, if exist, move position if(this.MapPlayersByKey.has(addPlayerData.userId)){ this.updatePlayerPosition({ userId: addPlayerData.userId, position: addPlayerData.position }); return; } //initialise player let player = new RemotePlayer( addPlayerData.userId, this, addPlayerData.position.x, addPlayerData.position.y, addPlayerData.name, addPlayerData.character, addPlayerData.position.direction, addPlayerData.position.moving ); this.MapPlayers.add(player); this.MapPlayersByKey.set(player.userId, player); player.updatePosition(addPlayerData.position); //init collision /*this.physics.add.collider(this.CurrentPlayer, player, (CurrentPlayer: CurrentGamerInterface, MapPlayer: GamerInterface) => { CurrentPlayer.say("Hello, how are you ? "); });*/ } public removePlayer(userId: string) { console.log('Removing player ', userId) let player = this.MapPlayersByKey.get(userId); if (player === undefined) { console.error('Cannot find user with id ', userId); } else { player.destroy(); this.MapPlayers.remove(player); } this.MapPlayersByKey.delete(userId); this.playersPositionInterpolator.removePlayer(userId); } updatePlayerPosition(message: MessageUserMovedInterface): void { let player : RemotePlayer | undefined = this.MapPlayersByKey.get(message.userId); if (player === undefined) { throw new Error('Cannot find player with ID "' + message.userId +'"'); } // 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) { let groupId = groupPositionMessage.groupId; let group = this.groups.get(groupId); if (group !== undefined) { group.setPosition(Math.round(groupPositionMessage.position.x), Math.round(groupPositionMessage.position.y)); } else { // TODO: circle radius should not be hard stored let sprite = new Sprite( this, Math.round(groupPositionMessage.position.x), Math.round(groupPositionMessage.position.y), 'circleSprite'); sprite.setDisplayOrigin(48, 48); this.add.existing(sprite); this.groups.set(groupId, sprite); } } deleteGroup(groupId: string): void { let group = this.groups.get(groupId); if(!group){ return; } group.destroy(); this.groups.delete(groupId); } public static getMapKeyByUrl(mapUrlStart: string) : string { // FIXME: the key should be computed from the full URL of the map. let startPos = mapUrlStart.indexOf('://')+3; let endPos = mapUrlStart.indexOf(".json"); return mapUrlStart.substring(startPos, endPos); } }