Compare commits

..

10 Commits

Author SHA1 Message Date
thayol 8ae113b2cf fix invalid id reported on entities query 2025-01-26 15:04:38 +09:00
thayol 11ec464d27 adjust vision through/into terrain 2025-01-25 07:12:50 +01:00
thayol e799be0b59 add .tool-versions 2025-01-25 07:10:11 +01:00
thayol 78c52c2cc8 clean up TODO notes 2025-01-25 00:08:39 +09:00
thayol ff4483e9cf fix shielding logic 2025-01-25 00:06:48 +09:00
thayol 2b2336bf70 fix castingVision giving vision to neutrals 2025-01-24 14:41:30 +09:00
thayol de3c175914 use glTF animations 2025-01-24 12:19:42 +09:00
thayol 52a0da10fe use the placeholder player model 2025-01-23 23:39:10 +09:00
thayol 305980b7f9 add shield buff property 2025-01-23 14:20:14 +09:00
thayol de4c82fd8b standardize logs 2025-01-23 12:11:26 +09:00
20 changed files with 334 additions and 142 deletions
+1
View File
@@ -0,0 +1 @@
nodejs 23.6.1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

+141 -66
View File
@@ -1,11 +1,13 @@
import * as THREE from 'three'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { Tween } from '@tweenjs/tween.js'
import * as THREE from 'three'
import Stats from 'stats.js'
const global = (0,eval)('this')
const scene = new THREE.Scene()
const raycaster = new THREE.Raycaster()
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000)
const clock = new THREE.Clock()
const renderer = new THREE.WebGLRenderer()
const backgroundColor = new THREE.Color().setHex(0x112233)
scene.background = backgroundColor
@@ -24,7 +26,6 @@ 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 = {
blue: new THREE.MeshToonMaterial({ color: 0x4444ff }),
@@ -35,6 +36,7 @@ const teamMaterials = {
redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }),
projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }),
range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }),
visionRange: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 6 }),
}
// TODO: draw lines of path for minimap camera
@@ -46,16 +48,51 @@ minimapRenderer.setSize(300, 300)
minimapRenderer.setAnimationLoop(minimapRender)
minimapCamera.position.set(10, 10, 10)
const animationActions = {}
const entities = {}
const projectiles = {}
const gltf = {}
const mixers = {}
const positionTweens = {}
const projectiles = {}
const rotationTweens = {}
const terrains = {}
var state = { abilities: [], entities: [], terrains: [], projectiles: [] }
global.animationActions = animationActions
global.entities = entities
global.gltf = gltf
global.mixers = mixers
global.projectiles = projectiles
global.terrains = terrains
global.state = state
global.terrains = terrains
const gltfLoader = new GLTFLoader()
const preloadGLTF = function loadTemplate(path) {
gltfLoader.load(path, (loadedGLTF) => gltf[path] = loadedGLTF)
}
const addGLTF = function addGLTF(scene, path, id, additionalSteps = function noAdditionalSteps() {}) {
if (gltf[path] == null) {
setTimeout(() => addGLTF(scene, path, id, additionalSteps), 200)
return
}
const scale = 2
const model = gltf[path].scene.clone()
const mixer = new THREE.AnimationMixer(model)
mixers[id] = mixer
animationActions[id] = {}
gltf[path].animations.forEach((it) => {
const animation = mixer.clipAction(it)
animationActions[id][it.name] = animation
})
model.scale.set(scale, scale, scale)
additionalSteps(model)
scene.add(model)
}
const geometry = new THREE.PlaneGeometry(0, 0)
const material = new THREE.MeshToonMaterial({ color: 0x115011 })
@@ -82,10 +119,14 @@ const mouse = {}
var stats = new Stats()
stats.showPanel(0)
var delta = 0
function render() {
stats.begin()
delta = clock.getDelta()
cameraMovement()
Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens
Object.values(positionTweens).forEach((tween) => tween.update())
Object.values(rotationTweens).forEach((tween) => tween.update())
Object.values(mixers).forEach((mixer) => mixer.update(delta))
renderer.render(scene, camera)
stats.end()
}
@@ -94,11 +135,14 @@ function minimapRender() {
minimapRenderer.render(scene, minimapCamera)
}
const lockedCameraSpeedMultiplier = 3
var cameraLocked = true
function followCamera() {
const entity = entities[playerId]
if (entity == null) { return }
const cameraSpeed = lockedCameraSpeedMultiplier * delta
const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x)
const distanceY = Math.abs((entity.position.y + cameraOffsetY) - camera.position.y)
@@ -128,13 +172,15 @@ function followCamera() {
}
}
const cameraSpeed = 0.03
const cameraSpeedMultiplier = 10
function cameraMovement() {
if (cameraLocked) {
followCamera()
return
}
const cameraSpeed = cameraSpeedMultiplier * delta
if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed }
else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed }
@@ -164,6 +210,7 @@ var websocket = null
global.websocket = null
var timerId = null
var playerId = null
var playerTeam = null
function connectWebSocket() {
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
@@ -192,6 +239,11 @@ function connectWebSocket() {
tweenDuration = 1000 / stateUpdates.tickRate
}
if (stateUpdates.width != null && stateUpdates.height != null && stateUpdates.width != (state.width ?? -1) && stateUpdates.height != (state.height ?? -1) ) {
ground.geometry = new THREE.PlaneGeometry(stateUpdates.width / 100, stateUpdates.height / 100)
ground.position.set(stateUpdates.width / 200, stateUpdates.height / 200, 0)
}
if (stateUpdates.width != null && stateUpdates.height != null) {
state.width = stateUpdates.width
state.height = stateUpdates.height
@@ -273,32 +325,35 @@ function connectWebSocket() {
}
}
if (state.width != null && state.height != null && (ground.geometry.attributes.width != state.width || ground.geometry.attributes.height != state.height)) {
ground.geometry = new THREE.PlaneGeometry(state.width / 100, state.height / 100)
ground.position.set(state.width / 200, state.height / 200, 0)
}
for (const e of Object.values(entities)) {
e.userData.flaggedForRemoval = true
}
for (const e of state.entities ?? []) {
let entity
let created = false
if (e.id == playerId && playerTeam != e.team) {
playerTeam = e.team
}
if (e.id in entities) {
entity = entities[e.id]
}
else {
const entityMaterial = teamMaterials[e.team]
entity = new THREE.Mesh(new THREE.CylinderGeometry(e.visualRadius / 100, e.visualRadius / 100, e.height / 50), entityMaterial)
created = true
entity = new THREE.Group()
entity.rotation.x = Math.PI / 2
entity.scale.set(e.visualRadius / 100, e.visualRadius / 100, e.visualRadius / 100)
entity.userData.type = 'entity'
entity.userData.id = e.id
entity.position.set(e.position.x / 100, e.position.y / 100, e.height / 100)
entity.position.set(e.position.x / 100, e.position.y / 100, 0)
scene.add(entity)
const hpMargin = 0.4
const hpMargin = 0.5
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 }))
maxHp.position.set(0, (e.height / 100) + hpMargin, 0)
maxHp.position.set(0, 0, 0)
maxHp.scale.set(1.5, 0.2, 1)
maxHp.layers.set(1)
entity.add(maxHp)
@@ -310,83 +365,107 @@ function connectWebSocket() {
maxHp.add(hp)
const teamMaterial = teamMaterials[`${e.team}Transparent`]
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.radius) / 100, (e.radius) / 100, 1), teamMaterial)
const teamMarkerSize = 4000
teamMarker.scale.y = e.height / teamMarkerSize
teamMarker.position.y = (e.height / (teamMarkerSize * 2)) - (e.height / 100)
teamMarker.position.y += 0.01
teamMarker.layers.set(1)
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry(1, 0.00001, 1), teamMaterial)
teamMarker.position.y = -0.493
entity.add(teamMarker)
const buffMaterial = new THREE.MeshToonMaterial({ color: 0xffff00, transparent: true, opacity: 0.4 })
const buffMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.visualRadius + 10) / 100, (e.visualRadius + 10) / 100, 1), buffMaterial)
const buffMarkerSize = 400
buffMarker.scale.y = e.height / buffMarkerSize
const buffMarker = new THREE.Mesh(new THREE.TorusGeometry(0.95, 0.15), buffMaterial)
buffMarker.rotation.x = Math.PI / 2
buffMarker.layers.set(1)
buffMarker.visible = false
entity.add(buffMarker)
const rotationBase = new THREE.Object3D()
entity.add(rotationBase)
const castingMaterial = new THREE.MeshToonMaterial({ color: 0x10dde0, transparent: true, opacity: 0.4 })
const castingMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.height * 0.9) / 100, (e.height * 0.9) / 100, 1), castingMaterial)
const castingMarkerSize = 800
castingMarker.rotation.z = Math.PI / 2
castingMarker.position.x = (e.radius) / 100
castingMarker.scale.y = e.height / castingMarkerSize
castingMarker.layers.set(1)
buffMarker.visible = false
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 rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry((rangeSize) / 100, (rangeSize) / 100, 1), rangeMaterial)
const rangeMarkerSize = 5000
rangeMarker.scale.y = e.height / rangeMarkerSize
rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100)
const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(rangeSize / e.visualRadius, rangeSize / e.visualRadius, 0.001), rangeMaterial)
rangeMarker.position.y = 0.004
rangeMarker.layers.set(1)
rangeMarker.visible = false
entity.add(rangeMarker)
const modelRotationBase = new THREE.Object3D()
modelRotationBase.rotation.y = e.rotation - (Math.PI / 2)
modelRotationBase.layers.set(1)
entity.add(modelRotationBase)
const visionRangeMaterial = teamMaterials['visionRange']
const visionRangeSize = e.visionRange ?? 0
const visionRangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(visionRangeSize / e.visualRadius, visionRangeSize / e.visualRadius, 0.001), visionRangeMaterial)
visionRangeMarker.position.y = 0.002
visionRangeMarker.layers.set(1)
visionRangeMarker.visible = false
entity.add(visionRangeMarker)
if (e.model != null) {
addGLTF(modelRotationBase, e.model, e.id, function(model) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
maxHp.position.set(0, size.y + hpMargin, 0)
buffMarker.position.y = size.y / 2
buffMarker.scale.z = size.y / 10
})
}
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
entity.children.at(2).visible = !e.dead && e.buffs.some((it) => it.id == 'exposed')
let z = e.height / 100
const animations = animationActions[e.id] ?? {}
const fadeIn = created ? 0 : 0.15
if (e.dead) {
entity.rotation.x = 0
entity.position.z = 0
z = 0
if (!animations.dead?.isRunning()) {
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
animations.dead?.reset().fadeIn(fadeIn).play()
}
}
else if (e.casting != null) {
if (!animations.cast?.isRunning()) {
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
animations.cast?.reset().fadeIn(fadeIn).play()
}
}
else {
entity.rotation.x = Math.PI / 2
entity.position.z = e.height / 100
if (!animations.default?.isRunning()) {
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
animations.default?.reset().fadeIn(fadeIn).play()
}
}
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 }, tweenDuration).start()
const oldRotationY = entity.children.at(4).rotation.y
const newRotationY = e.rotation - (Math.PI / 2)
if (Math.abs((oldRotationY - (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
entity.children.at(4).rotation.y = oldRotationY - (2 * Math.PI)
}
if (Math.abs((oldRotationY + (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
entity.children.at(4).rotation.y = oldRotationY + (2 * Math.PI)
}
positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: 0 }, tweenDuration).start()
rotationTweens[entity.id] = new Tween(entity.children.at(4).rotation).to({ x: 0, y: newRotationY, z: 0 }, 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
entity.children.at(3).children.at(0).visible = e.casting != null
entity.children.at(3).visible = !e.dead && e.id == playerId
// entity.children.at(5).visible = !e.dead && e.team == playerTeam // TODO: clipping makes the screen unviewable
}
for (const e of Object.values(entities)) {
if (e.userData.flaggedForRemoval) {
scene.remove(e)
delete animationActions[e.userData.id]
delete entities[e.userData.id]
delete mixers[e.userData.id]
delete positionTweens[e.userData.id]
delete rotationTweens[e.userData.id]
}
}
@@ -447,18 +526,6 @@ function connectWebSocket() {
terrain.userData.id = t.id
scene.add(terrain)
terrains[t.id] = terrain
// // TODO: bboxes aren't tracked and can leak memory
// const bboxValues = Object.values(t.bbox)
// if (bboxValues.length >= 4) {
// const width = (bboxValues[1] - bboxValues[3]) / 100
// const height = (bboxValues[0] - bboxValues[2]) / 100
// const bbox = new THREE.Mesh(new THREE.BoxGeometry(width, height, 0.2), bboxMaterial)
// bbox.position.set((bboxValues[3] / 100) + (width / 2), (bboxValues[2] / 100) + (height / 2), 0)
// bbox.layers.set(1)
// scene.add(bbox)
// }
}
terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
@@ -549,10 +616,18 @@ function connectWebSocket() {
}
window.addEventListener('load', () => {
preloadGLTF('models/generic-bam-placeholder.gltf')
preloadGLTF('models/generic-player-placeholder.gltf')
preloadGLTF('models/generic-player-placeholder-red.gltf')
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
playerId = params.id
if (playerId == null) {
playerId = prompt('Player ID:')
if (playerId == '') {
window.location.href = '/menu/'
return
}
}
connectWebSocket()
+2
View File
@@ -159,6 +159,7 @@
.buff:hover {
overflow: visible;
height: fit-content;
z-index: 3;
}
@@ -167,6 +168,7 @@
padding: 5px;
background-color: black;
width: fit-content;
min-width: 200px;
height: 100%;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<style>
html {
background-color: black;
color: white;
font-family: sans-serif;
}
a:link, a:hover, a:active, a:visited {
color: white;
}
</style>
<h1>Take control of a unit:</h1>
<ul id="links"></ul>
<script>
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
websocket.onopen = () => { websocket.send(JSON.stringify({ action: 'entities' })) }
websocket.onmessage = (event) => {
const message = JSON.parse(event.data)
const entityIds = message?.entities
if (entityIds == null) { return }
websocket.close()
let links = ''
entityIds.forEach((entityId) => links += `<li><a href="/?id=${encodeURI(entityId)}">${entityId}</a></li>`)
document.getElementById('links').innerHTML = links
}
</script>
+3 -3
View File
@@ -235,7 +235,7 @@ export default class Ability {
const entityId = collidingEntity.id
if (!collided.has(entityId)) {
collidingEntity.heal(amount, caster)
collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
collided.add(entityId)
}
}
@@ -252,8 +252,8 @@ export default class Ability {
}
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
caster.heal(amount, caster)
caster.heal(amount, caster) // NOTE: duplicated on purpose
caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
}
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
+8
View File
@@ -7,6 +7,7 @@ export default class Buff {
damageMultiplier = null
duration = 0
shield = null
#effect = null
@@ -25,4 +26,11 @@ export default class Buff {
duration: 4,
onHitMultiplier: 3,
})
static shieldThrowShield = new Buff({
id: 'shield_throw_shield',
name: 'Shield (of Shield Throw)',
duration: 5,
shield: 200,
})
}
+53 -27
View File
@@ -18,8 +18,9 @@ export default class Entity {
dead = false
ghosting = false
health = null
height = 40
height = null
maxHealth = 1
model = null
position = null
radius = 0
rotation = 0
@@ -28,21 +29,21 @@ export default class Entity {
visionRange = 900
visualRadius = null
#collision = true
#ghostable = true
#attacking = false
#bbox = new Float32Array(4)
#colliders = []
#entitiesInVision = []
#projectilesInVision = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#collision = true
#dest = null
#entitiesInVision = []
#game = null
#ghostable = true
#logic = null
#moving = false
#path = []
#noPathfindingUntil = 0
#path = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#projectilesInVision = []
#spawnPosition = new Vector2()
static bbox(x, y, radius) {
@@ -140,6 +141,9 @@ export default class Entity {
if (this.visualRadius == null) {
this.visualRadius = this.radius
}
if (this.height == null) {
this.height = this.visualRadius ?? this.radius
}
this.#calculateCollider()
}
@@ -177,7 +181,6 @@ export default class Entity {
this.moveAction(cursor, true)
}
// TODO: buffer skill inputs
castAction(slot, cursor, halt = false) {
if (this.dead) { return }
@@ -267,16 +270,27 @@ export default class Entity {
}
applyBuff(id, sourceId = null) {
const buff = (this.game?.buffs ?? []).find((it) => it.id == id)
if (buff == null) { return false }
const index = this.buffs.findIndex((it) => it.id == id)
const source = sourceId ?? this.id
const timestamp = this.game?.currentTick ?? 0
if (index > -1) {
this.buffs[index].timestamp = timestamp
this.buffs[index].source = source
if (index < 0) {
const entityBuff = { id, source, timestamp }
if (buff.shield != null) {
entityBuff.shield = buff.shield
}
this.buffs.push(entityBuff)
}
else {
this.buffs.push({ id, source, timestamp })
this.buffs[index].timestamp = timestamp
this.buffs[index].source = source
if (buff.shield != null) {
this.buffs[index].shield = (this.buffs[index].shield ?? 0) + buff.shield
}
}
}
@@ -322,24 +336,34 @@ export default class Entity {
return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
}
// TODO: add shielding logic
damage(amount, source = null) {
if (this.dead) { return }
let customMultipliers = 0
if (this.hasBuff(Buff.exposed.id)) {
const buff = this.getBuff(Buff.exposed.id)
if (buff.source == source.id) {
if (buff.source == source?.id) {
customMultipliers += (buff.onHitMultiplier - 1)
this.removeBuff(Buff.exposed.id)
}
}
const damageMultiplerBuffs = source.buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
const buffs = this.buffs ?? []
const damageMultiplerBuffs = buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
const buffPassiveDamageMultiplier = damageMultiplerBuffs.reduce((it) => it.damageMultiplier - 1, 0)
const damageMultipler = 1 + buffPassiveDamageMultiplier + customMultipliers
const damage = amount * damageMultipler
let damage = amount * damageMultipler
if (damage >= 0) {
buffs.filter((it) => it.shield != null && it.shield > 0).forEach((it) => {
if (damage <= 0) { return }
const shielded = Math.max(0, Math.min(damage, it.shield))
it.shield -= shielded
damage -= shielded
})
}
this.health = Math.min(Math.max(0, this.health - damage), this.maxHealth)
}
@@ -395,6 +419,10 @@ export default class Entity {
return { ...buffDefinition, ...entityBuff }
}
getBuffs() {
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
}
hasBuff(id) {
if (this.dead) { return false }
@@ -440,7 +468,7 @@ export default class Entity {
}
}
console.error(`Can't fix position ([${futurePosition.x}, ${futurePosition.y}]) of entity ID: ${this.id}`)
console.error({ error: 'position_unfixable', id: this.id, futurePosition })
}
isColliding() {
@@ -472,9 +500,10 @@ export default class Entity {
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, 0)
const posBbox = Entity.bbox(this.position.x, this.position.y, 0)
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !(SATX.bboxCheck(posBbox, it.bbox) && it.colliders().some((c) => SATX.collideObject(posCollider, c))))
const inWallVisionBypassRadius = Math.max(0, this.radius - 1)
const posCollider = Entity.collider(this.position.x, this.position.y, inWallVisionBypassRadius)
const posBbox = Entity.bbox(this.position.x, this.position.y, inWallVisionBypassRadius)
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)
@@ -633,11 +662,8 @@ export default class Entity {
#castingVision() {
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null)
if (enemyTeam == null) {
return // only blue/red teams have casting vision
}
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && it.team == enemyTeam && it.distanceTo(this.position) <= (it.visionRange + this.radius))
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && (enemyTeam == null || it.team == enemyTeam) && it.distanceTo(this.position) <= (it.visionRange + this.radius))
if (enemiesNearby) {
const radius = 300
const duration = this.game?.secToTick(2) ?? 0
@@ -658,6 +684,7 @@ export default class Entity {
owner: this.id,
position: this.position.clone(),
visionRange: radius,
team: enemyTeam,
})
this.game?.spawnProjectile(projectile)
@@ -742,8 +769,7 @@ export default class Entity {
}
}
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 1000); failsafe++) {
if (failsafe >= 10) { console.error('Failsafe is reached!!!'); process.exit(0) }
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
const obstaclesArray = Array.from(obstacles.values())
for (const obstacle of obstaclesArray) {
+15 -11
View File
@@ -35,7 +35,7 @@ export default class Game {
action(id, options) {
const entity = this.entities.find((it) => it.id == id)
if (entity == null) {
console.error({ error: 'Invalid ID' })
console.info({ info: 'action_invalid_id', id, options })
return
}
@@ -54,7 +54,7 @@ export default class Game {
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 } }) }
else { console.error({ error: 'despawn_unknown_object', object }) }
}
despawnEntity(entity) {
@@ -92,7 +92,7 @@ export default class Game {
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 } }) }
else { console.error({ error: 'spawn_unknown_object', object }) }
}
spawnEntity(entity) {
@@ -109,7 +109,7 @@ export default class Game {
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}.`)
console.info({ event: 'game_start', id: this.id, tickRate: this.tickRate, currentTick: this.currentTick })
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
}
@@ -118,15 +118,20 @@ export default class Game {
clearInterval(this.#gameLoopIntervalId)
this.#gameLoopIntervalId = null
console.info(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
console.info({ event: 'game_stop', id: this.id, currentTick: this.currentTick })
}
subscription(websocket, id) {
return function builtSubscription() {
return function builtSubscription(query = null) {
const game = this
if (query == 'id') { return id }
if (query != null) { return }
const entity = game.entities.find((it) => it.id == id)
if (entity == null) { return }
if (entity == null) {
websocket.close()
return
}
const team = entity.team
const state = game.visionByTeam(team)
@@ -189,15 +194,14 @@ export default class Game {
const before = performance.now()
this.update()
const after = performance.now()
const taken = (after - before)
const tickTaken = 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}`)
if (tickTaken > tickBudget) {
console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
}
}
}
+20 -6
View File
@@ -1,4 +1,4 @@
import { Dungeon } from './level.js'
import * as LEVEL from './level.js'
import { WebSocketExpress } from 'websocket-express'
import express from 'express'
import Game from './game.js'
@@ -9,7 +9,7 @@ try {
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
}
catch (error) {
console.warn('Could not adjust process priority on startup.')
console.warn({ warn: 'process_priority_unadjustable' })
}
const app = new WebSocketExpress()
@@ -23,6 +23,8 @@ app.use('/@tweenjs/', express.static('node_modules/@tweenjs'))
app.use('/stats.js/', express.static('node_modules/stats.js'))
app.use('/', express.static('public'))
app.use('/models', express.static('models'))
app.use('/tools/', express.static('tools'))
app.ws('/ws', async (req, res) => {
@@ -30,16 +32,27 @@ app.ws('/ws', async (req, res) => {
websocket.on('message', (rawData) => {
const message = JSON.parse(rawData)
console.log(message)
console.info(message)
if (message.action == 'entities') {
websocket.send(JSON.stringify({ entities: game.entities.map((it) => it.id) }))
return
}
if (message.action == 'join') {
const id = message.id
const connectionId = crypto.randomUUID()
if (!game.entities.some((it) => it.id == id)) {
console.info({ error: 'join_invalid_id', id, connectionId })
return
}
console.info({ event: 'connected', id, connectionId })
websocket.send(JSON.stringify(game.joinReport()))
const subscription = game.subscription(websocket, id).bind(game)
game.subscriptions.set(connectionId, subscription)
websocket.on('close', () => {
console.log({ event: 'disconnected', id })
console.info({ event: 'disconnected', id })
game.subscriptions.delete(connectionId)
})
return
@@ -50,7 +63,8 @@ app.ws('/ws', async (req, res) => {
})
app.listen(port, () => {
console.info(`Server started! Visit http://localhost:${port}`)
console.info({ event: 'startup', visit: `http://localhost:${port}/menu/` })
Dungeon.scenario(game)
LEVEL.Chase.scenario(game)
game.start()
})
+17 -6
View File
@@ -26,8 +26,24 @@ export class Dungeon {
game.spawnEntity(new Entity(Template.player({ id: '2', spawnPosition: new Vector2(1500, 700), team, dead: true })))
game.spawnEntity(new Entity(Template.basilisk({ id: 'boss', spawnPosition: new Vector2(2200, 750), team: Team.neutral })))
}
}
game.start()
export class Chase {
static scenario(game) {
game.width = 1000
game.height = 1000
const chaserTemplate = {
// TODO: TODO
}
const runnerTemplate = {
// TODO: TODO
}
game.spawnEntity(new Entity({ ...chaserTemplate }))
game.spawnEntity(new Entity({ ...runnerTemplate }))
}
}
@@ -47,9 +63,6 @@ export class Zigzag {
new Vector2(i + 100, game.height - lowest),
]))
}
game.start()
}
}
@@ -68,8 +81,6 @@ export class Ravine {
team,
})))
}
game.start()
}
static logic() {
const game = this
-9
View File
@@ -182,15 +182,6 @@ export default class Pathfind {
graphIndex += 5
}
// const niceGraph = []
// for (let i = 0; i < graph.length / 5; i += 5) {
// niceGraph.push({
// from: [graph[i], graph[i + 1]],
// to: [graph[i + 2], graph[i + 3]],
// distance: graph[i + 4],
// })
// }
// console.log(niceGraph)
return graph
}
+43 -12
View File
@@ -5,13 +5,12 @@ import Team from './team.js'
export default class Template {
static basilisk(overrides) {
return {
abilities: {},
height: 100,
logic: this.#basiliskLogic,
abilities: { a: Ability.rangedAttack.id },
logic: this.#basiliskLogic(),
maxHealth: 300,
model: 'models/generic-bam-placeholder.gltf',
radius: 180,
speed: 230,
visualRadius: 170,
maxHealth: 3000,
...overrides,
}
}
@@ -19,17 +18,16 @@ export default class Template {
static minion(team, options = {}) {
return {
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
height: options.ranged ? 40 : 38,
logic: this.#minionLogic(options.route, (team != Team.blue)),
maxHealth: options.ranged ? 300 : 450,
model: Team.blue == (team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
pathfindingCooldown: 0.2,
pathfindingObstacleLimit: 0,
position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0),
radius: 48,
radius: options.ranged ? 36 : 38,
speed: 325,
team,
visionRange: 1200,
visualRadius: options.ranged ? 36 : 38,
}
}
@@ -44,22 +42,55 @@ export default class Template {
d: Ability.circleOfResurrection.id,
f: Ability.blink.id,
},
height: 80,
logic: this.#playerLogic,
maxHealth: 600,
model: Team.blue == (overrides.team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
pathfindingObstacleLimit: 3,
radius: 65,
spawnPosition: new Vector2(500, 150),
visionRange: 1350,
visualRadius: 40,
...overrides,
}
}
static #basiliskLogic() {
const entity = this
let diedOnTick = null
let targetInRangeSince = null
return
return function builtBasiliskLogic() {
const entity = this
if (Array.from(entity.game?.subscriptions.values()).some((it) => it('id') == entity.id)) { return }
const attackDelaySec = 2
const despawnDelaySec = 5
const despawnDelay = entity.game?.secToTick(despawnDelaySec) ?? 1
const timestamp = entity.game?.currentTick ?? 0
if (entity.dead && diedOnTick == null) { diedOnTick = timestamp }
if (entity.dead && diedOnTick != null && diedOnTick + despawnDelay < timestamp) { entity.despawn() }
if (!entity.dead) { diedOnTick = null }
if (entity.dead) { return }
const target = entity.closestTargetTo(entity.position, 500)
if (target == null) {
targetInRangeSince = null
return
}
if (targetInRangeSince == null) {
targetInRangeSince = timestamp
}
const attackDelay = entity.game?.secToTick(attackDelaySec) ?? 1
if (targetInRangeSince + attackDelay < timestamp) {
entity.castAction('a', target.id)
}
const directionToTarget = target.position.clone().sub(entity.position).normalize()
const entityRotationVector = new Vector2(1, 0).rotateAround(new Vector2(), entity.rotation)
entity.rotation = directionToTarget.clone().add(entityRotationVector).add(entityRotationVector).add(entityRotationVector).angle()
}
}
static #minionLogic(route = [], odd = false) {