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 { Tween } from '@tweenjs/tween.js'
import * as THREE from 'three'
import Stats from 'stats.js' import Stats from 'stats.js'
const global = (0,eval)('this') const global = (0,eval)('this')
const scene = new THREE.Scene() const scene = new THREE.Scene()
const raycaster = new THREE.Raycaster() const raycaster = new THREE.Raycaster()
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000) const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000)
const clock = new THREE.Clock()
const renderer = new THREE.WebGLRenderer() const renderer = new THREE.WebGLRenderer()
const backgroundColor = new THREE.Color().setHex(0x112233) const backgroundColor = new THREE.Color().setHex(0x112233)
scene.background = backgroundColor scene.background = backgroundColor
@@ -24,7 +26,6 @@ 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 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 opacity = 0.3
const teamMaterials = { const teamMaterials = {
blue: new THREE.MeshToonMaterial({ color: 0x4444ff }), blue: new THREE.MeshToonMaterial({ color: 0x4444ff }),
@@ -35,6 +36,7 @@ const teamMaterials = {
redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }), redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }),
projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }), projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }),
range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }), 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 // TODO: draw lines of path for minimap camera
@@ -46,16 +48,51 @@ minimapRenderer.setSize(300, 300)
minimapRenderer.setAnimationLoop(minimapRender) minimapRenderer.setAnimationLoop(minimapRender)
minimapCamera.position.set(10, 10, 10) minimapCamera.position.set(10, 10, 10)
const animationActions = {}
const entities = {} const entities = {}
const projectiles = {} const gltf = {}
const mixers = {}
const positionTweens = {} const positionTweens = {}
const projectiles = {}
const rotationTweens = {}
const terrains = {} const terrains = {}
var state = { abilities: [], entities: [], terrains: [], projectiles: [] } var state = { abilities: [], entities: [], terrains: [], projectiles: [] }
global.animationActions = animationActions
global.entities = entities global.entities = entities
global.gltf = gltf
global.mixers = mixers
global.projectiles = projectiles global.projectiles = projectiles
global.terrains = terrains
global.state = state 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 geometry = new THREE.PlaneGeometry(0, 0)
const material = new THREE.MeshToonMaterial({ color: 0x115011 }) const material = new THREE.MeshToonMaterial({ color: 0x115011 })
@@ -82,10 +119,14 @@ const mouse = {}
var stats = new Stats() var stats = new Stats()
stats.showPanel(0) stats.showPanel(0)
var delta = 0
function render() { function render() {
stats.begin() stats.begin()
delta = clock.getDelta()
cameraMovement() 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) renderer.render(scene, camera)
stats.end() stats.end()
} }
@@ -94,11 +135,14 @@ function minimapRender() {
minimapRenderer.render(scene, minimapCamera) minimapRenderer.render(scene, minimapCamera)
} }
const lockedCameraSpeedMultiplier = 3
var cameraLocked = true var cameraLocked = true
function followCamera() { function followCamera() {
const entity = entities[playerId] const entity = entities[playerId]
if (entity == null) { return } if (entity == null) { return }
const cameraSpeed = lockedCameraSpeedMultiplier * delta
const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x) const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x)
const distanceY = Math.abs((entity.position.y + cameraOffsetY) - camera.position.y) 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() { function cameraMovement() {
if (cameraLocked) { if (cameraLocked) {
followCamera() followCamera()
return return
} }
const cameraSpeed = cameraSpeedMultiplier * delta
if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed } if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed }
else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed } else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed }
@@ -164,6 +210,7 @@ var websocket = null
global.websocket = null global.websocket = null
var timerId = null var timerId = null
var playerId = null var playerId = null
var playerTeam = null
function connectWebSocket() { function connectWebSocket() {
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`) websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
@@ -192,6 +239,11 @@ function connectWebSocket() {
tweenDuration = 1000 / stateUpdates.tickRate 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) { if (stateUpdates.width != null && stateUpdates.height != null) {
state.width = stateUpdates.width state.width = stateUpdates.width
state.height = stateUpdates.height 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)) { for (const e of Object.values(entities)) {
e.userData.flaggedForRemoval = true e.userData.flaggedForRemoval = true
} }
for (const e of state.entities ?? []) { for (const e of state.entities ?? []) {
let entity let entity
let created = false
if (e.id == playerId && playerTeam != e.team) {
playerTeam = e.team
}
if (e.id in entities) { if (e.id in entities) {
entity = entities[e.id] entity = entities[e.id]
} }
else { else {
const entityMaterial = teamMaterials[e.team] created = true
entity = new THREE.Mesh(new THREE.CylinderGeometry(e.visualRadius / 100, e.visualRadius / 100, e.height / 50), entityMaterial)
entity = new THREE.Group()
entity.rotation.x = Math.PI / 2 entity.rotation.x = Math.PI / 2
entity.scale.set(e.visualRadius / 100, e.visualRadius / 100, e.visualRadius / 100)
entity.userData.type = 'entity' entity.userData.type = 'entity'
entity.userData.id = e.id 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) scene.add(entity)
const hpMargin = 0.4 const hpMargin = 0.5
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 })) 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.scale.set(1.5, 0.2, 1)
maxHp.layers.set(1) maxHp.layers.set(1)
entity.add(maxHp) entity.add(maxHp)
@@ -310,83 +365,107 @@ function connectWebSocket() {
maxHp.add(hp) maxHp.add(hp)
const teamMaterial = teamMaterials[`${e.team}Transparent`] const teamMaterial = teamMaterials[`${e.team}Transparent`]
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.radius) / 100, (e.radius) / 100, 1), teamMaterial) const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry(1, 0.00001, 1), teamMaterial)
const teamMarkerSize = 4000 teamMarker.position.y = -0.493
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)
entity.add(teamMarker) entity.add(teamMarker)
const buffMaterial = new THREE.MeshToonMaterial({ color: 0xffff00, transparent: true, opacity: 0.4 }) 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 buffMarker = new THREE.Mesh(new THREE.TorusGeometry(0.95, 0.15), buffMaterial)
const buffMarkerSize = 400 buffMarker.rotation.x = Math.PI / 2
buffMarker.scale.y = e.height / buffMarkerSize
buffMarker.layers.set(1) buffMarker.layers.set(1)
buffMarker.visible = false buffMarker.visible = false
entity.add(buffMarker) 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 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 = (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 rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(rangeSize / e.visualRadius, rangeSize / e.visualRadius, 0.001), rangeMaterial)
const rangeMarkerSize = 5000 rangeMarker.position.y = 0.004
rangeMarker.scale.y = e.height / rangeMarkerSize
rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100)
rangeMarker.layers.set(1) rangeMarker.layers.set(1)
rangeMarker.visible = false rangeMarker.visible = false
entity.add(rangeMarker) 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 entities[e.id] = entity
} }
entity.children.at(0).visible = !e.dead entity.children.at(0).visible = !e.dead
entity.children.at(1).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) { if (e.dead) {
entity.rotation.x = 0 if (!animations.dead?.isRunning()) {
entity.position.z = 0 Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
z = 0 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 { else {
entity.rotation.x = Math.PI / 2 if (!animations.default?.isRunning()) {
entity.position.z = e.height / 100 Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
animations.default?.reset().fadeIn(fadeIn).play()
}
} }
entity.userData.flaggedForRemoval = false entity.userData.flaggedForRemoval = false
entity.children.at(3).rotation.y = e.rotation const oldRotationY = entity.children.at(4).rotation.y
positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z }, tweenDuration).start() 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 hp = entity.children.at(0).children.at(0)
const percentageHp = e.health / e.maxHealth const percentageHp = e.health / e.maxHealth
hp.scale.x = percentageHp hp.scale.x = percentageHp
hp.position.x = -(1 - percentageHp) / 2 hp.position.x = -(1 - percentageHp) / 2
// entity.children.at(4).visible = e.id == playerId entity.children.at(3).visible = !e.dead && e.id == playerId
entity.children.at(3).children.at(0).visible = e.casting != null // entity.children.at(5).visible = !e.dead && e.team == playerTeam // TODO: clipping makes the screen unviewable
} }
for (const e of Object.values(entities)) { for (const e of Object.values(entities)) {
if (e.userData.flaggedForRemoval) { if (e.userData.flaggedForRemoval) {
scene.remove(e) scene.remove(e)
delete animationActions[e.userData.id]
delete entities[e.userData.id] delete entities[e.userData.id]
delete mixers[e.userData.id]
delete positionTweens[e.userData.id] delete positionTweens[e.userData.id]
delete rotationTweens[e.userData.id]
} }
} }
@@ -447,18 +526,6 @@ function connectWebSocket() {
terrain.userData.id = t.id terrain.userData.id = t.id
scene.add(terrain) scene.add(terrain)
terrains[t.id] = 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) terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
@@ -549,10 +616,18 @@ function connectWebSocket() {
} }
window.addEventListener('load', () => { 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()) const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
playerId = params.id playerId = params.id
if (playerId == null) { if (playerId == null) {
playerId = prompt('Player ID:') playerId = prompt('Player ID:')
if (playerId == '') {
window.location.href = '/menu/'
return
}
} }
connectWebSocket() connectWebSocket()
+2
View File
@@ -159,6 +159,7 @@
.buff:hover { .buff:hover {
overflow: visible; overflow: visible;
height: fit-content;
z-index: 3; z-index: 3;
} }
@@ -167,6 +168,7 @@
padding: 5px; padding: 5px;
background-color: black; background-color: black;
width: fit-content; width: fit-content;
min-width: 200px;
height: 100%; height: 100%;
} }
</style> </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 const entityId = collidingEntity.id
if (!collided.has(entityId)) { if (!collided.has(entityId)) {
collidingEntity.heal(amount, caster) collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
collided.add(entityId) collided.add(entityId)
} }
} }
@@ -252,8 +252,8 @@ export default class Ability {
} }
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) { const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
caster.heal(amount, caster) caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
caster.heal(amount, caster) // NOTE: duplicated on purpose caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
} }
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) { const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
+8
View File
@@ -7,6 +7,7 @@ export default class Buff {
damageMultiplier = null damageMultiplier = null
duration = 0 duration = 0
shield = null
#effect = null #effect = null
@@ -25,4 +26,11 @@ export default class Buff {
duration: 4, duration: 4,
onHitMultiplier: 3, 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 dead = false
ghosting = false ghosting = false
health = null health = null
height = 40 height = null
maxHealth = 1 maxHealth = 1
model = null
position = null position = null
radius = 0 radius = 0
rotation = 0 rotation = 0
@@ -28,21 +29,21 @@ export default class Entity {
visionRange = 900 visionRange = 900
visualRadius = null visualRadius = null
#collision = true
#ghostable = true
#attacking = false #attacking = false
#bbox = new Float32Array(4) #bbox = new Float32Array(4)
#colliders = [] #colliders = []
#entitiesInVision = [] #collision = true
#projectilesInVision = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#dest = null #dest = null
#entitiesInVision = []
#game = null #game = null
#ghostable = true
#logic = null #logic = null
#moving = false #moving = false
#path = []
#noPathfindingUntil = 0 #noPathfindingUntil = 0
#path = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#projectilesInVision = []
#spawnPosition = new Vector2() #spawnPosition = new Vector2()
static bbox(x, y, radius) { static bbox(x, y, radius) {
@@ -140,6 +141,9 @@ export default class Entity {
if (this.visualRadius == null) { if (this.visualRadius == null) {
this.visualRadius = this.radius this.visualRadius = this.radius
} }
if (this.height == null) {
this.height = this.visualRadius ?? this.radius
}
this.#calculateCollider() this.#calculateCollider()
} }
@@ -177,7 +181,6 @@ export default class Entity {
this.moveAction(cursor, true) this.moveAction(cursor, true)
} }
// TODO: buffer skill inputs
castAction(slot, cursor, halt = false) { castAction(slot, cursor, halt = false) {
if (this.dead) { return } if (this.dead) { return }
@@ -267,16 +270,27 @@ export default class Entity {
} }
applyBuff(id, sourceId = null) { 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 index = this.buffs.findIndex((it) => it.id == id)
const source = sourceId ?? this.id const source = sourceId ?? this.id
const timestamp = this.game?.currentTick ?? 0 const timestamp = this.game?.currentTick ?? 0
if (index > -1) { if (index < 0) {
this.buffs[index].timestamp = timestamp const entityBuff = { id, source, timestamp }
this.buffs[index].source = source if (buff.shield != null) {
entityBuff.shield = buff.shield
}
this.buffs.push(entityBuff)
} }
else { 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)) 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) { damage(amount, source = null) {
if (this.dead) { return } if (this.dead) { return }
let customMultipliers = 0 let customMultipliers = 0
if (this.hasBuff(Buff.exposed.id)) { if (this.hasBuff(Buff.exposed.id)) {
const buff = this.getBuff(Buff.exposed.id) const buff = this.getBuff(Buff.exposed.id)
if (buff.source == source.id) { if (buff.source == source?.id) {
customMultipliers += (buff.onHitMultiplier - 1) customMultipliers += (buff.onHitMultiplier - 1)
this.removeBuff(Buff.exposed.id) 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 buffPassiveDamageMultiplier = damageMultiplerBuffs.reduce((it) => it.damageMultiplier - 1, 0)
const damageMultipler = 1 + buffPassiveDamageMultiplier + customMultipliers 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) this.health = Math.min(Math.max(0, this.health - damage), this.maxHealth)
} }
@@ -395,6 +419,10 @@ export default class Entity {
return { ...buffDefinition, ...entityBuff } return { ...buffDefinition, ...entityBuff }
} }
getBuffs() {
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
}
hasBuff(id) { hasBuff(id) {
if (this.dead) { return false } 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() { isColliding() {
@@ -472,9 +500,10 @@ 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 posCollider = Entity.collider(this.position.x, this.position.y, 0) const inWallVisionBypassRadius = Math.max(0, this.radius - 1)
const posBbox = Entity.bbox(this.position.x, this.position.y, 0) const posCollider = Entity.collider(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 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 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)
@@ -633,11 +662,8 @@ export default class Entity {
#castingVision() { #castingVision() {
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null) 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) { if (enemiesNearby) {
const radius = 300 const radius = 300
const duration = this.game?.secToTick(2) ?? 0 const duration = this.game?.secToTick(2) ?? 0
@@ -658,6 +684,7 @@ export default class Entity {
owner: this.id, owner: this.id,
position: this.position.clone(), position: this.position.clone(),
visionRange: radius, visionRange: radius,
team: enemyTeam,
}) })
this.game?.spawnProjectile(projectile) this.game?.spawnProjectile(projectile)
@@ -742,8 +769,7 @@ export default class Entity {
} }
} }
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 1000); failsafe++) { for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
if (failsafe >= 10) { console.error('Failsafe is reached!!!'); process.exit(0) }
const obstaclesArray = Array.from(obstacles.values()) const obstaclesArray = Array.from(obstacles.values())
for (const obstacle of obstaclesArray) { for (const obstacle of obstaclesArray) {
+15 -11
View File
@@ -35,7 +35,7 @@ export default class Game {
action(id, options) { action(id, options) {
const entity = this.entities.find((it) => it.id == id) const entity = this.entities.find((it) => it.id == id)
if (entity == null) { if (entity == null) {
console.error({ error: 'Invalid ID' }) console.info({ info: 'action_invalid_id', id, options })
return return
} }
@@ -54,7 +54,7 @@ export default class Game {
if (object instanceof Entity) { this.despawnEntity(object) } if (object instanceof Entity) { this.despawnEntity(object) }
else if (object instanceof Terrain) { this.removeTerrain(object) } else if (object instanceof Terrain) { this.removeTerrain(object) }
else if (object instanceof Projectile) { this.despawnProjectile(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) { despawnEntity(entity) {
@@ -92,7 +92,7 @@ export default class Game {
if (object instanceof Entity) { this.spawnEntity(object) } if (object instanceof Entity) { this.spawnEntity(object) }
else if (object instanceof Terrain) { this.addTerrain(object) } else if (object instanceof Terrain) { this.addTerrain(object) }
else if (object instanceof Projectile) { this.spawnProjectile(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) { spawnEntity(entity) {
@@ -109,7 +109,7 @@ export default class Game {
if (this.#gameLoopIntervalId != null) { return } if (this.#gameLoopIntervalId != null) { return }
this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget) 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) this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
} }
@@ -118,15 +118,20 @@ export default class Game {
clearInterval(this.#gameLoopIntervalId) clearInterval(this.#gameLoopIntervalId)
this.#gameLoopIntervalId = null 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) { subscription(websocket, id) {
return function builtSubscription() { return function builtSubscription(query = null) {
const game = this const game = this
if (query == 'id') { return id }
if (query != null) { return }
const entity = game.entities.find((it) => it.id == id) const entity = game.entities.find((it) => it.id == id)
if (entity == null) { return } if (entity == null) {
websocket.close()
return
}
const team = entity.team const team = entity.team
const state = game.visionByTeam(team) const state = game.visionByTeam(team)
@@ -189,15 +194,14 @@ export default class Game {
const before = performance.now() const before = performance.now()
this.update() this.update()
const after = performance.now() const after = performance.now()
const taken = (after - before) const tickTaken = after - before
const useAbsoluteBehind = true const useAbsoluteBehind = true
const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget)) const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind) this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
if (after - before > tickBudget) { if (tickTaken > tickBudget) {
const behindNotice = absoluteBehind > 0.1 ? `(Was already behind ${absoluteBehind.toFixed(1)} ms)` : `` console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. ${behindNotice}`)
} }
} }
} }
+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 { WebSocketExpress } from 'websocket-express'
import express from 'express' import express from 'express'
import Game from './game.js' import Game from './game.js'
@@ -9,7 +9,7 @@ try {
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST) os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
} }
catch (error) { catch (error) {
console.warn('Could not adjust process priority on startup.') console.warn({ warn: 'process_priority_unadjustable' })
} }
const app = new WebSocketExpress() 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('/stats.js/', express.static('node_modules/stats.js'))
app.use('/', express.static('public')) app.use('/', express.static('public'))
app.use('/models', express.static('models'))
app.use('/tools/', express.static('tools')) app.use('/tools/', express.static('tools'))
app.ws('/ws', async (req, res) => { app.ws('/ws', async (req, res) => {
@@ -30,16 +32,27 @@ app.ws('/ws', async (req, res) => {
websocket.on('message', (rawData) => { websocket.on('message', (rawData) => {
const message = JSON.parse(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') { if (message.action == 'join') {
const id = message.id const id = message.id
const connectionId = crypto.randomUUID() 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())) websocket.send(JSON.stringify(game.joinReport()))
const subscription = game.subscription(websocket, id).bind(game) const subscription = game.subscription(websocket, id).bind(game)
game.subscriptions.set(connectionId, subscription) game.subscriptions.set(connectionId, subscription)
websocket.on('close', () => { websocket.on('close', () => {
console.log({ event: 'disconnected', id }) console.info({ event: 'disconnected', id })
game.subscriptions.delete(connectionId) game.subscriptions.delete(connectionId)
}) })
return return
@@ -50,7 +63,8 @@ app.ws('/ws', async (req, res) => {
}) })
app.listen(port, () => { 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.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.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), new Vector2(i + 100, game.height - lowest),
])) ]))
} }
game.start()
} }
} }
@@ -68,8 +81,6 @@ export class Ravine {
team, team,
}))) })))
} }
game.start()
} }
static logic() { static logic() {
const game = this const game = this
-9
View File
@@ -182,15 +182,6 @@ export default class Pathfind {
graphIndex += 5 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 return graph
} }
+42 -11
View File
@@ -5,13 +5,12 @@ import Team from './team.js'
export default class Template { export default class Template {
static basilisk(overrides) { static basilisk(overrides) {
return { return {
abilities: {}, abilities: { a: Ability.rangedAttack.id },
height: 100, logic: this.#basiliskLogic(),
logic: this.#basiliskLogic, maxHealth: 300,
model: 'models/generic-bam-placeholder.gltf',
radius: 180, radius: 180,
speed: 230, speed: 230,
visualRadius: 170,
maxHealth: 3000,
...overrides, ...overrides,
} }
} }
@@ -19,17 +18,16 @@ export default class Template {
static minion(team, options = {}) { static minion(team, options = {}) {
return { return {
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id }, abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
height: options.ranged ? 40 : 38,
logic: this.#minionLogic(options.route, (team != Team.blue)), logic: this.#minionLogic(options.route, (team != Team.blue)),
maxHealth: options.ranged ? 300 : 450, 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, pathfindingCooldown: 0.2,
pathfindingObstacleLimit: 0, pathfindingObstacleLimit: 0,
position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0), position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0),
radius: 48, radius: options.ranged ? 36 : 38,
speed: 325, speed: 325,
team, team,
visionRange: 1200, visionRange: 1200,
visualRadius: options.ranged ? 36 : 38,
} }
} }
@@ -44,24 +42,57 @@ export default class Template {
d: Ability.circleOfResurrection.id, d: Ability.circleOfResurrection.id,
f: Ability.blink.id, f: Ability.blink.id,
}, },
height: 80,
logic: this.#playerLogic, logic: this.#playerLogic,
maxHealth: 600, maxHealth: 600,
model: Team.blue == (overrides.team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
pathfindingObstacleLimit: 3, pathfindingObstacleLimit: 3,
radius: 65, radius: 65,
spawnPosition: new Vector2(500, 150), spawnPosition: new Vector2(500, 150),
visionRange: 1350, visionRange: 1350,
visualRadius: 40,
...overrides, ...overrides,
} }
} }
static #basiliskLogic() { static #basiliskLogic() {
const entity = this let diedOnTick = null
let targetInRangeSince = null
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 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) { static #minionLogic(route = [], odd = false) {
const checkpointSize = 300 const checkpointSize = 300
const recalculateDestRadius = 50 const recalculateDestRadius = 50