diff --git a/public/client.js b/public/client.js
index f0fcafb..6aff89e 100644
--- a/public/client.js
+++ b/public/client.js
@@ -197,11 +197,18 @@ function connectWebSocket() {
state.height = stateUpdates.height
minimapCamera.top = state.height / 200
- minimapCamera.right = state.height / 200
+ minimapCamera.right = state.width / 200
minimapCamera.bottom = -state.height / 200
- minimapCamera.left = -state.height / 200
+ minimapCamera.left = -state.width / 200
minimapCamera.updateProjectionMatrix()
minimapCamera.position.set(state.width / 200, state.height / 200, minimapCameraZ)
+
+ const size = 300
+ const wide = state.width > state.height
+ minimapRenderer.setSize(
+ wide ? size : (state.width / state.height) * size,
+ wide ? (state.height / state.width) * size : size,
+ )
}
for (const [key, value] of Object.entries(stateUpdates)) {
@@ -333,8 +340,8 @@ function connectWebSocket() {
rotationBase.add(castingMarker)
const rangeMaterial = teamMaterials['range']
- const rangeSize = e.visionRange ?? 0
- // const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius
+ // const rangeSize = e.visionRange ?? 0
+ const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius
const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry((rangeSize) / 100, (rangeSize) / 100, 1), rangeMaterial)
const rangeMarkerSize = 5000
rangeMarker.scale.y = e.height / rangeMarkerSize
@@ -346,18 +353,32 @@ function connectWebSocket() {
entities[e.id] = entity
}
+ entity.children.at(0).visible = !e.dead
+ entity.children.at(1).visible = !e.dead
entity.children.at(2).visible = e.buffs.some((it) => it.id == 'exposed') // TODO: only works for Exposed now
+ let z = e.height / 100
+
+ if (e.dead) {
+ entity.rotation.x = 0
+ entity.position.z = 0
+ z = 0
+ }
+ else {
+ entity.rotation.x = Math.PI / 2
+ entity.position.z = e.height / 100
+ }
+
entity.userData.flaggedForRemoval = false
entity.children.at(3).rotation.y = e.rotation
- positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: e.height / 100 }, tweenDuration).start()
+ positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z }, tweenDuration).start()
const hp = entity.children.at(0).children.at(0)
const percentageHp = e.health / e.maxHealth
hp.scale.x = percentageHp
hp.position.x = -(1 - percentageHp) / 2
- entity.children.at(4).visible = e.id == playerId // TODO: undo, just for clarity
+ // entity.children.at(4).visible = e.id == playerId
entity.children.at(3).children.at(0).visible = e.casting != null
}
@@ -446,8 +467,8 @@ function connectWebSocket() {
if (playerId != null) {
const player = state.entities.find((e) => e.id == playerId)
if (player != null) {
- for (let abilityIndex = 0; abilityIndex < 4; abilityIndex++) {
- const abilityKey = ['a', 'q', 'w', 'e'][abilityIndex]
+ for (let abilityIndex = 0; abilityIndex < 7; abilityIndex++) {
+ const abilityKey = ['a', 'q', 'w', 'e', 'r', 'd', 'f'][abilityIndex]
if (player.abilities[abilityKey] != null) {
const abilityId = player.abilities[abilityKey]
const ability = state.abilities.find((it) => it.id == abilityId)
@@ -565,6 +586,15 @@ window.addEventListener('load', () => {
if (event.code == 'KeyE') {
websocket.send(JSON.stringify({ action: 'cast', slot: 'e', id: playerId, x, y }))
}
+ if (event.code == 'KeyR') {
+ websocket.send(JSON.stringify({ action: 'cast', slot: 'r', id: playerId, x, y }))
+ }
+ if (event.code == 'KeyD') {
+ websocket.send(JSON.stringify({ action: 'cast', slot: 'd', id: playerId, x, y }))
+ }
+ if (event.code == 'KeyF') {
+ websocket.send(JSON.stringify({ action: 'cast', slot: 'f', id: playerId, x, y }))
+ }
}
})
diff --git a/public/index.html b/public/index.html
index 0346538..8883ce4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -199,6 +199,21 @@
+
+
+
diff --git a/src/ability.js b/src/ability.js
index a9f12a8..34e8f2e 100644
--- a/src/ability.js
+++ b/src/ability.js
@@ -10,7 +10,7 @@ export default class Ability {
name = 'Ability'
- castTime = 0
+ castTime = null
cooldown = 0
damage = 0
moveCancelable = false
@@ -18,15 +18,17 @@ export default class Ability {
range = 0
speed = 1000
- #effect = () => {}
+ #effect = null
- get effect() { return this.#effect }
+ get effect() { return this.#effect ?? Ability.noEffect }
set effect(value) { this.#effect = value }
constructor(options = {}) {
Object.entries(options).forEach(([key, value]) => this[key] = value)
}
+ static get noEffect() { return function noEffect() {} }
+
static straightShot = new Ability({
id: 'straight_shot',
name: 'Straight Shot',
@@ -155,8 +157,7 @@ export default class Ability {
static blink = new Ability({
id: 'blink',
name: 'Blink',
- castTime: 0.25,
- cooldown: 2,
+ cooldown: 10,
range: 475,
effect: function blinkEffect(caster, cursor) {
const ability = this
@@ -246,4 +247,69 @@ export default class Ability {
caster.game?.spawnProjectile(projectile)
},
})
+
+ static circleOfResurrectionChannel = new Ability({
+ id: 'channel:circle_of_resurrection',
+ name: 'Channeling: Circle of Resurrection',
+ castTime: 3,
+ })
+
+ static circleOfResurrection = new Ability({
+ id: 'circle_of_resurrection',
+ name: 'Circle of Resurrection',
+ castTime: 0.5,
+ cooldown: 100,
+ duration: 3,
+ radius: 300,
+ range: 300,
+ effect: function circleOfResurrectionEffect(caster, cursor) {
+ const ability = this
+ caster.haltAction()
+
+ const direction = cursor.clone().sub(caster.position)
+ if (direction.length() > ability.range) {
+ direction.normalize().multiplyScalar(ability.range)
+ }
+
+ const destination = caster.position.clone().add(direction)
+
+ const team = caster.team
+ const currentTick = caster.game?.currentTick ?? 0
+ const duration = caster.game?.secToTick(ability.duration) ?? 0
+ const despawnAfter = currentTick + duration
+ const casterPosition = caster.position.clone()
+
+ const circleOfResurrectionLogic = function castingVisionLogic(projectile) {
+ const currentTick = projectile.game?.currentTick ?? 0
+ if (casterPosition.distanceTo(caster.position) > 1) {
+ projectile.despawn()
+ }
+
+ if (currentTick > despawnAfter) {
+ const entities = projectile.game?.entities ?? []
+ const pos = projectile.position
+ projectile.despawn()
+ const nearbyDeadTeammates = entities.filter((it) => it.dead && it.team == team)
+ const closestDeadTeammate = nearbyDeadTeammates.reduce((e1, e2) => (e1?.distanceTo(pos) ?? Infinity) < e2.distanceTo(pos) ? e1 : e2, null)
+ if (closestDeadTeammate != null) {
+ closestDeadTeammate.revive(closestDeadTeammate.maxHealth / 4)
+ caster.cooldown(ability.id)
+ }
+ }
+ }
+
+ const projectile = new Projectile({
+ logic: circleOfResurrectionLogic,
+ owner: caster.id,
+ position: destination,
+ radius: ability.radius,
+ visualRadius: 0,
+ })
+
+ caster.game?.spawnProjectile(projectile)
+ if (caster.casting != null) {
+ caster.forceCast(Ability.circleOfResurrectionChannel.id, destination)
+ }
+ },
+ })
}
diff --git a/src/buff.js b/src/buff.js
index 1d09e64..4180edf 100644
--- a/src/buff.js
+++ b/src/buff.js
@@ -7,11 +7,13 @@ export default class Buff {
duration = 0
- #effect = () => {}
+ #effect = null
- get effect() { return this.#effect }
+ get effect() { return this.#effect ?? Buff.noEffect }
set effect(value) { this.#effect = value }
+ static get noEffect() { return function noEffect() {} }
+
constructor(options = {}) {
Object.entries(options).forEach(([key, value]) => this[key] = value)
}
diff --git a/src/entity.js b/src/entity.js
index 1340f26..fe2608c 100644
--- a/src/entity.js
+++ b/src/entity.js
@@ -172,10 +172,14 @@ export default class Entity {
set y(value) { this.position.y = value }
attackAction(cursor) {
+ if (this.dead) { return }
+
this.moveAction(cursor, true)
}
castAction(slot, cursor, halt = false) {
+ if (this.dead) { return }
+
const ability = this.ability(slot)
if (ability == null) { return }
@@ -197,6 +201,12 @@ export default class Entity {
this.rotation = targetPosition.clone().sub(this.position).angle()
}
+ if (ability.castTime == null) {
+ ability.effect(this, cursor)
+
+ return true
+ }
+
const cooldown = this.game?.secToTick(ability.cooldown) ?? 0
const lastCast = this.cooldowns[ability.id]
const timestamp = this.game?.currentTick ?? 0
@@ -210,10 +220,14 @@ export default class Entity {
}
haltAction() {
+ if (this.dead) { return }
+
this.#moving = false
}
moveAction(cursor, attack = false) {
+ if (this.dead) { return }
+
if (this.casting != null && this.game?.abilities.filter((it) => it.id == this.casting.ability)?.moveCancelable) {
if (!attack && !(this.casting != null && this.casting.ability == this.abilities[0])) {
this.casting = null
@@ -226,6 +240,8 @@ export default class Entity {
}
stopAction() {
+ if (this.dead) { return }
+
this.casting = null
this.#moving = true
this.#attacking = false
@@ -301,10 +317,12 @@ 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) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
+ return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
}
damage(amount, source = null) {
+ if (this.dead) { return }
+
let damage = amount
if (this.hasBuff(Buff.exposed.id)) {
const buff = this.getBuff(Buff.exposed.id)
@@ -325,6 +343,27 @@ export default class Entity {
return this.position.distanceTo(cursor)
}
+ forceCast(abilityId, cursor) {
+ if (this.dead) { return }
+
+ const ability = this.game?.abilities.find((it) => it.id == abilityId)
+ if (ability == null) { return }
+
+ const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position
+ if (targetPosition instanceof Vector2) {
+ this.rotation = targetPosition.clone().sub(this.position).angle()
+ }
+
+ const timestamp = this.game?.currentTick ?? 0
+
+ if (ability.castTime == null) {
+ ability.effect(this, cursor)
+ }
+ else {
+ this.casting = { ability: ability.id, cursor, timestamp }
+ }
+ }
+
futureCollidables(futurePosition) {
return this.customBboxCollidables(new Float32Array([
futurePosition.y + this.radius,
@@ -335,6 +374,8 @@ export default class Entity {
}
getBuff(id) {
+ if (this.dead) { return }
+
const entityBuff = this.buffs.find((it) => it.id == id)
if (entityBuff == null) { return }
@@ -345,10 +386,14 @@ export default class Entity {
}
hasBuff(id) {
+ if (this.dead) { return false }
+
return this.buffs.some((it) => it.id == id) && this.game?.buffs.some((it) => it.id == id)
}
heal(amount) {
+ if (this.dead) { return }
+
this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth)
}
@@ -403,7 +448,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) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
+ const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true }
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat()
@@ -429,7 +474,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) => it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
+ const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return [] }
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
@@ -437,6 +482,8 @@ export default class Entity {
}
removeBuff(id) {
+ if (this.dead) { return }
+
this.buffs = this.buffs.filter((it) => it.id != id)
}
@@ -446,6 +493,15 @@ export default class Entity {
this.dead = false
}
+ revive(startingHealth = null) {
+ this.dead = false
+ const health = (startingHealth ?? this.maxHealth)
+ this.health = Math.max(0, Math.min(health, this.maxHealth))
+
+ this.#calculateCollider()
+ this.#calculateVision()
+ }
+
setPosition(vector) {
this.position.copy(vector)
this.#calculateCollider()
@@ -475,15 +531,14 @@ export default class Entity {
])
}
- // TODO: make non-race-condition calculations multi-threaded
update() {
+ this.#calculateVision()
+ this.#checkHealth()
if (this.dead) {
// TODO: do something while the entity is dead (and disallow casting, vision, etc)
}
else {
- this.#calculateVision()
this.#cast()
- this.#checkHealth()
this.#move()
this.#tickBuffs()
this.fixPosition()
@@ -523,6 +578,12 @@ export default class Entity {
}
#calculateVision() {
+ if (this.dead) {
+ this.#entitiesInVision = [this.id]
+ this.#projectilesInVision = []
+ return
+ }
+
const entities = this.game?.entities ?? []
const projectiles = this.game?.projectiles ?? []
@@ -555,15 +616,22 @@ export default class Entity {
ability.effect(this, this.casting.cursor)
- this.casting = null
+ if (this.casting.ability == ability.id) {
+ this.casting = null
+ }
+
// TODO: only spawn castingVision if slightly outside regular vision (or obstructed)
Ability.castingVision.effect(this, this.position)
return true
}
#checkHealth() {
- if (this.health <= 0) {
+ if (!this.dead && this.health <= 0) {
this.dead = true
+ this.buffs = []
+ }
+ else if (this.dead && this.health > 0) {
+ this.health = 0
}
}
diff --git a/src/game.js b/src/game.js
index 9fbbfa5..5690c6f 100644
--- a/src/game.js
+++ b/src/game.js
@@ -19,21 +19,18 @@ export default class Game {
tickRate = 30
width = 0
- #eventEmitter = new EventEmitter()
#gameLoopIntervalId = null
#logic = null
#nextTickAt = 0
#startTimestamp = 0
+ #subscriptions = new Map()
#tickBudget = 1000 / this.tickRate
get logic() { return this.#logic }
- get eventEmitter() { return this.#eventEmitter }
get tickBudget() { return this.#tickBudget }
- set logic(value) { this.#logic = value }
+ get subscriptions() { return this.#subscriptions }
- constructor() {
- this.#eventEmitter.setMaxListeners(20)
- }
+ set logic(value) { this.#logic = value }
action(id, options) {
const entity = this.entities.find((it) => it.id == id)
@@ -140,6 +137,10 @@ export default class Game {
}
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)
@@ -147,8 +148,6 @@ export default class Game {
this.#logic()
}
- this.eventEmitter.emit('tick')
-
this.currentTick++
}
diff --git a/src/index.js b/src/index.js
index aac1d5b..5eeaf1f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,4 +1,4 @@
-import { Dungeon, Ravine } from './level.js'
+import { Dungeon } from './level.js'
import { WebSocketExpress } from 'websocket-express'
import express from 'express'
import Game from './game.js'
@@ -33,13 +33,14 @@ app.ws('/ws', async (req, res) => {
console.log(message)
if (message.action == 'join') {
const id = message.id
+ const connectionId = crypto.randomUUID()
websocket.send(JSON.stringify(game.joinReport()))
const subscription = game.subscription(websocket, id).bind(game)
- game.eventEmitter.on('tick', subscription)
+ game.subscriptions.set(connectionId, subscription)
websocket.on('close', () => {
console.log({ event: 'disconnected', id })
- game.eventEmitter.removeListener('tick', subscription)
+ game.subscriptions.delete(connectionId)
})
return
}
@@ -51,6 +52,5 @@ app.ws('/ws', async (req, res) => {
app.listen(port, () => {
console.info(`Server started! Visit http://localhost:${port}`)
- // Dungeon.scenario(game)
- Ravine.scenario(game)
+ Dungeon.scenario(game)
})
diff --git a/src/level.js b/src/level.js
index e06e3ed..4556383 100644
--- a/src/level.js
+++ b/src/level.js
@@ -6,11 +6,28 @@ import Terrain from './terrain.js'
export class Dungeon {
static scenario(game) {
- game.width = 3000
- game.height = 3000
+ game.width = 2500
+ game.height = 1500
- const from = new Vector2(100, 100)
+ const team = Team.blue
+ const enemy = Team.neutral
+
+ game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: new Vector2(1500, 700), team })))
+ game.spawnEntity(new Entity(Template.player({ id: '2', spawnPosition: new Vector2(200, 1300), team, health: 10 })))
+
+ game.spawnEntity(new Entity(Template.basilisk({ id: 'boss', spawnPosition: new Vector2(2200, 750), team: enemy })))
+
+ setTimeout(() => game.entities.find((it) => it.id == '1').damage(9999), 10)
+
+ game.start()
+ }
+}
+
+export class Zigzag {
+ static scenario(game) {
+ game.width = 3000
game.height = 2000
+ const from = new Vector2(100, 100)
game.spawnEntity(new Entity(Template.player({ id: '1', spawnPosition: from, pathfindingObstacleLimit: 1, pathfindingCooldown: 0 })))
for (let i = 100; i < game.width; i += 300) {
const highest = ((i - 100) % 600) == 0 ? 0 : 500
diff --git a/src/template.js b/src/template.js
index 08054e6..69abb56 100644
--- a/src/template.js
+++ b/src/template.js
@@ -3,6 +3,19 @@ import Ability from './ability.js'
import Team from './team.js'
export default class Template {
+ static basilisk(overrides) {
+ return {
+ abilities: {},
+ height: 100,
+ logic: this.#basiliskLogic,
+ radius: 180,
+ speed: 230,
+ visualRadius: 170,
+ maxHealth: 3000,
+ ...overrides,
+ }
+ }
+
static minion(team, options = {}) {
return {
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
@@ -26,7 +39,9 @@ export default class Template {
a: Ability.rangedAttack.id,
q: Ability.straightShot.id,
w: Ability.expose.id,
- e: Ability.blink.id,
+ e: Ability.control.id,
+ d: Ability.circleOfResurrection.id,
+ f: Ability.blink.id,
},
height: 80,
logic: this.#playerLogic,
@@ -40,6 +55,12 @@ export default class Template {
}
}
+ static #basiliskLogic() {
+ const entity = this
+
+ return
+ }
+
static #minionLogic(route = [], odd = false) {
const checkpointSize = 300
const recalculateDestRadius = 50
@@ -87,8 +108,8 @@ export default class Template {
static #playerLogic() {
const entity = this
- if (entity.dead) {
- entity.respawn()
- }
+ // if (entity.dead) {
+ // entity.respawn()
+ // }
}
}
diff --git a/src/terrain.js b/src/terrain.js
index 9a98322..5d03994 100644
--- a/src/terrain.js
+++ b/src/terrain.js
@@ -32,7 +32,9 @@ export default class Terrain {
this.#calculateUnadjustedWaypoints()
this.#calculateBbox()
}
+
get vertices() { return this.#vertices }
+ get dead() { return false }
static waypointsForSide(fromVertex, toVertex, isClockwise = false) {
const from = isClockwise ? toVertex : fromVertex