209 lines
6.2 KiB
JavaScript
209 lines
6.2 KiB
JavaScript
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()
|
|
}
|
|
}
|