This repository has been archived on 2026-05-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
instructions-clear/src/game.js
T
2025-01-22 22:52:08 +09:00

209 lines
6.3 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(), delay }
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) // TODO: entity with lower ID has unfair collision advantage (regular loop + until it fully loops around with an offset?)
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()
}
}