diff --git a/src/ability.js b/src/ability.js index 34b9ff6..9a16d61 100644 --- a/src/ability.js +++ b/src/ability.js @@ -24,6 +24,7 @@ export default class Ability { Object.entries(options).forEach(([key, value]) => this[key] = value) } + // TODO: skill seemingly going right through minions without a registered hit static straightShot = new Ability({ id: 'straight_shot', name: 'Straight Shot', diff --git a/src/entity.js b/src/entity.js index 9b1af83..1715efe 100644 --- a/src/entity.js +++ b/src/entity.js @@ -7,11 +7,6 @@ import Team from './team.js' export default class Entity { id = crypto.randomUUID() - speed = 400 - radius = 0 - health = 1 - maxHealth = 1 - height = 40 abilities = [ Ability.rangedAttack, Ability.straightShot, @@ -19,18 +14,24 @@ export default class Entity { Ability.blink, ] casting = null + cooldowns = {} + dead = false + health = null + height = 40 + maxHealth = 1 + radius = 0 + speed = 400 team = Team.neutral - cooldowns = {} - - #attack = false + #attacking = false #dest = null #game = null #logic = null - #move = false + #moving = false #path = [] - #position = new Vector2() + #position = null #scheduledPathfinding = null + #spawnPosition = new Vector2() static collider(x, y, radius) { return new SAT.Circle(new SAT.Vector(x, y), radius) @@ -38,6 +39,12 @@ export default class Entity { constructor(options = {}) { Object.entries(options).forEach(([key, value]) => this[key] = value) + if (this.#position == null) { + this.#position = this.#spawnPosition.clone() + } + if (this.health == null) { + this.health = this.maxHealth + } } get destination() { return this.#dest } @@ -45,6 +52,7 @@ export default class Entity { get game() { return this.#game } get position() { return this.#position } get scheduledPathfinding() { return this.#scheduledPathfinding } + get spawnPosition() { return this.#spawnPosition } get x() { return this.position.x } get y() { return this.position.y } @@ -53,6 +61,7 @@ export default class Entity { set game(value) { this.#game = value } set position(value) { this.#position = value } set scheduledPathfinding(value) { this.#scheduledPathfinding = value } + set spawnPosition(value) { this.#spawnPosition = value } set x(value) { this.position.x = value } set y(value) { this.position.y = value } @@ -101,7 +110,7 @@ export default class Entity { } if (halt) { - this.#move = false + this.#moving = false } const cursor = new Vector2(x, y) @@ -118,42 +127,26 @@ export default class Entity { } haltAction() { - this.#move = false + this.#moving = false } moveAction(x, y, attack = false) { - if (this.casting != null && (!this.#attack || this.casting.ability.id != this.abilities[0].id)) { + if (this.casting != null && (!this.#attacking || this.casting.ability.id != this.abilities[0].id)) { this.casting = null } - this.#attack = attack - this.#move = true + this.#attacking = attack + this.#moving = true this.#dest = SATX.fixCollisions(new Vector2(x, y), this.collidables(), this.radius, this.game?.width, this.game?.height) } stopAction() { this.casting = null - this.#move = true - this.#attack = false + this.#moving = true + this.#attacking = false } - cast() { - if (this.casting == null) { - return false - } - - const castTime = this.game?.secToTick(this.casting.ability.castTime) ?? 0 - const castStart = this.casting.timestamp - const timestamp = this.game?.currentTick ?? 0 - if (castStart + castTime > timestamp) { - return false - } - - this.casting.ability.effect(this, this.casting.cursor) - - this.casting = null - return true - } + // --- Actions above --- // collidables() { const entityColliders = (this.game?.entities ?? []).filter((e) => e.id != this.id).map((e) => e.collider) @@ -186,6 +179,12 @@ export default class Entity { return SATX.collideObjects(this.collider, colliders) } + respawn() { + this.#position = this.#spawnPosition.clone() + this.health = this.maxHealth + this.dead = false + } + state() { return { ...this, @@ -201,21 +200,89 @@ export default class Entity { this.fixPosition() } - move(distanceTraveled = 0) { - if (this.casting != null) { return false } - - if (this.#attack && this.game?.entities.some((e) => e.team != this.team && e.position.clone().sub(this.position).length() < this.abilities[0].range)) { - const cooldown = this.game?.secToTick(this.abilities[0].cooldown) ?? 0 - const lastCast = this.cooldowns[this.abilities[0].id] - const timestamp = this.game?.currentTick ?? 0 - if (lastCast != null && lastCast + cooldown > timestamp) { return false } - - const target = this.#dest ?? this.position - this.castAction(0, target.x, target.y, false) - return true + update() { + if (this.dead) { + // TODO: do something while the entity is dead + } + else { + this.#cast() + this.#checkHealth() + this.#move() + this.fixPosition() } - if (!this.#move || this.#dest == null) { return false } + if (this.#logic != null) { + this.#logic() + } + } + + 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]) => { + return SATX.clamp( + waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), + this.game?.width, + this.game?.height, + this.radius, + ) + }) ?? [] + } + + #cast() { + if (this.casting == null) { + return false + } + + const castTime = this.game?.secToTick(this.casting.ability.castTime) ?? 0 + const castStart = this.casting.timestamp + const timestamp = this.game?.currentTick ?? 0 + if (castStart + castTime > timestamp) { + return false + } + + this.casting.ability.effect(this, this.casting.cursor) + + this.casting = null + return true + } + + #checkHealth() { + if (this.health <= 0) { + this.dead = true + } + } + + // TODO: make scheduled pathfinding continue until collision to make the entity more "alive" + #move(distanceTraveled = 0) { + if (this.casting != null) { return false } + + if (this.#attacking) { + const attackCursor = this.#dest ?? this.position + const targets = this.game?.entities.filter((e) => e.team != this.team && e.position.clone().sub(attackCursor).length() < this.abilities[0].range) + const target = targets.reduce((prev, e) => { + if (prev == null || e.position.clone().sub(attackCursor).length() > prev.position.clone().sub(attackCursor).length()) { + return e + } + else { + return prev + } + }, null) + + if (target != null && target.position.clone().sub(this.position).length() < this.abilities[0].range) { + const cooldown = this.game?.secToTick(this.abilities[0].cooldown) ?? 0 + const lastCast = this.cooldowns[this.abilities[0].id] + const timestamp = this.game?.currentTick ?? 0 + if (lastCast != null && lastCast + cooldown > timestamp) { return false } + + this.castAction(0, attackCursor.x, attackCursor.y, false) + return true + } + } + + if (!this.#moving || this.#dest == null) { return false } const collidables = this.collidables() const fixedDest = SATX.clamp(SATX.fixCollisions(this.#dest, collidables, this.radius), this.game?.width, this.game?.height, this.radius) @@ -262,37 +329,13 @@ export default class Entity { if (this.position.equals(destination)) { this.#path = this.#path.slice(1) if (this.#path.length > 0) { - this.move(distance) + this.#move(distance) } else { this.#dest = null - this.#move = false + this.#moving = false } } } } - - update() { - this.cast() - this.move() - this.fixPosition() - if (this.#logic != null) { - this.#logic() - } - } - - 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]) => { - return SATX.clamp( - waypoint.clone().add(direction.clone().multiplyScalar(this.radius)), - this.game?.width, - this.game?.height, - this.radius, - ) - }) ?? [] - } } diff --git a/src/game.js b/src/game.js index f83d52b..3fbc641 100644 --- a/src/game.js +++ b/src/game.js @@ -8,8 +8,7 @@ export default class Game { currentTick = 0 width = 2000 height = 2000 - running = false - nextTick = 0 + nextTickAt = 0 #logic = null #entities = [] @@ -88,15 +87,15 @@ export default class Game { gameLoop() { const tickBudget = this.#tickBudget - if (this.nextTick != null) { - const nextTick = this.nextTick - this.nextTick = null + if (this.nextTickAt != null) { + const nextTickAt = this.nextTickAt + this.nextTickAt = null let start = performance.now() - while (start < nextTick) { start = performance.now() } + while (start < nextTickAt) { start = performance.now() } this.update() - this.nextTick = start + tickBudget + this.nextTickAt = start + tickBudget } } diff --git a/src/index.js b/src/index.js index 6a7134e..99fe2d2 100644 --- a/src/index.js +++ b/src/index.js @@ -58,12 +58,20 @@ app.ws('/ws', async (req, res) => { }) function laneScenario() { + // TODO: proper respawn + const playerLogic = function playerLogic() { + const entity = this + if (entity.dead) { + entity.respawn() + } + } + const entity1 = new Entity({ id: '1', - health: 100, height: 80, + logic: playerLogic, maxHealth: 100, - position: new Vector2(500, 150), + spawnPosition: new Vector2(500, 150), radius: 50, team: Team.blue, }) @@ -72,10 +80,10 @@ function laneScenario() { const entity2 = new Entity({ id: '2', - health: 100, height: 80, + logic: playerLogic, maxHealth: 100, - position: new Vector2(1600, 1800), + spawnPosition: new Vector2(1600, 1800), radius: 50, team: Team.red, }) @@ -107,30 +115,10 @@ function laneScenario() { midSouthWall.id = 'midSouthWall' game.addTerrain(midSouthWall) - // TODO: proper death and respawn - const playerLogic = function playerLogic() { - const entity = this - if (entity.health <= 0) { - if (entity.id == '1' || entity.id == '2') { - entity.health = entity.maxHealth - if (entity.id == '1') { - entity.teleport(new Vector2(500, 150)) - } - if (entity.id == '2') { - entity.teleport(new Vector2(1600, 1800)) - } - if (entity.id == '3') { - entity.teleport(new Vector2(1800, 1600)) - } - } - } - } - - entity1.logic = playerLogic - entity2.logic = playerLogic - const blueMinionLogic = function minionLogic() { const entity = this + if (entity.dead) { entity.despawn() } + let goal = new Vector2(1900, 1900) if (entity.position.x < 800 || entity.position.y < 1100) { goal = new Vector2(850, 1150) @@ -139,14 +127,12 @@ function laneScenario() { const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(75) const subGoal = entity.position.clone().add(direction) entity.attackAction(subGoal.x, subGoal.y) - - if (entity.health <= 0) { - entity.despawn() - } } const redMinionLogic = function minionLogic() { const entity = this + if (entity.dead) { entity.despawn() } + let goal = new Vector2(100, 100) if (entity.position.x > 900 || entity.position.y > 1200) { goal = new Vector2(850, 1150) @@ -155,10 +141,6 @@ function laneScenario() { const direction = goal.clone().sub(entity.position).normalize().multiplyScalar(75) const subGoal = entity.position.clone().add(direction) entity.attackAction(subGoal.x, subGoal.y) - - if (entity.health <= 0) { - entity.despawn() - } } const minionTemplate = { @@ -177,7 +159,6 @@ function laneScenario() { team: Team.blue, position: new Vector2(200, 200), }) - // blueMinion.scheduledPathfinding = game.entities.length % game.tickRate const blueMeleeMinion = new Entity({ ...minionTemplate, @@ -193,7 +174,6 @@ function laneScenario() { team: Team.red, position: new Vector2(1800, 1800), }) - // redMinion.scheduledPathfinding = game.entities.length % game.tickRate const redMeleeMinion = new Entity({ ...minionTemplate,