import { EventEmitter } from 'node:events' import { Vector2 } from 'three' import Ability from './ability.js' import Buff from './buff.js' import Entity from './entity.js' import Projectile from './projectile.js' import Terrain from './terrain.js' export default class Game { id = crypto.randomUUID() abilities = Object.values({...Ability}) buffs = Object.values({...Buff}) currentTick = 0 entities = [] height = 0 projectiles = [] terrains = [] tickRate = 30 width = 0 #gameLoopIntervalId = null #logic = null #nextTickAt = 0 #startTimestamp = 0 #subscriptions = new Map() #tickBudget = 1000 / this.tickRate get logic() { return this.#logic } get tickBudget() { return this.#tickBudget } get subscriptions() { return this.#subscriptions } set logic(value) { this.#logic = value } action(id, options) { const entity = this.entities.find((it) => it.id == id) if (entity == null) { console.error({ error: 'Invalid ID' }) return } if (options.action == 'attack') { entity.attackAction(new Vector2(options.x, options.y)) } if (options.action == 'cast') { entity.castAction(options.slot, new Vector2(options.x, options.y)) } if (options.action == 'halt') { entity.haltAction() } if (options.action == 'stop') { entity.stopAction() } if (options.action == 'move') { entity.moveAction(new Vector2(options.x, options.y)) } } addTerrain(terrain) { this.terrains.push(terrain) } despawn(object) { if (object instanceof Entity) { this.despawnEntity(object) } else if (object instanceof Terrain) { this.removeTerrain(object) } else if (object instanceof Projectile) { this.despawnProjectile(object) } else { console.error({ error: { reason: 'Can\'t despawn object', object } }) } } despawnEntity(entity) { this.entities = this.entities.filter((e) => e.id != entity.id) entity.game = null } despawnProjectile(projectile) { this.projectiles = this.projectiles.filter((p) => p.id != projectile.id) projectile.game = null } joinReport() { return { id: this.id, height: this.height, width: this.width, currentTick: this.currentTick, abilities: this.abilities, buffs: this.buffs, terrains: this.terrains, tickRate: this.tickRate, } } removeTerrain(terrain) { this.terrains = this.terrains.filter((t) => t.id != terrain.id) } secToTick(sec) { return Math.floor(this.tickRate * sec) } spawn(object) { if (object instanceof Entity) { this.spawnEntity(object) } else if (object instanceof Terrain) { this.addTerrain(object) } else if (object instanceof Projectile) { this.spawnProjectile(object) } else { console.error({ error: { reason: 'Can\'t spawn object', object } }) } } spawnEntity(entity) { this.entities.push(entity) entity.game = this } spawnProjectile(projectile) { this.projectiles.push(projectile) projectile.game = this } start() { if (this.#gameLoopIntervalId != null) { return } this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget) console.info(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${this.currentTick}.`) this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5) } stop() { if (this.#gameLoopIntervalId == null) { return } clearInterval(this.#gameLoopIntervalId) this.#gameLoopIntervalId = null console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`) } subscription(websocket, id) { return function builtSubscription() { const game = this const entity = game.entities.find((it) => it.id == id) if (entity == null) { return } const team = entity.team const state = game.visionByTeam(team) state.currentTick = game.currentTick websocket.send(JSON.stringify(state)) } } update() { for (const subscription of this.#subscriptions.values()) { subscription() } const callUpdate = function callUpdate(object) { object.update() } this.entities.forEach(callUpdate) this.projectiles.forEach(callUpdate) if (this.#logic != null) { this.#logic() } this.currentTick++ } visibleEntities(team) { const visionSources = this.visionSources(team) return Array.from(new Set(visionSources.map((it) => it.entitiesInVision).flat())) } visibleProjectiles(team) { const visionSources = this.visionSources(team) return Array.from(new Set(visionSources.map((it) => it.projectilesInVision).flat())) } visionSources(team) { const entityVisionSources = this.entities.filter((it) => it.team == team) const projectileVisionSources = this.projectiles.filter((it) => it.visionRange > 0 && (it.team == null || it.team == team)) return entityVisionSources.concat(projectileVisionSources) } visionByTeam(team) { const visionSources = this.visionSources(team) const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat()) const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat()) return { entities: this.entities.filter((it) => visibleEntities.has(it.id)), projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)), } } #gameLoop() { if (this.#nextTickAt != null) { const tickBudget = this.#tickBudget const nextTickAt = this.#nextTickAt this.#nextTickAt = null let start = 0 while (start < nextTickAt) { start = performance.now() } const before = performance.now() this.update() const after = performance.now() const taken = (after - before) const useAbsoluteBehind = true const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget)) this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind) if (after - before > tickBudget) { const behindNotice = absoluteBehind > 0.1 ? `(Was already behind ${absoluteBehind.toFixed(1)} ms)` : `` console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. ${behindNotice}`) } } } #gameLoopCall() { this.#gameLoop() } }