reldens
Version:
Reldens - MMORPG Platform
735 lines (695 loc) • 25.6 kB
JavaScript
/**
*
* Reldens - SceneDynamic
*
* Main game scene class extending Phaser Scene for rendering the game world.
* Manages map creation, tileset animations, minimap, player movement, object interpolation,
* and input handling. Coordinates with GameManager for scene lifecycle and state management.
*
*/
const { Scene, Input } = require('phaser');
const { TileSetAnimation } = require('./tileset-animation');
const { Minimap } = require('./minimap');
const { GameConst } = require('../constants');
const { ActionsConst } = require('../../actions/constants');
const { Logger, sc } = require('@reldens/utils');
/**
* @typedef {import('./game-manager').GameManager} GameManager
*/
class SceneDynamic extends Scene
{
/**
* @param {string} key
* @param {Object} data
* @param {GameManager} gameManager
*/
constructor(key, data, gameManager)
{
super({key});
this.key = key;
this.params = data;
this.gameManager = gameManager;
this.eventsManager = gameManager.events;
this.configManager = gameManager.config;
this.layers = {};
this.transition = true;
this.useTsAnimation = false;
this.arrowSprite = false;
this.objectsAnimationsData = false;
this.objectsAnimations = {};
this.setPropertiesFromConfig();
this.minimap = this.createMinimapInstance(this.minimapConfig);
this.player = false;
this.interpolatePlayersPosition = {};
this.interpolateObjectsPositions = {};
this.generatedTilesets = [];
this.tilesetAnimations = [];
this.stopOnDeathOrDisabledSent = false;
}
/**
* @returns {boolean}
*/
setPropertiesFromConfig()
{
// @TODO - BETA - Move defaults to constants.
if(!this.configManager){
this.configuredFrameRate = 10;
this.clientInterpolation = true;
this.interpolationSpeed = 0.1;
this.minimapConfig = {};
return false;
}
this.configuredFrameRate = this.configManager.getWithoutLogs('client/general/animations/frameRate', 10);
this.clientInterpolation = this.configManager.getWithoutLogs('client/general/engine/clientInterpolation', true);
this.interpolationSpeed = this.configManager.getWithoutLogs('client/general/engine/interpolationSpeed', 0.1);
this.minimapConfig = this.configManager.getWithoutLogs('client/ui/minimap', {});
return true;
}
/**
* @param {Object} config
* @returns {Minimap|false}
*/
createMinimapInstance(config)
{
if(!this.minimapConfig.enabled){
return false;
}
return new Minimap({config, events: this.eventsManager});
}
init()
{
this.scene.setVisible(false, this.key);
this.input.keyboard.removeAllListeners();
}
async create()
{
this.eventsManager.emitSync('reldens.beforeSceneDynamicCreate', this);
this.disableContextMenu();
this.createControllerKeys();
this.setupKeyboardAndPointerEvents();
await this.createSceneMap();
this.cameras.main.on('camerafadeincomplete', () => {
this.transition = false;
this.gameManager.gameDom.activeElement().blur();
this.minimap.createMap(this, this.gameManager.getCurrentPlayerAnimation());
this.gameManager.setChangingScene(false);
});
this.eventsManager.emitSync('reldens.afterSceneDynamicCreate', this);
}
/**
* @param {number} time
* @param {number} delta
*/
update(time, delta)
{
this.interpolatePositions();
this.movePlayerByPressedButtons();
}
/**
* @returns {boolean}
*/
disableContextMenu()
{
if(!this.gameManager.config.get('client/ui/controls/disableContextMenu')){
return false;
}
this.gameManager.gameDom.getDocument().addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
});
return true;
}
setupKeyboardAndPointerEvents()
{
this.input.keyboard.on('keydown', (event) => {
return this.executeKeyDownBehavior(event);
});
this.input.keyboard.on('keyup', (event) => {
this.executeKeyUpBehavior(event);
});
this.input.on('pointerdown', (pointer, currentlyOver) => {
return this.executePointerDownAction(pointer, currentlyOver);
});
}
async createSceneMap()
{
this.map = this.make.tilemap({key: this.params.roomName});
let loadMapImagesData = this.processMissingImagesFromTilesets();
await this.addTilesetImages(loadMapImagesData);
this.registerLayers();
this.registerTilesetAnimation();
}
/**
* @returns {Array<Object>}
*/
processMissingImagesFromTilesets()
{
let mapFromCache = this.cache?.tilemap?.entries?.entries[this.params.roomName]?.data;
let loadMapImages = this.params.sceneImages;
let loadingTilesetImageData = [];
let loadMapImagesData = [];
for(let tileset of mapFromCache.tilesets){
if(!sc.inArray(tileset.image, loadMapImages)){
this.load.image(tileset.image, `/assets/maps/${tileset.image}`).on('loaderror', (event) => {
Logger.error('Load map image error: ' + tileset.image, event);
});
loadingTilesetImageData.push({tilesetName: tileset.name, imageKey: tileset.image});
continue;
}
loadMapImagesData.push({tilesetName: tileset.name, imageKey: tileset.image});
}
if(0 < loadingTilesetImageData.length){
this.load.on('complete', async () => {
for(let data of loadingTilesetImageData){
Logger.debug('Adding tileset "'+data.tilesetName+'" with image key "'+data.imageKey+'".');
let tileset = this.map.addTilesetImage(data.tilesetName, data.imageKey);
this.generatedTilesets.push(tileset);
}
});
this.load.start();
}
return loadMapImagesData;
}
/**
* @param {Array<Object>} loadMapImages
*/
async addTilesetImages(loadMapImages)
{
for(let data of loadMapImages){
let tilesetName = data.tilesetName;
Logger.debug('Adding missing image to tileset "'+tilesetName+'" with image key "'+data.imageKey+'".');
let tileset = this.map.addTilesetImage(tilesetName, data.imageKey);
if(!tileset){
Logger.critical(
'Tileset creation error. Check if the tileset name equals the imageKey without the extension.',
{
roomName: this.params.roomName,
imageKeys: loadMapImages,
createdTileset: tileset
}
);
}
this.generatedTilesets.push(tileset);
}
}
registerTilesetAnimation()
{
for(let tileset of this.generatedTilesets){
if(!this.hasTilesetAnimations(tileset)){
continue;
}
this.useTsAnimation = true;
for(let i of Object.keys(this.layers)){
let layer = this.layers[i];
let tilesetAnimation = new TileSetAnimation();
tilesetAnimation.register(layer, tileset);
tilesetAnimation.start();
this.tilesetAnimations.push(tilesetAnimation);
}
}
}
/**
* @param {Object} tileset
* @returns {boolean}
*/
hasTilesetAnimations(tileset)
{
let tilesData = tileset?.tileData || {};
let dataKeys = Object.keys(tilesData);
if(0 === dataKeys.length){
return false;
}
for(let i of dataKeys){
if(tilesData[i].animation){
return true;
}
}
return false;
}
/**
* @param {KeyboardEvent} event
* @returns {boolean|void}
*/
executeKeyDownBehavior(event)
{
if(this.gameManager.gameDom.insideInput()){
return false;
}
// @TODO - BETA - Make configurable the keys related to the actions and skills.
if(Input.Keyboard.KeyCodes.SPACE === event.keyCode && !this.gameManager.gameDom.insideInput()){
if(!this.player){
return false;
}
this.player.runActions();
}
if(Input.Keyboard.KeyCodes.ESC === event.keyCode){
this.gameManager.gameEngine.clearTarget();
}
if(Input.Keyboard.KeyCodes.F5 === event.keyCode){
this.gameManager.forcedDisconnection = true;
}
return true;
}
/**
* @param {KeyboardEvent} event
*/
executeKeyUpBehavior(event)
{
if(!this.player){
return;
}
// stop all directional keys (arrows and wasd):
let keys = this.availableControllersKeyCodes();
if(-1 !== keys.indexOf(event.keyCode)){
// @NOTE: all keyup events has to be sent.
this.player.stop();
}
}
createControllerKeys()
{
// @TODO - BETA - Controllers will be part of the configuration in the database.
this.keyLeft = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.LEFT);
this.keyA = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.A);
this.keyRight = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.RIGHT);
this.keyD = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.D);
this.keyUp = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.UP);
this.keyW = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.W);
this.keyDown = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.DOWN);
this.keyS = this.input.keyboard.addKey(Input.Keyboard.KeyCodes.S);
let keys = this.availableControllersKeyCodes();
let inputElements = this.gameManager.gameDom.getElements('input');
for(let inputElement of inputElements){
this.addAndRemoveCapture(keys, inputElement);
}
}
/**
* @param {Array<number>} keys
* @param {HTMLElement} inputElement
*/
addAndRemoveCapture(keys, inputElement)
{
this.loopKeysAddListenerToElement(keys, inputElement, 'focusin', 'removeCapture');
this.loopKeysAddListenerToElement(keys, inputElement, 'click', 'removeCapture');
this.loopKeysAddListenerToElement(keys, inputElement, 'focusout', 'addCapture');
this.loopKeysAddListenerToElement(keys, inputElement, 'blur', 'addCapture');
}
/**
* @returns {Array<number>}
*/
availableControllersKeyCodes()
{
return [
Input.Keyboard.KeyCodes.LEFT,
Input.Keyboard.KeyCodes.A,
Input.Keyboard.KeyCodes.RIGHT,
Input.Keyboard.KeyCodes.D,
Input.Keyboard.KeyCodes.UP,
Input.Keyboard.KeyCodes.W,
Input.Keyboard.KeyCodes.DOWN,
Input.Keyboard.KeyCodes.S
];
}
/**
* @param {Phaser.Input.Pointer} pointer
* @param {Array} currentlyOver
* @returns {boolean}
*/
executePointerDownAction(pointer, currentlyOver)
{
if(0 < currentlyOver.length){
return false;
}
if(!this.gameManager.config.get('client/players/tapMovement/enabled')){
return false;
}
if(this.gameManager.activeRoomEvents.roomData?.worldConfig?.applyGravity){
return false;
}
let primaryMove = this.gameManager.config.get('client/ui/controls/primaryMove');
let primaryTouch = this.gameManager.config.get('client/ui/controls/allowPrimaryTouch');
if(
(!pointer.wasTouch && !pointer.primaryDown && primaryMove)
|| (!pointer.wasTouch && pointer.primaryDown && !primaryMove)
|| (pointer.wasTouch && !pointer.primaryDown && primaryTouch)
){
return false;
}
// @TODO - BETA - Temporal avoid double actions, if you target something you will not be moved to the
// pointer, in a future release this will be configurable, so you can walk to objects and they get
// activated, for example: click on and NPC, automatically walk close and automatically get a dialog
// opened.
if(this.gameManager.gameDom.insideInput()){
this.gameManager.gameDom.activeElement().blur();
}
if(!this.appendRowAndColumn(pointer)){
return false;
}
this.player.moveToPointer(pointer);
this.updatePointerObject(pointer);
return true;
}
movePlayerByPressedButtons()
{
// if player is writing there's no movement:
if(this.gameManager.gameDom.insideInput()){
return;
}
if(this.transition || this.gameManager.isChangingScene){
return;
}
if(this.player.isDeath() || this.player.isDisabled()){
if(!this.stopOnDeathOrDisabledSent){
this.player.fullStop();
}
this.stopOnDeathOrDisabledSent = true;
return;
}
// @TODO - BETA - Controllers will be part of the configuration in the database.
if(this.keyRight.isDown || this.keyD.isDown){
this.player.right();
}
if(this.keyLeft.isDown || this.keyA.isDown){
this.player.left();
}
if(this.keyDown.isDown || this.keyS.isDown){
this.player.down();
}
if(this.keyUp.isDown || this.keyW.isDown){
this.player.up();
}
}
interpolatePositions()
{
if(!this.clientInterpolation){
return;
}
this.processPlayersPositionInterpolation();
this.processObjectsPositionInterpolation();
}
/**
* @returns {boolean}
*/
processPlayersPositionInterpolation()
{
let playerKeys = Object.keys(this.interpolatePlayersPosition);
if(0 === playerKeys.length){
return false;
}
if(!sc.get(this.player, 'players')){
return false;
}
for(let i of playerKeys){
let entityState = this.interpolatePlayersPosition[i];
if(!entityState){
continue;
}
let entity = this.player.players[i];
if(!entity){
continue;
}
if(this.isCurrentPosition(entity, entityState)){
delete this.interpolatePlayersPosition[i];
continue;
}
let newX = sc.roundToPrecision(
Phaser.Math.Linear(entity.x, (entityState.x - this.player.leftOff), this.interpolationSpeed),
2
);
let newY = sc.roundToPrecision(
Phaser.Math.Linear(entity.y, (entityState.y - this.player.topOff), this.interpolationSpeed),
2
);
//Logger.debug('Player interpolation update.', newX, newY);
this.player.processPlayerPositionAnimationUpdate(entity, entityState, i, newX, newY);
if(!entityState.mov){
delete this.interpolatePlayersPosition[i];
}
}
return true;
}
/**
* @returns {boolean}
*/
processObjectsPositionInterpolation()
{
let objectsKeys = Object.keys(this.interpolateObjectsPositions);
if(0 === objectsKeys.length){
return false;
}
let objectsPlugin = this.gameManager.getFeature('objects');
for(let i of objectsKeys){
this.interpolateBulletPosition(i, objectsPlugin);
this.interpolateObjectAnimationPosition(i, objectsPlugin);
}
return true;
}
/**
* @param {string} i
* @param {Object} objectsPlugin
* @returns {boolean}
*/
interpolateBulletPosition(i, objectsPlugin)
{
if(!this.isBullet(i)){
return false;
}
let entity = sc.get(objectsPlugin.bullets, i);
if(!entity){
return false;
}
let entityState = this.interpolateObjectsPositions[i];
if(!entityState){
return false;
}
if(this.isCurrentPosition(entity, entityState)){
delete this.interpolateObjectsPositions[i];
return false;
}
let x = sc.roundToPrecision(Phaser.Math.Linear(entity.x, entityState.x, this.interpolationSpeed), 0);
let y = sc.roundToPrecision(Phaser.Math.Linear(entity.y, entityState.y, this.interpolationSpeed), 0);
let bodyData = {x, y};
objectsPlugin.updateBulletBodyPosition(i, bodyData);
if(!entityState.mov){
delete this.interpolateObjectsPositions[i];
}
return true;
}
/**
* @param {string} objectKey
* @returns {boolean}
*/
isBullet(objectKey)
{
return -1 !== objectKey.indexOf('bullet');
}
/**
* @param {string} i
* @param {Object} objectsPlugin
* @returns {boolean}
*/
interpolateObjectAnimationPosition(i, objectsPlugin)
{
let entity = this.objectsAnimations[i];
if(!entity){
return false;
}
let entityState = this.interpolateObjectsPositions[i];
if(!entityState){
return false;
}
if(this.isCurrentPosition(entity, entityState)){
delete this.interpolateObjectsPositions[i];
return false;
}
let x = sc.roundToPrecision(Phaser.Math.Linear(entity.x, entityState.x, this.interpolationSpeed), 0);
let y = sc.roundToPrecision(Phaser.Math.Linear(entity.y, entityState.y, this.interpolationSpeed), 0);
let bodyData = {x, y, inState: entityState.inState, mov: entityState.mov, dir: entityState.dir};
objectsPlugin.updateObjectsAnimations(i, bodyData, this);
if(!entityState.mov){
delete this.interpolateObjectsPositions[i];
}
return true;
}
/**
* @param {Object} entity
* @param {Object} entityState
* @returns {boolean}
*/
isCurrentPosition(entity, entityState)
{
if(!entity || !entityState){
Logger.warning('None entity found to compare current entity position.');
return false;
}
return Math.round(entity.x) === Math.round(entityState.x) && Math.round(entity.y) === Math.round(entityState.y);
}
async changeScene()
{
this.minimap?.destroyMap();
this.eventsManager.emitSync('reldens.changeSceneDestroyPrevious', this);
this.objectsAnimations = {};
this.objectsAnimationsData = false;
if(this.useTsAnimation){
for(let tilesetAnimation of this.tilesetAnimations){
tilesetAnimation.destroy();
}
}
}
/**
* @return {boolean}
*/
registerLayers()
{
if(0 === this.map.layers.length){
return false;
}
let idx = 0;
// @TODO - BETA - Use single get(client/map).
let depthBelowPlayer = this.configManager.get('client/map/layersDepth/belowPlayer');
let depthForChangePoints = this.configManager.get('client/map/layersDepth/changePoints');
for(let layer of this.map.layers){
this.layers[idx] = this.map.createLayer(layer.name, this.generatedTilesets);
if(!this.layers[idx]){
Logger.critical('Map layer could not be created.', layer.name, this.key);
continue;
}
if(-1 !== layer.name.indexOf('below-player')){
this.layers[idx].setDepth(depthBelowPlayer);
}
if(-1 !== layer.name.indexOf('over-player')){
// we need to set the depth higher than everything else (multiply to get the highest value):
this.layers[idx].setDepth(idx * this.map.height * this.map.tileHeight);
}
if(-1 !== layer.name.indexOf('change-points')){
this.layers[idx].setDepth(depthForChangePoints);
}
idx++;
}
return true;
}
/**
* @param {Phaser.Input.Pointer} pointer
* @returns {Object|boolean}
*/
appendRowAndColumn(pointer)
{
let worldToTileXY = this.map.worldToTileXY(pointer.worldX, pointer.worldY);
let playerToTileXY = this.map.worldToTileXY(this.player.state.x, this.player.state.y);
if(!worldToTileXY || !playerToTileXY){
Logger.error('Move to pointer error.');
return false;
}
pointer.worldColumn = worldToTileXY.x;
pointer.worldRow = worldToTileXY.y;
pointer.playerOriginCol = playerToTileXY.x;
pointer.playerOriginRow = playerToTileXY.y;
return pointer;
}
/**
* @param {number} x
* @param {number} y
* @param {string} message
* @param {string} color
* @param {string} font
* @param {number} [fontSize]
* @param {number} [duration]
* @param {number} [top]
* @param {string} [stroke]
* @param {number} [strokeThickness]
* @param {string} [shadowColor]
*/
createFloatingText(
x,
y,
message,
color,
font,
fontSize = 14,
duration = 600,
top = 50,
stroke = '#000000',
strokeThickness = 4,
shadowColor = 'rgba(0,0,0,0.7)'
){
let damageSprite = this.add.text(x, y, message, {fontFamily: font, fontSize: fontSize+'px'});
damageSprite.style.setColor(color);
damageSprite.style.setAlign('center');
damageSprite.style.setStroke(stroke, strokeThickness);
damageSprite.style.setShadow(5, 5, shadowColor, 5);
damageSprite.setDepth(200000);
this.add.tween({
targets: damageSprite, duration, ease: 'Exponential.In', y: y - top,
onComplete: () => {
damageSprite.destroy();
}
});
}
/**
* @param {Phaser.Input.Pointer} pointer
*/
updatePointerObject(pointer)
{
if(!this.configManager.get('client/ui/pointer/show')){
return;
}
if(this.arrowSprite){
this.arrowSprite.destroy();
}
let topOffSet = this.configManager.get('client/ui/pointer/topOffSet', 16);
this.arrowSprite = this.physics.add.sprite(pointer.worldX, pointer.worldY - topOffSet, GameConst.ARROW_DOWN);
this.arrowSprite.setDepth(500000);
this.arrowSprite.anims.play(GameConst.ARROW_DOWN, true).on('animationcomplete', () => {
this.arrowSprite.destroy();
});
}
/**
* @param {string} key
* @returns {Object|false}
*/
getAnimationByKey(key)
{
if(!this.anims || !this.anims?.anims || !this.anims?.anims?.entries){
Logger.error('Animations not loaded.', this.anims);
return false;
}
return sc.get(this.anims.anims.entries, key, false);
}
/**
* @param {string} objKey
* @param {Object} extraData
* @param {Object} currentPlayer
* @returns {Object|false}
*/
getObjectFromExtraData(objKey, extraData, currentPlayer)
{
// @TODO - BETA - Replace with constants.
// objKey = t > target
// objKey = o > owner
let returnObj = false;
let dataTargetType = objKey+'T'; // tT - oT === DATA_TARGET_TYPE - DATA_OWNER_TYPE
let dataTargetKey = objKey+'K'; // tK - oK === DATA_TARGET_KEY - DATA_OWNER_KEY
let isTargetPlayer = extraData[dataTargetType] === ActionsConst.DATA_TYPE_VALUE_PLAYER;
if(!isTargetPlayer && sc.hasOwn(this.objectsAnimations, extraData[dataTargetKey])){
returnObj = this.objectsAnimations[extraData[dataTargetKey]];
}
if(isTargetPlayer && sc.hasOwn(currentPlayer.players, extraData[dataTargetKey])){
returnObj = currentPlayer.players[extraData[dataTargetKey]];
}
return returnObj;
}
/**
* @param {Array<number>} keys
* @param {HTMLElement} element
* @param {string} eventName
* @param {string} action
*/
loopKeysAddListenerToElement(keys, element, eventName, action)
{
element.addEventListener(eventName, () => {
for(let keyCode of keys){
this.input.keyboard[action](keyCode);
}
});
}
}
module.exports.SceneDynamic = SceneDynamic;