From 6b8a220f39ebd5ef4f2d6fb3f62133e5c4561c87 Mon Sep 17 00:00:00 2001 From: Thayol Date: Mon, 20 Jan 2025 11:17:35 +0900 Subject: [PATCH] fix vision logic and game tick timer --- .gitignore | 4 ++++ public/client.js | 4 +++- src/entity.js | 36 ++++++++++++++++++++---------------- src/game.js | 25 ++++++++++++++++++------- src/level.js | 23 ++++++++++++++++++----- src/projectile.js | 21 +++++++++++++++++++-- src/terrain.js | 8 +++++++- 7 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index ea01008..0938c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,8 @@ dist .yarn/install-state.gz .pnp.* +# Files generated by the app public/temp + +# Flamegraphs +*.0X diff --git a/public/client.js b/public/client.js index d28d824..f0fcafb 100644 --- a/public/client.js +++ b/public/client.js @@ -23,6 +23,7 @@ camera.layers.enable(2) const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc }) 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 opacity = 0.3 const teamMaterials = { @@ -36,6 +37,7 @@ const teamMaterials = { range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }), } +// TODO: draw lines of path for minimap camera const minimapCameraZ = 10 const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10) const minimapRenderer = new THREE.WebGLRenderer() @@ -419,7 +421,7 @@ function connectWebSocket() { const shape = new THREE.Shape() 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)) - 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.id = t.id scene.add(terrain) diff --git a/src/entity.js b/src/entity.js index e595eb9..8e03dd7 100644 --- a/src/entity.js +++ b/src/entity.js @@ -15,8 +15,11 @@ export default class Entity { bbox = new Float32Array(4) buffs = [] casting = null + collision = true cooldowns = {} dead = false + ghostable = true + ghosting = false health = null height = 40 maxHealth = 1 @@ -41,6 +44,10 @@ export default class Entity { #spawnPosition = new Vector2() + static bbox(x, y, radius) { + return new Float32Array([y + radius, x + radius, y - radius, x - radius]) + } + static collider(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) } + // 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) { let damage = amount 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) { const entityBuff = this.buffs.find((it) => it.id == id) if (entityBuff == null) { return } @@ -402,7 +410,7 @@ export default class Entity { isInLineOfSight(destination, position = this.position) { 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 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 } 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)) 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) return !colliders.some((it) => SATX.collideObject(collider, it)) } @@ -424,7 +436,7 @@ export default class Entity { obstaclesInStraightPath(destination, position = this.position) { 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 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 [] } 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) } - 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) { const collidables = this.futureCollidables(futurePosition) if (collidables.length < 1) { diff --git a/src/game.js b/src/game.js index da135cc..284f78e 100644 --- a/src/game.js +++ b/src/game.js @@ -7,6 +7,8 @@ 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}) averageTick = 0 @@ -16,6 +18,7 @@ export default class Game { height = 0 projectiles = [] secondToSlowestTick = 0 + startTimestamp = 0 terrains = [] tickRate = 30 width = 0 @@ -100,7 +103,9 @@ export default class Game { start() { 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() { @@ -108,6 +113,7 @@ export default class Game { clearInterval(this.gameLoopIntervalId) this.gameLoopIntervalId = null + console.log(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`) } subscription(websocket, id) { @@ -123,8 +129,9 @@ export default class Game { } update() { - this.entities.forEach((e) => e.update()) - this.projectiles.forEach((p) => p.update()) + const callUpdate = function callUpdate(object) { object.update() } + this.entities.forEach(callUpdate) + this.projectiles.forEach(callUpdate) if (this.#logic != null) { this.#logic() } @@ -151,6 +158,7 @@ export default class Game { 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) { const visionSources = this.visionSources(team) const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision()).flat()) @@ -180,9 +188,11 @@ export default class Game { this.update() const after = performance.now() const taken = (after - before) - const prevBehind = this.#behindMs - this.#behindMs = Math.max(0, this.#behindMs + taken - tickBudget) - this.#nextTickAt = start + tickBudget - prevBehind + + const useAbsoluteBehind = true + 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 if (this.#currentTiming++ > this.#timings.length) { @@ -190,7 +200,8 @@ export default class Game { } 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}`) } } } diff --git a/src/level.js b/src/level.js index c37fae4..63d2c81 100644 --- a/src/level.js +++ b/src/level.js @@ -11,22 +11,35 @@ export class Dungeon { game.height = 3000 const playerSpawn = new Vector2(game.width / 2, game.height / 2) - game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, team: Team.blue }))) - game.entities.at(0).moveAction(playerSpawn.clone().add(new Vector2(0, -200))) + game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: playerSpawn, position: new Vector2(playerSpawn.x - 1300, playerSpawn.y - 500), team: Team.blue }))) const dummyLogic = function dummyLogic() { - if (game.currentTick % (3 * game.tickRate) == 0) { - this.castAction('q', playerSpawn) + const entity = this + 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 } 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.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([ new Vector2(3 * (game.width / 10), 2 * (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), 2 * (game.height / 5)), - ])) + ], false)) game.start() } diff --git a/src/projectile.js b/src/projectile.js index 426d2a2..21eeeeb 100644 --- a/src/projectile.js +++ b/src/projectile.js @@ -64,8 +64,24 @@ export default class Projectile { if (entities == null) { return } 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) { @@ -87,8 +103,9 @@ export default class Projectile { if (projectiles == null) { return } 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() { diff --git a/src/terrain.js b/src/terrain.js index 2f22de7..1e7c2de 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -7,6 +7,8 @@ export default class Terrain { static #nextUniqueId = 0 bbox = new Float32Array(4) + collision = true + ghostable = false position = new Vector2() relativeVertices = [] @@ -14,12 +16,16 @@ export default class Terrain { #vertices = [] #unadjustedWaypoints = [] - constructor(vertices) { + constructor(vertices, collision = null) { this.#vertices = vertices.map((v) => new Vector2(v.x, v.y)) if (ShapeUtils.isClockWise(this.#vertices)) { this.#vertices.reverse() } + if (collision != null) { + this.collision = collision + } + this.#calculateColliders() this.#calculatePosition() this.#calculateRelativeVertices()