fix vision logic and game tick timer

This commit is contained in:
2025-01-20 11:17:35 +09:00
parent bf38f69071
commit 6b8a220f39
7 changed files with 89 additions and 32 deletions
+4
View File
@@ -129,4 +129,8 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# Files generated by the app
public/temp public/temp
# Flamegraphs
*.0X
+3 -1
View File
@@ -23,6 +23,7 @@ camera.layers.enable(2)
const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc }) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc })
const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 }) const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 })
const passableTerrainMaterial = new THREE.MeshToonMaterial({ color: 0x228822, transparent: true, opacity: 0.65 })
// const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 }) // const bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 })
const opacity = 0.3 const opacity = 0.3
const teamMaterials = { const teamMaterials = {
@@ -36,6 +37,7 @@ const teamMaterials = {
range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }), range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }),
} }
// TODO: draw lines of path for minimap camera
const minimapCameraZ = 10 const minimapCameraZ = 10
const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10) const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10)
const minimapRenderer = new THREE.WebGLRenderer() const minimapRenderer = new THREE.WebGLRenderer()
@@ -419,7 +421,7 @@ function connectWebSocket() {
const shape = new THREE.Shape() const shape = new THREE.Shape()
shape.moveTo(vertices.at(0).x / 100, vertices.at(0).y / 100) shape.moveTo(vertices.at(0).x / 100, vertices.at(0).y / 100)
vertices.slice(1).forEach((v) => shape.lineTo(v.x / 100, v.y / 100)) vertices.slice(1).forEach((v) => shape.lineTo(v.x / 100, v.y / 100))
terrain = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { bevelEnabled: false, depth: 0.5 }), terrainMaterial) terrain = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { bevelEnabled: false, depth: t.collision ? 0.5 : 0.35 }), t.collision ? terrainMaterial : passableTerrainMaterial)
terrain.userData.type = 'terrain' terrain.userData.type = 'terrain'
terrain.userData.id = t.id terrain.userData.id = t.id
scene.add(terrain) scene.add(terrain)
+20 -16
View File
@@ -15,8 +15,11 @@ export default class Entity {
bbox = new Float32Array(4) bbox = new Float32Array(4)
buffs = [] buffs = []
casting = null casting = null
collision = true
cooldowns = {} cooldowns = {}
dead = false dead = false
ghostable = true
ghosting = false
health = null health = null
height = 40 height = 40
maxHealth = 1 maxHealth = 1
@@ -41,6 +44,10 @@ export default class Entity {
#spawnPosition = new Vector2() #spawnPosition = new Vector2()
static bbox(x, y, radius) {
return new Float32Array([y + radius, x + radius, y - radius, x - radius])
}
static collider(x, y, radius) { static collider(x, y, radius) {
return new SAT.Circle(new SAT.Vector(x, y), radius) return new SAT.Circle(new SAT.Vector(x, y), radius)
} }
@@ -288,6 +295,12 @@ export default class Entity {
.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null) .reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
} }
// TODO: collision and ghosting checks are duplicated
customBboxCollidables(bbox) {
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
return entitiesAndTerrains.filter((it) => it.collision && !(this.ghosting && it.ghostable) && SATX.bboxCheck(bbox, it.bbox))
}
damage(amount, source = null) { damage(amount, source = null) {
let damage = amount let damage = amount
if (this.hasBuff(Buff.exposed.id)) { if (this.hasBuff(Buff.exposed.id)) {
@@ -328,11 +341,6 @@ export default class Entity {
])) ]))
} }
customBboxCollidables(bbox) {
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
return entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
}
getBuff(id) { getBuff(id) {
const entityBuff = this.buffs.find((it) => it.id == id) const entityBuff = this.buffs.find((it) => it.id == id)
if (entityBuff == null) { return } if (entityBuff == null) { return }
@@ -402,7 +410,7 @@ export default class Entity {
isInLineOfSight(destination, position = this.position) { isInLineOfSight(destination, position = this.position) {
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => it.collision && !(this.ghosting && it.ghostable) && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true } if (bboxCheckedObstacles.length < 1) { return true }
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat()
@@ -416,7 +424,11 @@ export default class Entity {
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true } if (bboxCheckedObstacles.length < 1) { return true }
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat() const posCollider = Entity.collider(this.position.x, this.position.y, 1) // TODO: magic number for radius
const posBbox = Entity.bbox(this.position.x, this.position.y, 1) // TODO: magic number for radius
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !(SATX.bboxCheck(posBbox, it.bbox) && it.colliders().some((c) => SATX.collideObject(posCollider, c))))
const colliders = unpassableTerrain.map((it) => it.colliders()).flat()
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0) const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
return !colliders.some((it) => SATX.collideObject(collider, it)) return !colliders.some((it) => SATX.collideObject(collider, it))
} }
@@ -424,7 +436,7 @@ export default class Entity {
obstaclesInStraightPath(destination, position = this.position) { obstaclesInStraightPath(destination, position = this.position) {
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius) const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? []) const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => SATX.bboxCheck(bbox, it.bbox)) const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => it.collision && !(this.ghosting && it.ghostable) && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return [] } if (bboxCheckedObstacles.length < 1) { return [] }
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius) const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
@@ -481,14 +493,6 @@ export default class Entity {
return this.game?.visibleEntities(this.team) return this.game?.visibleEntities(this.team)
} }
waypoints() {
const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id)
const terrainColliders = (this.game?.terrains ?? [])
const unadjustedWaypoints = entityColliders.concat(terrainColliders).map((e) => e.unadjustedWaypoints).flat()
return unadjustedWaypoints.map(([waypoint, direction]) => this.adjustWaypoint(waypoint, direction)) ?? []
}
willCollide(futurePosition) { willCollide(futurePosition) {
const collidables = this.futureCollidables(futurePosition) const collidables = this.futureCollidables(futurePosition)
if (collidables.length < 1) { if (collidables.length < 1) {
+18 -7
View File
@@ -7,6 +7,8 @@ import Projectile from './projectile.js'
import Terrain from './terrain.js' import Terrain from './terrain.js'
export default class Game { export default class Game {
id = crypto.randomUUID()
abilities = Object.values({...Ability}) abilities = Object.values({...Ability})
buffs = Object.values({...Buff}) buffs = Object.values({...Buff})
averageTick = 0 averageTick = 0
@@ -16,6 +18,7 @@ export default class Game {
height = 0 height = 0
projectiles = [] projectiles = []
secondToSlowestTick = 0 secondToSlowestTick = 0
startTimestamp = 0
terrains = [] terrains = []
tickRate = 30 tickRate = 30
width = 0 width = 0
@@ -100,7 +103,9 @@ export default class Game {
start() { start() {
if (this.gameLoopIntervalId != null) { return } if (this.gameLoopIntervalId != null) { return }
this.gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), 1) this.startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
console.log(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${this.currentTick}.`)
this.gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
} }
stop() { stop() {
@@ -108,6 +113,7 @@ export default class Game {
clearInterval(this.gameLoopIntervalId) clearInterval(this.gameLoopIntervalId)
this.gameLoopIntervalId = null this.gameLoopIntervalId = null
console.log(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
} }
subscription(websocket, id) { subscription(websocket, id) {
@@ -123,8 +129,9 @@ export default class Game {
} }
update() { update() {
this.entities.forEach((e) => e.update()) const callUpdate = function callUpdate(object) { object.update() }
this.projectiles.forEach((p) => p.update()) this.entities.forEach(callUpdate)
this.projectiles.forEach(callUpdate)
if (this.#logic != null) { if (this.#logic != null) {
this.#logic() this.#logic()
} }
@@ -151,6 +158,7 @@ export default class Game {
return entityVisionSources.concat(projectileVisionSources) return entityVisionSources.concat(projectileVisionSources)
} }
// TODO: castingVision should not reveal casting in non-lanes (= only spawn castingVision if slightly outside regular vision [or obstructeed])
visionByTeam(team) { visionByTeam(team) {
const visionSources = this.visionSources(team) const visionSources = this.visionSources(team)
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision()).flat()) const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision()).flat())
@@ -180,9 +188,11 @@ export default class Game {
this.update() this.update()
const after = performance.now() const after = performance.now()
const taken = (after - before) const taken = (after - before)
const prevBehind = this.#behindMs
this.#behindMs = Math.max(0, this.#behindMs + taken - tickBudget) const useAbsoluteBehind = true
this.#nextTickAt = start + tickBudget - prevBehind const absoluteBehind = Math.max(0, (start - this.startTimestamp) - ((this.currentTick) * tickBudget))
this.#behindMs = absoluteBehind
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
this.#timings[this.#currentTiming] = taken this.#timings[this.#currentTiming] = taken
if (this.#currentTiming++ > this.#timings.length) { if (this.#currentTiming++ > this.#timings.length) {
@@ -190,7 +200,8 @@ export default class Game {
} }
if (after - before > tickBudget) { if (after - before > tickBudget) {
console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. (Behind ${this.#behindMs.toFixed(1)} ms)`) 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}`)
} }
} }
} }
+18 -5
View File
@@ -11,22 +11,35 @@ export class Dungeon {
game.height = 3000 game.height = 3000
const playerSpawn = new Vector2(game.width / 2, game.height / 2) const playerSpawn = new Vector2(game.width / 2, game.height / 2)
game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, team: Team.blue }))) game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, position: new Vector2(playerSpawn.x - 1300, playerSpawn.y - 500), team: Team.blue })))
game.entities.at(0).moveAction(playerSpawn.clone().add(new Vector2(0, -200)))
const dummyLogic = function dummyLogic() { const dummyLogic = function dummyLogic() {
if (game.currentTick % (3 * game.tickRate) == 0) { const entity = this
this.castAction('q', playerSpawn) if (entity.position.x > 1250) {
entity.moveAction(new Vector2(500, entity.position.y))
}
else if (entity.position.x < 550 || entity.destination == null) {
entity.moveAction(new Vector2(1300, entity.position.y))
}
if (game.currentTick > 0 && game.currentTick % (6 * game.tickRate) == 0) {
entity.castAction('q', playerSpawn)
} }
} }
const dummy = { radius: 100, visualRadius: 50, abilities: { q: Ability.straightShot.id }, logic: dummyLogic } const dummy = { radius: 100, visualRadius: 50, abilities: { q: Ability.straightShot.id }, logic: dummyLogic }
game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 1 * (game.height / 4)) })) game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 1 * (game.height / 4)) }))
game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 3 * (game.height / 4)) })) game.spawnEntity(new Entity({ ...dummy, position: new Vector2(1 * (game.width / 5), 3 * (game.height / 4)) }))
game.addTerrain(new Terrain([
new Vector2(3.5 * (game.width / 10), 1.6 * (game.height / 5)),
new Vector2(3.5 * (game.width / 10), 1.4 * (game.height / 5)),
new Vector2(4 * (game.width / 10), 1.4 * (game.height / 5)),
new Vector2(4 * (game.width / 10), 1.6 * (game.height / 5)),
]))
game.addTerrain(new Terrain([ game.addTerrain(new Terrain([
new Vector2(3 * (game.width / 10), 2 * (game.height / 5)), new Vector2(3 * (game.width / 10), 2 * (game.height / 5)),
new Vector2(3 * (game.width / 10), 1 * (game.height / 5)), new Vector2(3 * (game.width / 10), 1 * (game.height / 5)),
new Vector2(4 * (game.width / 10), 1 * (game.height / 5)), new Vector2(4 * (game.width / 10), 1 * (game.height / 5)),
new Vector2(4 * (game.width / 10), 2 * (game.height / 5)), new Vector2(4 * (game.width / 10), 2 * (game.height / 5)),
])) ], false))
game.start() game.start()
} }
+19 -2
View File
@@ -64,8 +64,24 @@ export default class Projectile {
if (entities == null) { return } if (entities == null) { return }
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius) const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
return entitiesInVisionRange.concat([this]).map((it) => it.id) return entitiesInLineOfSight.concat([this]).map((it) => it.id)
}
isInLineOfVision(destination) {
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
const terrains = this.game?.terrains ?? []
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true }
const posCollider = Entity.collider(this.position.x, this.position.y, 1) // TODO: magic number for radius
const posBbox = Entity.bbox(this.position.x, this.position.y, 1) // TODO: magic number for radius
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !(SATX.bboxCheck(posBbox, it.bbox) && it.colliders().some((c) => SATX.collideObject(posCollider, c))))
const colliders = unpassableTerrain.map((it) => it.colliders()).flat()
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
return !colliders.some((it) => SATX.collideObject(collider, it))
} }
setPosition(vector) { setPosition(vector) {
@@ -87,8 +103,9 @@ export default class Projectile {
if (projectiles == null) { return } if (projectiles == null) { return }
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius) const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
return projectilesInVisionRange.map((it) => it.id) return projectilesInLineOfSight.concat([this]).map((it) => it.id)
} }
#calculateBbox() { #calculateBbox() {
+7 -1
View File
@@ -7,6 +7,8 @@ export default class Terrain {
static #nextUniqueId = 0 static #nextUniqueId = 0
bbox = new Float32Array(4) bbox = new Float32Array(4)
collision = true
ghostable = false
position = new Vector2() position = new Vector2()
relativeVertices = [] relativeVertices = []
@@ -14,12 +16,16 @@ export default class Terrain {
#vertices = [] #vertices = []
#unadjustedWaypoints = [] #unadjustedWaypoints = []
constructor(vertices) { constructor(vertices, collision = null) {
this.#vertices = vertices.map((v) => new Vector2(v.x, v.y)) this.#vertices = vertices.map((v) => new Vector2(v.x, v.y))
if (ShapeUtils.isClockWise(this.#vertices)) { if (ShapeUtils.isClockWise(this.#vertices)) {
this.#vertices.reverse() this.#vertices.reverse()
} }
if (collision != null) {
this.collision = collision
}
this.#calculateColliders() this.#calculateColliders()
this.#calculatePosition() this.#calculatePosition()
this.#calculateRelativeVertices() this.#calculateRelativeVertices()