87 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
thayol 55e5e8117c exclude dead entities from auto-attack target selection 2025-01-23 11:17:16 +09:00
thayol 15e72a9e10 fix auto-attack when target not in direct vision 2025-01-23 10:49:14 +09:00
thayol 4acd7a2881 fix projectiles colliding with dead entities 2025-01-23 10:36:28 +09:00
thayol afa419e77a add acceleration to shield throw 2025-01-23 00:04:13 +09:00
thayol 441a73355e untangle abilities 2025-01-22 23:41:53 +09:00
thayol 59b5a603a0 generalize buff damage multipliers 2025-01-22 23:33:48 +09:00
thayol 4c76d5dbde restrict casting vision to nearby enemies 2025-01-22 23:21:39 +09:00
thayol 0db1ceeedc fix dead state 2025-01-22 22:52:08 +09:00
thayol 4f8dcebcd1 increase process priority instead of offloading to workers 2025-01-22 15:00:32 +09:00
thayol c4c7c921d7 optimize reporting and serialization for clients 2025-01-22 12:41:55 +09:00
thayol 916bc31356 fix ghostable entities being pushed by ghosted entities 2025-01-22 00:16:07 +09:00
thayol fa2dbb5237 add bbox checks to pathfinding graphs 2025-01-21 23:57:45 +09:00
thayol 8ce1a2266f add bbox checks for pathfinding 2025-01-21 10:02:54 +09:00
thayol 6b8a220f39 fix vision logic and game tick timer 2025-01-20 11:17:35 +09:00
thayol bf38f69071 add vision 2025-01-20 00:05:48 +09:00
thayol 634dde2a3b use auto-incremented IDs instead of UUIDs 2025-01-19 21:43:27 +09:00
thayol e4f1fe19f4 fix projectile colliders in movement 2025-01-19 20:55:16 +09:00
thayol 072204b902 add catching up mechanic for ticks 2025-01-19 18:03:20 +09:00
thayol 04cc3f951e make projectiles use bounding boxes too 2025-01-19 16:11:51 +09:00
thayol e75c0d2944 use bounding boxes to optimize collision detection 2025-01-19 14:24:19 +09:00
thayol 0a4853aff9 add a basic terrain layout 2025-01-19 00:59:17 +09:00
thayol 0b949683a6 add 3D casting indicator 2025-01-18 21:02:04 +09:00
thayol 7824ba976b add stats 2025-01-18 20:10:54 +09:00
thayol 8457312f63 display buffs in the client 2025-01-18 12:00:12 +09:00
thayol 18c3ace616 make minimap dynamic 2025-01-18 11:07:06 +09:00
thayol 7415475cb0 add buffs 2025-01-18 10:49:38 +09:00
thayol ed6394354e fix auto attack range after cast 2025-01-18 10:01:46 +09:00
thayol 8ebae0d866 use ability keys instead of indices 2025-01-18 09:53:50 +09:00
thayol b4162d4e39 add range indicator 2025-01-18 09:42:16 +09:00
thayol 8e95bc141c fix melee attacks 2025-01-17 23:40:33 +09:00
thayol 9345c7af04 rely on stringification instead of state reports 2025-01-17 23:04:38 +09:00
thayol 80ccb92815 add visualRadius 2025-01-17 17:51:00 +09:00
thayol a44693aa5d despawn projectile instead of weird movement 2025-01-17 14:46:27 +09:00
thayol 1a5e811020 add moveCancelable to Ability 2025-01-17 14:43:49 +09:00
thayol 787b48a4df fix projectiles phasing through stuff 2025-01-17 14:01:30 +09:00
thayol 20f8a2f1fe use obstacle-in-path pathfinding 2025-01-17 13:01:47 +09:00
thayol 597aa204de add README 2025-01-14 01:44:21 +09:00
thayol 92e06dedce add minion routing 2025-01-13 22:38:54 +09:00
thayol 9d3fbda494 move entity definitions to templates 2025-01-13 16:53:12 +09:00
thayol ffbc4d9803 inflate ranges by entity radii 2025-01-13 16:17:34 +09:00
thayol 16429a6e1b add dead state 2025-01-13 14:08:10 +09:00
thayol 03bbea4862 fix auto-attack targeting 2025-01-13 11:45:26 +09:00
thayol 49a4d3e924 add visual distinction for teams 2025-01-12 20:38:00 +09:00
thayol ea23aa3174 adjust visual height 2025-01-12 20:05:29 +09:00
thayol 8e861929cb fix pathfinding issues 2025-01-12 19:43:45 +09:00
thayol 6ff950640c extend moveset with attack, halt, stop 2025-01-12 17:03:42 +09:00
thayol 302d2f0618 fix cast times 2025-01-12 14:50:37 +09:00
thayol d9d62d7070 collapse Effect into Ability 2025-01-12 13:58:35 +09:00
thayol d9849f770b fix game loop timer 2025-01-12 10:58:30 +09:00
thayol e0dd7dcaf3 add cast times and cooldowns 2025-01-12 03:30:52 +09:00
thayol 2eb914a680 add homing projectiles 2025-01-12 00:29:11 +09:00
thayol 51b61ab449 add skillshots 2025-01-12 00:11:00 +09:00
thayol 957b09b878 add lane scenario with HP 2025-01-11 21:40:57 +09:00
thayol 462dfe7b9a add tweening 2025-01-11 19:38:40 +09:00
thayol 4aba510ec0 improve position fixing 2025-01-11 18:17:44 +09:00
thayol f1c191f61f fix waypoints going out of bounds 2025-01-10 23:47:58 +09:00
thayol fe4dc8b8bc fix pathfinding for real 2024-12-25 18:12:53 +09:00
thayol 8fe48fb679 fix pathfinding nekimegyafalnak style 2024-12-25 17:19:45 +09:00
thayol 0f8a73911f check bi-directional paths in graph building 2024-12-25 11:19:01 +09:00
thayol 5acc827f7b revert "disable collision to fix pathfinding phasing through walls"
This reverts commit f48a6bf9aa.
2024-12-25 09:37:13 +09:00
thayol 2570f32592 revert "fix some pathfinding problems"
This reverts commit 2a9ef691fe.
2024-12-25 09:37:05 +09:00
thayol 2a9ef691fe fix some pathfinding problems 2024-12-25 09:33:07 +09:00
thayol f48a6bf9aa disable collision to fix pathfinding phasing through walls 2024-12-25 03:55:32 +09:00
thayol 3bb34ed012 fix line instantiation 2024-12-25 03:42:12 +09:00
thayol 227cc1590a fall back to lines when radius is zero 2024-12-25 01:35:12 +09:00
thayol fb6e75e38c add pathfinding times report 2024-12-25 01:15:46 +09:00
thayol 05360208b0 add unoptimized pathfinding 2024-12-25 00:32:33 +09:00
thayol 47aade7b3f add camera movement 2024-12-24 10:23:33 +09:00
thayol 37a77e902c add terrain collision 2024-12-23 18:30:59 +09:00
thayol ae6f4c2847 rename main.js to client.js 2024-12-23 12:18:21 +09:00
thayol ba0d8f606a add client side terrain without collision 2024-12-23 11:57:36 +09:00
thayol 604368b52c add minimap 2024-12-23 10:08:06 +09:00
thayol e23978ea90 ditch THREE raycasting for SAT again 2024-12-23 09:46:26 +09:00
thayol 054d22d01a replace most systems with THREE 2024-12-22 23:52:56 +09:00
thayol 14212afd70 add move speed 2024-12-22 17:00:23 +09:00
thayol 69343821b6 add move command to client 2024-12-22 16:50:54 +09:00
thayol 2957903cb1 Buff Queen AKA "Ez egy fa?"
Because the first placeholder player model
resembled a queen that's been to the gym a
bit too much. Also, before she got her head
and hands, she looked like a tree, legit.
2024-12-21 23:46:32 +09:00
37 changed files with 7016 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
*Dockerfile*
*docker-compose*
node_modules
+136
View File
@@ -0,0 +1,136 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Files generated by the app
public/temp
# Flamegraphs
*.0X
+1
View File
@@ -0,0 +1 @@
nodejs 23.6.1
+7
View File
@@ -0,0 +1,7 @@
FROM node:current-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY public ./public
COPY src ./src
CMD ["node", "src/index.js"]
+83 -2
View File
@@ -1,5 +1,86 @@
# Instructions Clear
Node JS MOBA attempt: https://git.uncensored.hu/thayol/instructions-clear/src/branch/nodejs/
Instructions Clear is a Multiplayer Online Battle Arena (MOBA) where two teams of five players fight for victory.
The players have the ability to upgrade their abilities via their gear earned from quests.
But make sure to prioritize resurrecting your allies first because once you wipe, it's all over.
The game blends some elements of traditional MMORPG dungeons with modern MOBAs.
Godot Async Survival attempt: https://git.uncensored.hu/thayol/instructions-clear/src/branch/godot/
## Design Pillars
| Power | Reaction | Competition |
| --- | --- | --- |
| Players gradually get stronger as they complete quests. | Quests provide upgrade paths that provide opportunity to adapt. | If not by sheer power or a good build, teams have to outperform the opposing team to win by any means. |
## Audience & Market
The game is designed to be played online.
Elements of the no longer popular MMORPG genre are used to give former players a modern game.
It is aimed at young adults.
## Core Gameplay
The game controls are single unit isometric Real-Time Strategy (RTS) style (point-and-click).
The player can decide the pathing destination via right mouse clicks, combine it with auto-attacking the closest target via left mouse clicks, and cast abilities using the Q, W, and E keys.
Players of the same team collectively deal damage to players of the other team to bring the members' health points to zero. If a team's collective health pool has run out, that team loses.
Quests provide the income of players. Each quest gives the option of changing an ability or cashing out for gold.
Abilities provided by quests can either be a "legendary" (main) ability or a "basic" (minor) ability. A player is limited to one legendary ability per game.
The main ideology behind legendary abilities is the traditional damage, control, support trio.
Gold can be used to resurrect others (in lieu of a specialized ability), and to upgrade ability slots (to scale power).
The game is likely to provide a single level only.
## Mechanics for mechanics
The game concept is mainly dictated by MOBAs with the tried and tested systems, however, there are differences:
* Abilities replace items to reduce the possible combinations and to ease the learning curve.
* Quests replace last-hitting to make income more diverse to enable traditionally low-income roles like position 5 supports to earn equally.
* The win condition is changed to team elimination instead of the destruction of the main structure so that the game is more centered around the already celebrated team fights.
* Equal, generic ability kits replace heroes (or champions) to eliminate the possibility of a "lost draft" at the hero selection phase.
TBD: What are quests?
TBD: How (and if) structures work
TBD: Unlike in the Defense Of The Ancients (DOTA), the core structure (the Ancient) is not a win condition but it provides a safe haven until destroyed.
## Gameplay Balance & Pacing
As all MOBAs, if balancing is not perfect, players will gravitate towards "metas" to win.
TBD: The pacing is so that it doesn't feel like too much preparation but it isn't dragged out.
TBD: The "AFK farming" tactic should be crossed out by giving players incentive to fight.
### Possible Issues
**Corpse camping:** After a successful pick, the enemies would not let the picked player revive.
**Long death timers:** The player can get bored until they get revived.
## Setting & World
TBD: Diagonal lanes or a free-for-all layout?
## Narrative
TBD: Gameplay is king, narrative is going to be an afterthought.
## Business Model
This is a free-to-play game. Since running servers costs money and the server binary is not public, a subscription model can be introduced to spin up more servers on demand instead of waiting in queue for the free servers.
The cosmetic aspect ("skins" on the player model) is supposed to be responsible for the majority of the income.
## Timeline
1. Prototype with working pathing
1. Win condition: full team elimination
1. Basic ability mechanics
1. Ability upgrades (upgrade trees)
1. Quest mechanics
1. Quest type diversification
1. Retrospective to plan the remainder of the timeline
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 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

+1075
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "instructions-clear",
"version": "1.0.0",
"main": "src/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Thayol",
"license": "UNLICENSED",
"description": "",
"dependencies": {
"@tweenjs/tween.js": "^25.0.0",
"sat": "^0.9.0",
"stats.js": "^0.17.0",
"three": "^0.171.0",
"websocket-express": "^3.1.2"
}
}
+723
View File
@@ -0,0 +1,723 @@
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
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setAnimationLoop(render)
const cameraOffsetX = 0
const cameraOffsetY = -13.5
const cameraOffsetZ = 20
camera.position.set(cameraOffsetX, cameraOffsetY, cameraOffsetZ)
camera.rotation.set((34 / 180) * Math.PI, 0, 0)
camera.zoom += 0.2
camera.updateProjectionMatrix()
camera.layers.enable(1)
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 opacity = 0.3
const teamMaterials = {
blue: new THREE.MeshToonMaterial({ color: 0x4444ff }),
blueTransparent: new THREE.MeshToonMaterial({ color: 0x4444ff, transparent: true, opacity }),
neutral: new THREE.MeshToonMaterial({ color: 0xcccccc }),
neutralTransparent: new THREE.MeshToonMaterial({ color: 0xcccccc, transparent: true, opacity }),
red: new THREE.MeshToonMaterial({ color: 0xff4444 }),
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
const minimapCameraZ = 10
const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10)
const minimapRenderer = new THREE.WebGLRenderer()
minimapRenderer.setSize(300, 300)
minimapRenderer.setAnimationLoop(minimapRender)
minimapCamera.position.set(10, 10, 10)
const animationActions = {}
const entities = {}
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.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 })
const ground = new THREE.Mesh(geometry, material)
scene.add(ground)
const ambientLight = new THREE.AmbientLight(0x404040, 10)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5)
directionalLight.position.set(-0.5, -0.05, 1)
directionalLight.power = 3000
scene.add(directionalLight)
global.THREE = THREE
global.renderer = renderer
global.camera = camera
global.scene = scene
var tweenDuration = 1
const keysDown = {}
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())
Object.values(rotationTweens).forEach((tween) => tween.update())
Object.values(mixers).forEach((mixer) => mixer.update(delta))
renderer.render(scene, camera)
stats.end()
}
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)
camera.position.z = cameraOffsetZ
if (distanceX > 0.01) {
if (entity.position.x + cameraOffsetX > camera.position.x) {
camera.position.x += cameraSpeed * distanceX
}
if (entity.position.x + cameraOffsetX < camera.position.x) {
camera.position.x -= cameraSpeed * distanceX
}
}
else if (distanceX != 0) {
camera.position.x = entity.position.x + cameraOffsetX
}
if (distanceY > 0.01) {
if (entity.position.y + cameraOffsetY > camera.position.y) {
camera.position.y += cameraSpeed * distanceY
}
if (entity.position.y + cameraOffsetY < camera.position.y) {
camera.position.y -= cameraSpeed * distanceY
}
}
else if (distanceY != 0) {
camera.position.y = entity.position.y + cameraOffsetY
}
}
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 }
if (keysDown.ArrowUp) { camera.position.y += cameraSpeed }
else if (keysDown.ArrowDown) { camera.position.y -= cameraSpeed }
if (keysDown.Space) {
camera.position.set(entities[playerId].position.x + cameraOffsetX, entities[playerId].position.y + cameraOffsetY, cameraOffsetZ)
}
}
function raycastToGround() {
const canvas = renderer.domElement
raycaster.setFromCamera(new THREE.Vector2((mouse.x / canvas.clientWidth) * 2 - 1, (mouse.y / canvas.clientHeight) * -2 + 1), camera)
const intersect = raycaster.intersectObject(ground).at(0)?.point
if (intersect != null) {
return {
x: Math.round(intersect.x * 100),
y: Math.round(intersect.y * 100),
}
}
return null
}
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`)
global.websocket = websocket
websocket.onerror = () => websocket.close()
websocket.onopen = () => {
document.getElementById('connection').innerHTML = 'open'
clearInterval(timerId)
websocket.send(JSON.stringify({ action: 'join', id: playerId }))
}
websocket.onclose = () => {
websocket = null
document.getElementById('connection').innerHTML = 'closed'
timerId = setInterval(() => {
if (websocket == null) {
connectWebSocket()
}
}, 2000)
}
websocket.onmessage = (event) => {
state.byteSize = new Blob([event.data]).size
const stateUpdates = JSON.parse(event.data)
if (stateUpdates.tickRate != null) {
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
minimapCamera.top = state.height / 200
minimapCamera.right = state.width / 200
minimapCamera.bottom = -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)) {
if (!['abilities', 'terrains', 'entities', 'projectiles', 'width', 'height'].includes(key)) {
state[key] = value
}
}
if (stateUpdates.abilities != null) {
const ids = stateUpdates.abilities.map((it) => it.id)
state.abilities = state.abilities.filter((it) => ids.includes(it.id))
for (const ability of stateUpdates.abilities ?? []) {
const index = state?.abilities?.findIndex((it) => it.id == ability.id)
if (index > -1) {
state.abilities[index] = {...state.abilities[index], ...ability}
}
else {
state.abilities.push(ability)
}
}
}
if (stateUpdates.entities != null) {
const ids = stateUpdates.entities.map((it) => it.id)
state.entities = state.entities.filter((it) => ids.includes(it.id))
for (const entity of stateUpdates.entities ?? []) {
const index = state?.entities?.findIndex((it) => it.id == entity.id)
if (index > -1) {
state.entities[index] = {...state.entities[index], ...entity}
}
else {
state.entities.push(entity)
}
}
}
if (stateUpdates.terrains != null) {
const ids = stateUpdates.terrains.map((it) => it.id)
state.terrains = state.terrains.filter((it) => ids.includes(it.id))
for (const terrain of stateUpdates.terrains ?? []) {
const index = state?.terrains?.findIndex((it) => it.id == terrain.id)
if (index > -1) {
state.terrains[index] = {...state.terrains[index], ...terrain}
}
else {
state.terrains.push(terrain)
}
}
}
if (stateUpdates.projectiles != null) {
const ids = stateUpdates.projectiles.map((it) => it.id)
state.projectiles = state.projectiles.filter((it) => ids.includes(it.id))
for (const projectile of stateUpdates.projectiles) {
const index = state?.projectiles?.findIndex((it) => it.id == projectile.id)
if (index > -1) {
state.projectiles[index] = {...state.projectiles[index], ...projectile}
}
else {
state.projectiles.push(projectile)
}
}
}
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 {
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, 0)
scene.add(entity)
const hpMargin = 0.5
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 }))
maxHp.position.set(0, 0, 0)
maxHp.scale.set(1.5, 0.2, 1)
maxHp.layers.set(1)
entity.add(maxHp)
const hp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0x77ff77 }))
hp.position.set(0, 0, 0)
hp.scale.set(1, 1, 1)
hp.layers.set(1)
maxHp.add(hp)
const teamMaterial = teamMaterials[`${e.team}Transparent`]
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.TorusGeometry(0.95, 0.15), buffMaterial)
buffMarker.rotation.x = Math.PI / 2
buffMarker.layers.set(1)
buffMarker.visible = false
entity.add(buffMarker)
const rangeMaterial = teamMaterials['range']
const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius
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.dead && e.buffs.some((it) => it.id == 'exposed')
const animations = animationActions[e.id] ?? {}
const fadeIn = created ? 0 : 0.15
if (e.dead) {
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 {
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
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(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]
}
}
for (const p of Object.values(projectiles)) {
p.userData.flaggedForRemoval = true
}
for (const p of state.projectiles ?? []) {
let projectile
if (p.id in projectiles) {
projectile = projectiles[p.id]
}
else {
projectile = new THREE.Mesh(new THREE.SphereGeometry(p.visualRadius / 100), projectileMaterial)
projectile.userData.type = 'projectile'
projectile.userData.id = p.id
projectile.position.set(p.position.x / 100, p.position.y / 100, p.height / 100)
projectile.layers.set(2)
scene.add(projectile)
projectile.rotation.x = Math.PI / 2 // needed for the team marker...
const teamMaterial = teamMaterials[`${p.team}Transparent`] ?? teamMaterials['projectile']
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((p.radius) / 100, (p.radius) / 100, 1), teamMaterial)
const teamMarkerSize = 4000
teamMarker.scale.y = p.height / teamMarkerSize
teamMarker.position.y = (p.height / (teamMarkerSize * 2)) - (p.height / 100)
teamMarker.position.y += 0.01
teamMarker.layers.set(2)
projectile.add(teamMarker)
projectiles[p.id] = projectile
}
projectile.userData.flaggedForRemoval = false
positionTweens[projectile.id] = new Tween(projectile.position).to({ x: p.position.x / 100, y: p.position.y / 100, z: p.height / 100 }, tweenDuration).start()
}
for (const p of Object.values(projectiles)) {
if (p.userData.flaggedForRemoval) {
scene.remove(p)
delete projectiles[p.userData.id]
delete positionTweens[p.userData.id]
}
}
for (const t of state.terrains ?? []) {
let terrain
if (t.id in terrains) {
terrain = terrains[t.id]
}
else {
const vertices = t.relativeVertices
const shape = new THREE.Shape()
shape.moveTo(vertices.at(0).x / 100, vertices.at(0).y / 100)
vertices.slice(1).forEach((v) => shape.lineTo(v.x / 100, v.y / 100))
terrain = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { bevelEnabled: false, depth: t.collision ? 0.5 : 0.35 }), t.collision ? terrainMaterial : passableTerrainMaterial)
terrain.userData.type = 'terrain'
terrain.userData.id = t.id
scene.add(terrain)
terrains[t.id] = terrain
}
terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
}
if (playerId != null) {
const player = state.entities.find((e) => e.id == playerId)
if (player != null) {
const playerAbilities = player.abilities
let abilitiesHTML = ''
let i = 0
for (const [abilityKey, _abilityId] of Object.entries(playerAbilities)) {
i++
const abilityKeyText = abilityKey.toUpperCase()
const abilityTemplate = `<div id="ability-${i}" class="ability">${abilityKeyText}<div id="ability-${i}-cooldown" class="cooldown"></div><div id="ability-${i}-cooldown-text" class="cooldown-text"></div></div>`
abilitiesHTML += abilityTemplate
}
if (document.getElementById(`abilities`).innerHTML != abilitiesHTML) {
document.getElementById(`abilities`).innerHTML = abilitiesHTML
}
let abilityIndex = 0
for (const [_abilityKey, abilityId] of Object.entries(playerAbilities)) {
abilityIndex++
const ability = state.abilities.find((it) => it.id == abilityId)
const lastCast = player.cooldowns[ability.id] ?? -Infinity
const cooldownDuration = (ability.cooldown * state.tickRate) ?? 0
const remainingCooldown = (lastCast + cooldownDuration) - state.currentTick
let cssPercentage = '100%'
let text = ''
if (remainingCooldown > 0) {
const cooldownPercentage = 1 - (remainingCooldown / cooldownDuration)
cssPercentage = `${Math.round(100 * cooldownPercentage)}%`
if (remainingCooldown / state.tickRate <= 5) {
text = `${(Math.round(10 * remainingCooldown / state.tickRate) / 10).toFixed(1)}`
}
else {
text = `${Math.round(remainingCooldown / state.tickRate)}`
}
}
if (player.casting?.ability == ability.id) {
document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(50% 0%, 0% 100%, 100% 100%)` // triangle
}
else {
document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(0 ${cssPercentage}, 100% ${cssPercentage}, 100% 100%, 0 100%)`
}
document.getElementById(`ability-${abilityIndex}-cooldown-text`).innerHTML = text
}
let buffs = ``
player.buffs.forEach((b) => {
buffs += `<div class="buff"><div class="buff-body">${state.buffs.find((it) => it.id == b.id).name}</div></div>`
})
if (document.getElementById('buffs').innerHTML != buffs) {
document.getElementById('buffs').innerHTML = buffs
}
let castIndicatorDisplay = 'none'
if (player.casting != null) {
castIndicatorDisplay = 'block'
const ability = state.abilities.find((it) => it.id == player.casting.ability)
if (ability != null) {
const castDuration = (ability.castTime * state.tickRate) ?? 0
const remainingCastTime = (player.casting.timestamp + castDuration) - state.currentTick
let cssPercentage = '100%'
if (remainingCastTime > 0) {
const castPercentage = 1 - (remainingCastTime / castDuration)
cssPercentage = `${Math.round(100 * castPercentage)}%`
}
document.getElementById('cast_indicator_progress').style.clipPath = `polygon(0 0, ${cssPercentage} 0, ${cssPercentage} 100%, 0% 100%)`
document.getElementById('cast_indicator_name').innerHTML = ability.name ?? ''
}
}
document.getElementById('cast_indicator').style.display = castIndicatorDisplay
}
}
document.getElementById('state').innerHTML = JSON.stringify(stateUpdates, null, 2)
}
}
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()
const canvas = renderer.domElement
canvas.classList.add('canvas')
window.addEventListener('mousedown', (event) => {
const intersect = raycastToGround()
if (intersect != null) {
const { x, y } = intersect
if (event.button == 0) {
websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y }))
}
if (event.button == 2) {
websocket.send(JSON.stringify({ action: 'move', id: playerId, x, y }))
}
}
})
window.addEventListener('keydown', (event) => {
const intersect = raycastToGround()
if (intersect != null) {
const { x, y } = intersect
if (event.code == 'KeyA') {
websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y }))
}
if (event.code == 'KeyX') {
websocket.send(JSON.stringify({ action: 'cast', slot: 'a', id: playerId, x, y }))
}
if (event.code == 'KeyS') {
websocket.send(JSON.stringify({ action: 'stop', id: playerId }))
}
if (event.code == 'KeyH') {
websocket.send(JSON.stringify({ action: 'halt', id: playerId }))
}
const alreadyBound = ['A', 'X', 'S', 'H']
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter) => {
if (alreadyBound.includes(letter)) { return }
if (event.code == `Key${letter}`) {
websocket.send(JSON.stringify({ action: 'cast', slot: letter.toLowerCase(), id: playerId, x, y }))
}
})
}
})
window.addEventListener('wheel', (event) => {
if (event.deltaY < 0) {
camera.zoom += 0.2
if (camera.zoom > 3) {
camera.zoom = 3
}
camera.updateProjectionMatrix()
}
if (event.deltaY > 0) {
camera.zoom -= 0.2
if (camera.zoom < 1) {
camera.zoom = 1
}
camera.updateProjectionMatrix()
}
})
window.addEventListener('resize', (event) => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
window.addEventListener('contextmenu', (event) => event.preventDefault())
window.addEventListener('keydown', (event) => keysDown[event.code] = true)
window.addEventListener('keyup', (event) => keysDown[event.code] = false)
window.addEventListener('keydown', (event) => {
if (event.code == 'Space') {
cameraLocked = !cameraLocked
}
})
window.addEventListener('mousemove', (event) => {
mouse.x = event.clientX
mouse.y = event.clientY
})
document.body.appendChild(canvas)
const minimap = minimapRenderer.domElement
minimap.classList.add('minimap')
document.body.appendChild(minimap)
document.body.appendChild(stats.dom)
})
+192
View File
@@ -0,0 +1,192 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="author" content="Thayol">
<script type="importmap">
{
"imports": {
"three": "/three/build/three.module.js",
"three/addons/": "/three/examples/jsm/",
"@tweenjs/tween.js": "/@tweenjs/tween.js/dist/tween.esm.js",
"stats.js": "/stats.js/src/Stats.js"
}
}
</script>
<style>
* {
box-sizing: border-box;
}
html {
font-family: sans-serif;
}
html, body {
margin: 0;
padding: 0;
user-select: none;
}
.debug-panel {
display: none;
font-size: 0.8rem;
position: fixed;
opacity: 0.2;
overflow-y: scroll;
inset: 0 0 290px auto;
border-bottom-left-radius: 10px;
padding: 10px 10px 20px 20px;
background-color: white;
border: 5px solid gray;
border-top: none;
border-right: none;
width: 300px;
transition-duration: 0.2s;
transition-property: opacity;
}
.debug-panel:hover {
opacity: 1;
}
.minimap {
position: fixed;
inset: auto 0 0 auto;
border: 5px solid gray;
border-top-left-radius: 10px;
border-bottom: none;
border-right: none;
}
.abilities {
display: none;
position: fixed;
gap: 10px;
padding: 15px;
padding-bottom: 10px;
inset: auto 0 0 0;
width: fit-content;
margin: auto;
border: 5px solid gray;
background-color: black;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom: none;
}
.abilities:has(.ability) {
display: flex;
}
.ability {
position: relative;
flex: 1 0 0;
border: 1px solid white;
width: 75px;
height: 75px;
color: white;
}
.cooldown {
position: absolute;
top: 0;
left: 0;
width: 73px;
height: 73px;
background-color: grey;
opacity: 0.4;
}
.cooldown-text {
position: absolute;
top: 0;
left: 0;
width: 73px;
height: 73px;
line-height: 73px;
text-align: center;
color: white;
font-family: monospace;
}
.cast-indicator-wrapper {
display: none;
position: fixed;
inset: auto 0 30%;
width: 400px;
margin: auto;
}
.cast-indicator-progress {
position: absolute;
background-color: #edd9ff;
width: calc(100% - 4px);
height: calc(100% - 4px);
}
.cast-indicator-name {
text-align: center;
color: white;
text-shadow: 1px 1px 2px black, 0 0 1em dimgray, 0 0 0.2em dimgray;
}
.cast-indicator-bar {
position: relative;
background-color: dimgray;
width: 100%;
height: 20px;
padding: 2px;
}
.buffs {
position: fixed;
display: flex;
gap: 10px;
inset: auto 0 120px calc(50vw - 165px);
width: fit-content;
}
.buff {
flex: 1 0 0;
width: 30px;
height: 30px;
background-color: black;
/* border: 1px solid gray; */
border-right: 1px solid gray;
color: white;
overflow: hidden;
}
.buff:hover {
overflow: visible;
height: fit-content;
z-index: 3;
}
.buff-body {
border: 1px solid gray;
padding: 5px;
background-color: black;
width: fit-content;
min-width: 200px;
height: 100%;
}
</style>
</head>
<body>
<div class="debug-panel">
<p>Connection: <span id="connection"></span></p>
<pre id="state"></pre>
</div>
<div id="cast_indicator" class="cast-indicator-wrapper">
<div id="cast_indicator_name" class="cast-indicator-name"></div>
<div class="cast-indicator-bar">
<div id="cast_indicator_progress" class="cast-indicator-progress"></div>
</div>
</div>
<div id="abilities" class="abilities">
</div>
<div id="buffs" class="buffs"></div>
<script type="module" src="client.js"></script>
</body>
</html>
+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>
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

+320
View File
@@ -0,0 +1,320 @@
import Buff from './buff.js'
import Projectile from './projectile.js'
// Three classes: Blade, Armor, Charm
export default class Ability {
id = `ability-${Ability.nextId()}`
static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0
name = 'Ability'
castTime = null
cooldown = 0
damage = 0
moveCancelable = false
radius = 1
range = 0
speed = 1000
#effect = null
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 blink = new Ability({
id: 'blink',
name: 'Blink',
cooldown: 10,
range: 475,
effect: function blinkEffect(caster, cursor) {
const ability = this
const direction = cursor.clone().sub(caster.position)
const realRange = ability.range + caster.radius
if (direction.length() > realRange) {
direction.normalize().multiplyScalar(realRange)
}
const destination = caster.position.clone().add(direction)
caster.teleport(destination)
caster.cooldown(ability.id)
},
})
static circleOfResurrection = new Ability({
id: 'circle_of_resurrection',
name: 'Circle of Resurrection',
castTime: 0.5,
cooldown: 100,
duration: 3,
moveCancelable: true,
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 && it.distanceTo(pos) <= ability.radius)
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)
}
},
})
static circleOfResurrectionChannel = new Ability({
id: 'channel:circle_of_resurrection',
name: 'Channeling: Circle of Resurrection',
castTime: 3,
moveCancelable: true,
})
static control = new Ability({
id: 'control',
name: 'Control',
castTime: 1,
cooldown: 5,
effect: function controlEffect(caster, cursor) { },
})
static expose = new Ability({
id: 'expose',
name: 'Expose',
castTime: 0.25,
cooldown: 6,
radius: 80,
range: 1200,
speed: 1700,
visualRadius: 50,
effect: function exposeEffect(caster, cursor) {
const ability = this
const exposeCollision = function exposeCollision(projectile, collidingEntity) {
if (projectile.game == null) { return }
if (collidingEntity == null) { return }
if (collidingEntity.team == caster.id) { return }
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
collidingEntity.applyBuff(Buff.exposed.id, caster.id)
projectile.despawn()
}
const projectile = new Projectile({
onCollide: exposeCollision,
owner: caster.id,
position: caster.position.clone(),
radius: ability.radius,
speed: ability.speed,
visualRadius: ability.visualRadius,
})
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
caster.game?.spawnProjectile(projectile)
caster.cooldown(ability.id)
},
})
static meleeAttack = new Ability({
id: 'melee_attack',
name: 'Melee Attack',
castTime: (1.4 * 0.22),
cooldown: 1.4,
moveCancelable: true,
damage: 60,
radius: 5,
range: 100,
effect: function meleeAttackEffect(caster, targetId) {
const ability = this
const target = caster.game?.entities.find((it) => it.id == targetId)
if (target == null) { return }
target.damage(ability.damage, caster)
caster.cooldown(ability.id)
},
})
static rangedAttack = new Ability({
id: 'ranged_attack',
name: 'Ranged Attack',
castTime: (1.6 * 0.18839),
cooldown: 1.6,
damage: 60,
moveCancelable: true,
radius: 5,
range: 500,
speed: 2000,
effect: function rangedAttackEffect(caster, targetId) {
const ability = this
const target = caster.game?.entities.find((it) => it.id == targetId)
if (target == null) { return }
const rangedAttackAfter = function rangedAttackAfter() {
target.damage(ability.damage, caster)
}
const projectile = new Projectile({
after: rangedAttackAfter,
homingTarget: target,
owner: caster.id,
position: caster.position.clone(),
radius: ability.radius,
speed: ability.speed,
})
caster.game?.spawnProjectile(projectile)
caster.cooldown(ability.id)
},
})
static shieldThrow = new Ability({
id: 'shield_throw',
name: 'Shield Throw',
castTime: 0.25,
cooldown: 5,
damage: 90,
deceleratePerTick: 90,
radius: 110,
range: 1025,
speed: 2400,
effect: function shieldThrowEffect(caster, cursor) {
const ability = this
const amount = ability.damage
let onTheWayBack = false
let collided = new Set()
const shieldThrowCollision = function shieldThrowCollision(projectile, collidingEntity) {
if (collidingEntity == null) { return }
if (collidingEntity.id == caster.id) { return }
if (caster.team == null || collidingEntity.team == null || collidingEntity.team != caster.team) { return }
const entityId = collidingEntity.id
if (!collided.has(entityId)) {
collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
collided.add(entityId)
}
}
const accelerateLogic = function accelerateLogic(projectile) {
if (onTheWayBack) {
projectile.speed += ability.deceleratePerTick
}
else {
if (projectile.speed - ability.deceleratePerTick >= ability.deceleratePerTick) {
projectile.speed = projectile.speed - ability.deceleratePerTick
}
}
}
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
}
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
projectile.destination = null
projectile.homingTarget = caster
onTheWayBack = true
collided.clear()
projectile.after = shieldThrowSecondAfter
}
const projectile = new Projectile({
after: shieldThrowFirstAfter,
logic: accelerateLogic,
onCollide: shieldThrowCollision,
owner: caster.id,
position: caster.position.clone(),
radius: ability.radius,
speed: ability.speed,
visionRange: ability.radius * 1.5,
})
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
caster.game?.spawnProjectile(projectile)
caster.cooldown(ability.id)
},
})
static straightShot = new Ability({
id: 'straight_shot',
name: 'Straight Shot',
castTime: 0.25,
cooldown: 1,
damage: 83,
radius: 60,
range: 1200,
visualRadius: 20,
speed: 2000,
effect: function straightShotEffect(caster, cursor) {
const ability = this
const straightShotCollision = function straightShotCollision(projectile, collidingEntity) {
if (projectile.game == null) { return }
if (collidingEntity == null) { return }
if (collidingEntity.id == caster.id) { return }
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
collidingEntity.damage(ability.damage, caster)
projectile.despawn()
}
const projectile = new Projectile({
onCollide: straightShotCollision,
owner: caster.id,
position: caster.position.clone(),
radius: ability.radius,
speed: ability.speed,
visualRadius: ability.visualRadius,
})
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
caster.game?.spawnProjectile(projectile)
caster.cooldown(ability.id)
},
})
}
+36
View File
@@ -0,0 +1,36 @@
export default class Buff {
id = `ability-${Buff.nextId()}`
static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0
name = 'Buff'
damageMultiplier = null
duration = 0
shield = null
#effect = null
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)
}
static exposed = new Buff({
id: 'exposed',
name: 'Exposed',
duration: 4,
onHitMultiplier: 3,
})
static shieldThrowShield = new Buff({
id: 'shield_throw_shield',
name: 'Shield (of Shield Throw)',
duration: 5,
shield: 200,
})
}
+886
View File
@@ -0,0 +1,886 @@
import { Vector2 } from 'three'
import Buff from './buff.js'
import Pathfind from './pathfind.js'
import Projectile from './projectile.js'
import SAT from 'sat'
import SATX from './satx.js'
import Team from './team.js'
export default class Entity {
id = `entity-${Entity.nextId()}`
static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0
abilities = {}
buffs = []
casting = null
cooldowns = {}
dead = false
ghosting = false
health = null
height = null
maxHealth = 1
model = null
position = null
radius = 0
rotation = 0
speed = 400
team = Team.neutral
visionRange = 900
visualRadius = null
#attacking = false
#bbox = new Float32Array(4)
#colliders = []
#collision = true
#dest = null
#entitiesInVision = []
#game = null
#ghostable = true
#logic = null
#moving = false
#noPathfindingUntil = 0
#path = []
#pathfindingCooldown = 0
#pathfindingObstacleLimit = null
#projectilesInVision = []
#spawnPosition = new Vector2()
static bbox(x, y, radius) {
return new Float32Array([y + radius, x + radius, y - radius, x - radius])
}
static collider(x, y, radius) {
return new SAT.Circle(new SAT.Vector(x, y), radius)
}
// deliberate code duplication for performance
static tunnelCollider(fromX, fromY, toX, toY, radius) {
if (radius <= 0) {
return SATX.line(fromX, fromY, toX, toY)
}
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [
new SAT.Vector(),
new SAT.Vector(sides[0], sides[1]),
new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])),
new SAT.Vector(2 * sides[2], 2 * sides[3]),
])
}
// deliberate code duplication for performance
static tunnelVertices(fromX, fromY, toX, toY, radius) {
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
return [
new Vector2(fromX - sides[2], fromY - sides[3]),
new Vector2(fromX - sides[2] + sides[0], fromY - sides[3] + sides[1]),
new Vector2(fromX + sides[2] + sides[0], fromY + sides[3] + sides[1]),
new Vector2(fromX + sides[2], fromY + sides[3]),
]
}
// deliberate code duplication for performance
static tunnelBbox(fromX, fromY, toX, toY, radius) {
if (radius <= 0) {
return new Float32Array([
Math.max(fromY, toY),
Math.max(fromX, toX),
Math.min(fromY, toY),
Math.min(fromX, toX),
])
}
const sides = new Float32Array(5)
sides[0] = toX - fromX
sides[1] = toY - fromY
sides[4] = Math.hypot(sides[0], sides[1])
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
sides[3] = (sides[0] / sides[4]) * radius
const offsetX = fromX + sides[0]
const x1 = fromX - sides[2]
const x2 = fromX + sides[2]
const x3 = offsetX - sides[2]
const x4 = offsetX + sides[2]
const offsetY = fromY + sides[1]
const y1 = fromY - sides[3]
const y2 = fromY + sides[3]
const y3 = offsetY - sides[3]
const y4 = offsetY + sides[3]
return new Float32Array([
Math.max(y1, y2, y3, y4),
Math.max(x1, x2, x3, x4),
Math.min(y1, y2, y3, y4),
Math.min(x1, x2, x3, x4),
])
}
constructor(options = {}) {
Object.entries(options).forEach(([key, value]) => this[key] = value)
if (this.position == null) {
this.position = this.#spawnPosition.clone()
}
if (this.health == null) {
this.health = this.maxHealth
}
if (this.visualRadius == null) {
this.visualRadius = this.radius
}
if (this.height == null) {
this.height = this.visualRadius ?? this.radius
}
this.#calculateCollider()
}
get attacking() { return this.#attacking }
get bbox() { return this.#bbox }
get collision() { return this.#collision }
get destination() { return this.#dest }
get entitiesInVision() { return this.#entitiesInVision }
get game() { return this.#game }
get ghostable() { return this.#ghostable }
get logic() { return this.#logic }
get pathfindingCooldown() { return this.#pathfindingCooldown }
get pathfindingObstacleLimit() { return this.#pathfindingObstacleLimit }
get projectilesInVision() { return this.#projectilesInVision }
get spawnPosition() { return this.#spawnPosition }
get x() { return this.position.x }
get y() { return this.position.y }
set bbox(value) { this.#bbox = value }
set collision(value) { this.#collision = value }
set destination(value) { this.#dest = value }
set game(value) { this.#game = value }
set ghostable(value) { this.#ghostable = value }
set logic(value) { this.#logic = value }
set pathfindingCooldown(value) { this.#pathfindingCooldown = value }
set pathfindingObstacleLimit(value) { this.#pathfindingObstacleLimit = value }
set spawnPosition(value) { this.#spawnPosition = value }
set x(value) { this.position.x = value }
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 }
if (this.casting != null) {
const abilityBeingCasted = this.game?.abilities.find((it) => it.id == this.casting.ability)
if (abilityBeingCasted != null && abilityBeingCasted.id == ability.id) {
return false
}
return false
}
if (halt) {
this.#moving = false
}
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 cooldown = this.game?.secToTick(ability.cooldown) ?? 0
const lastCast = this.cooldowns[ability.id]
const timestamp = this.game?.currentTick ?? 0
if (lastCast != null && lastCast + cooldown > timestamp) {
return false
}
if (ability.castTime == null) {
this.#castingVision()
ability.effect(this, cursor)
return true
}
this.casting = { ability: ability.id, cursor, timestamp }
return true
}
haltAction() {
if (this.dead) { return }
this.#moving = false
}
moveAction(cursor, attack = false) {
if (this.dead) { return }
if (this.casting != null && this.game?.abilities.find((it) => it.id == this.casting.ability)?.moveCancelable) {
if (!attack && !(this.casting != null && this.casting.ability == this.abilities[0])) {
this.casting = null
}
}
this.#attacking = attack
this.#moving = true
this.#dest = cursor.clone()
}
stopAction() {
if (this.dead) { return }
this.casting = null
this.#moving = true
this.#attacking = false
}
// --- Actions above --- //
ability(slot) {
if (this.abilities[slot] != null) {
return this.game?.abilities.find((it) => it.id == this.abilities[slot])
}
}
adjustWaypoint(waypoint, direction) {
return SATX.clamp(
waypoint.clone().add(direction.clone().multiplyScalar(this.radius)),
this.game?.width,
this.game?.height,
this.radius,
)
}
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 < 0) {
const entityBuff = { id, source, timestamp }
if (buff.shield != null) {
entityBuff.shield = buff.shield
}
this.buffs.push(entityBuff)
}
else {
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
}
}
}
collidables() {
return this.customBboxCollidables(this.bbox)
}
collider() {
return this.#colliders.at(0)
}
colliders() {
return this.#colliders
}
cooldown(id) {
this.cooldowns[id] = this.game?.currentTick ?? 0
}
closestTargetTo(cursor, range, targetAllies = false) {
const entities = this.game?.entities
if (entities == null) { return }
const targetsInRange = targetAllies
? entities.filter((it) => !it.dead && this.team == it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
: entities.filter((it) => !it.dead && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
if (targetsInRange.length < 1) { return }
const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
const entityIdsInDirectVision = this.#entitiesInVision
if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) {
return absoluteClosestTarget
}
const visibleEntityIds = this.visibleEntities()
const visibleEntitiesInRange = targetsInRange.filter((it) => visibleEntityIds.includes(it.id))
return visibleEntitiesInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
}
customBboxCollidables(bbox) {
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
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 customMultipliers = 0
if (this.hasBuff(Buff.exposed.id)) {
const buff = this.getBuff(Buff.exposed.id)
if (buff.source == source?.id) {
customMultipliers += (buff.onHitMultiplier - 1)
this.removeBuff(Buff.exposed.id)
}
}
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
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)
}
despawn() {
this.game?.despawn(this)
}
distanceTo(cursor) {
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) {
this.#castingVision()
ability.effect(this, cursor)
}
else {
this.casting = { ability: ability.id, cursor, timestamp }
}
}
futureCollidables(futurePosition) {
return this.customBboxCollidables(new Float32Array([
futurePosition.y + this.radius,
futurePosition.x + this.radius,
futurePosition.y - this.radius,
futurePosition.x - this.radius,
]))
}
getBuff(id) {
if (this.dead) { return }
const entityBuff = this.buffs.find((it) => it.id == id)
if (entityBuff == null) { return }
const buffDefinition = this.game?.buffs.find((it) => it.id == entityBuff.id)
if (buffDefinition == null) { return }
return { ...buffDefinition, ...entityBuff }
}
getBuffs() {
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
}
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, _source) {
if (this.dead) { return }
this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth)
}
fixPosition() {
const fixedPosition = this.fixFuturePosition(this.position)
if (this.position.equals(fixedPosition)) { return }
this.setPosition(fixedPosition)
}
fixFuturePosition(futurePosition) {
const maxX = this.game?.width ?? Infinity
const maxY = this.game?.height ?? Infinity
const radius = this.radius
if (!this.willCollide(futurePosition)) {
return SATX.clamp(futurePosition, maxX, maxY, radius)
}
let direction = new Vector2(0, 5)
let multiplier = 1
const rotationSlices = 16
const origin = new Vector2()
for (let limit = 1; limit <= 10000; limit++) {
const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices
const offset = direction.clone().rotateAround(origin, rads).multiplyScalar(multiplier)
const position = SATX.clamp(futurePosition.clone().add(offset), maxX, maxY, radius)
if (!this.willCollide(position)) {
return position
}
if (limit % rotationSlices == 0) {
multiplier++
}
}
console.error({ error: 'position_unfixable', id: this.id, futurePosition })
}
isColliding() {
const collidables = this.collidables()
if (collidables.length < 1) {
return false
}
const colliders = collidables.map((it) => it.colliders()).flat()
const collider = this.collider()
return colliders.some((it) => SATX.collideObject(collider, it))
}
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.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()
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
return !colliders.some((it) => SATX.collideObject(collider, it))
}
isInLineOfVision(destination) {
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
const terrains = this.game?.terrains ?? []
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true }
const 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)
return !colliders.some((it) => SATX.collideObject(collider, it))
}
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.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)
return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it)))
}
removeBuff(id) {
if (this.dead) { return }
this.buffs = this.buffs.filter((it) => it.id != id)
}
respawn() {
this.setPosition(this.#spawnPosition)
this.health = this.maxHealth
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()
}
teleport(cursor) {
this.setPosition(this.fixFuturePosition(cursor))
}
unadjustedWaypoints() {
const numberOfWaypoints = 8
const margin = 1
const enclosingRegularPolygonRadius = SATX.enclosingRegularPolygonRadius(numberOfWaypoints)
const radius = this.radius * enclosingRegularPolygonRadius + margin
const baseWaypoint = new Vector2(radius, 0)
const waypoints = []
const origin = new Vector2
const unitOfRotation = (Math.PI * 2 / numberOfWaypoints)
for (let i = 0; i < numberOfWaypoints; i++) {
waypoints.push(baseWaypoint.clone().rotateAround(origin, unitOfRotation * i))
}
return waypoints.map((w) => [
w.clone().add(this.position),
w.clone().normalize().multiplyScalar(enclosingRegularPolygonRadius),
])
}
update() {
this.#calculateVision()
this.#checkHealth()
if (!this.dead) {
this.#cast()
this.#move()
this.#tickBuffs()
this.fixPosition()
}
if (this.#logic != null) {
this.#logic()
}
}
visibleEntities() {
return this.game?.visibleEntities(this.team)
}
willCollide(futurePosition) {
const collidables = this.futureCollidables(futurePosition)
if (collidables.length < 1) {
return false
}
const colliders = collidables.map((it) => it.colliders()).flat()
const collider = Entity.collider(futurePosition.x, futurePosition.y, this.radius)
return colliders.some((it) => SATX.collideObject(collider, it))
}
#calculateBbox() {
this.bbox[0] = this.position.y + this.radius
this.bbox[1] = this.position.x + this.radius
this.bbox[2] = this.position.y - this.radius
this.bbox[3] = this.position.x - this.radius
}
#calculateCollider() {
this.#calculateBbox()
this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)]
}
#calculateVision() {
if (this.dead) {
this.#entitiesInVision = [this.id]
this.#projectilesInVision = []
return
}
const entities = this.game?.entities ?? []
const projectiles = this.game?.projectiles ?? []
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
}
#cast() {
if (this.casting == null) {
return false
}
const ability = this.game?.abilities.find((it) => it.id == this.casting.ability)
if (ability == null) {
return
}
const castTime = this.game?.secToTick(ability.castTime) ?? 0
const castStart = this.casting.timestamp
const timestamp = this.game?.currentTick ?? 0
if (castStart + castTime > timestamp) {
return false
}
ability.effect(this, this.casting.cursor)
if (this.casting.ability == ability.id) {
this.casting = null
}
this.#castingVision()
return true
}
#castingVision() {
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null)
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
if (duration <= 0) { return }
const currentTick = this.game?.currentTick ?? 0
const despawnAfter = currentTick + duration
const castingVisionLogic = function castingVisionLogic(projectile) {
const currentTick = projectile.game?.currentTick ?? 0
if (currentTick > despawnAfter) {
projectile.despawn()
}
}
const projectile = new Projectile({
logic: castingVisionLogic,
owner: this.id,
position: this.position.clone(),
visionRange: radius,
team: enemyTeam,
})
this.game?.spawnProjectile(projectile)
}
}
#checkHealth() {
if (!this.dead && this.health <= 0) {
this.dead = true
this.buffs = []
}
else if (this.dead && this.health > 0) {
this.health = 0
}
}
#move(distanceTraveled = 0) {
if (this.casting != null) { return false }
const currentTick = this.game?.currentTick ?? 0
if (this.#attacking) {
const cursor = this.#dest ?? this.position
const basicAttack = this.ability('a')
if (basicAttack != null) {
const target = this.closestTargetTo(cursor, 500)
if (target != null && this.distanceTo(target.position) < basicAttack.range + this.radius + target.radius) {
const cooldown = this.game?.secToTick(basicAttack.cooldown) ?? 0
const lastCast = this.cooldowns[basicAttack.id]
if (lastCast != null && lastCast + cooldown > currentTick) { return false }
this.castAction('a', target.id, false)
return true
}
}
}
if (!this.#moving || this.#dest == null) { return false }
const fixedDest = this.fixFuturePosition(this.#dest)
const pathfinding = this.#noPathfindingUntil <= currentTick
const obstacles = new Map()
let pathGotObstructed = false
if (pathfinding && this.#path.length > 0) {
const sectionDest = this.#path.at(0)
const sectionObstacles = this.obstaclesInStraightPath(sectionDest)
if (sectionObstacles.length > 0) {
pathGotObstructed = true
for (const obstacle of sectionObstacles) {
if (!obstacles.has(obstacle.id)) {
obstacles.set(obstacle.id, obstacle)
}
}
}
}
if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) {
const lineOfSight = this.isInLineOfSight(fixedDest)
if (lineOfSight) {
this.#path = [fixedDest]
}
}
if (pathfinding && (pathGotObstructed || this.#path.length < 1 || (this.#path.at(-1)?.distanceTo(fixedDest) ?? 0) > 0.01)) {
const start = SATX.vectorToFloat32Array(this.position)
const goal = SATX.vectorToFloat32Array(fixedDest)
const obstacleWaypoints = new Map()
const obstacleColliders = new Map()
const obstacleBboxes = new Map()
const initialObstaclesMargin = this.radius + 20
const initialObstacles = this.customBboxCollidables(new Float32Array([
this.position.y + initialObstaclesMargin,
this.position.x + initialObstaclesMargin,
this.position.y - initialObstaclesMargin,
this.position.x - initialObstaclesMargin,
]))
for (const obstacle of initialObstacles) {
if (!obstacles.has(obstacle.id)) {
obstacles.set(obstacle.id, obstacle)
}
}
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
const obstaclesArray = Array.from(obstacles.values())
for (const obstacle of obstaclesArray) {
if (!obstacleWaypoints.has(obstacle.id)) {
const waypoint = obstacle.unadjustedWaypoints().map(([w, d]) => SATX.vectorToFloat32Array(this.adjustWaypoint(w, d)))
const bbox = obstacle.bbox
const colliders = obstacle.colliders()
obstacleWaypoints.set(obstacle.id, waypoint)
obstacleColliders.set(obstacle.id, colliders)
obstacleBboxes.set(obstacle.id, bbox)
}
}
const waypoints = [
start,
goal,
...Array.from(obstacleWaypoints.values()).flat()
]
const bboxesSize = obstacleBboxes.size * 5
const bboxes = new Float32Array(bboxesSize)
let i = 0
for (const obstacle of obstacleBboxes.values()) {
bboxes[i] = obstacle[0]
bboxes[i + 1] = obstacle[1]
bboxes[i + 2] = obstacle[2]
bboxes[i + 3] = obstacle[3]
bboxes[i + 4] = Math.floor(i / 5)
i += 5
}
const colliders = Array.from(obstacleColliders.values())
const graph = Pathfind.buildGraph(waypoints, bboxes, colliders, this.radius)
const path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1]))
if (path.length == 0) {
// WARNING: This unsets the destination because if an unreachable spot is clicked,
// pathfinding cycles all obstacles forever. A possible alternative could
// be setting a pathfinding timeout, but then moveAction must reset that!
this.#dest = null
break
}
let obstacleInPath = false
let lastSection = this.position
for (const section of path) {
const sectionObstacles = this.obstaclesInStraightPath(section, lastSection)
if (sectionObstacles.length > 0) {
obstacleInPath = true
for (const obstacle of sectionObstacles) {
if (!obstacles.has(obstacle.id)) {
obstacles.set(obstacle.id, obstacle)
}
}
}
lastSection = section
}
this.#path = path
if (!obstacleInPath) {
break
}
}
}
if (pathfinding && this.pathfindingCooldown > 0) {
this.#noPathfindingUntil = currentTick + (this.game?.secToTick(this.pathfindingCooldown) ?? 0)
}
if (this.#path.length > 0) {
const speed = (this.speed / (this.game?.tickRate ?? 1)) - distanceTraveled
const destination = this.#path.at(0)
const difference = destination.clone().sub(this.position)
const distance = difference.length()
const direction = difference.clone().normalize()
const stepTaken = this.position.clone().add(direction.multiplyScalar(speed))
const position = distance <= speed ? destination : stepTaken
const rotation = direction.angle()
this.rotation = rotation
if (!this.willCollide(position)) {
this.setPosition(position)
}
if (this.position.equals(destination)) {
this.#path = this.#path.slice(1)
if (this.#path.length > 0) {
this.#move(distance)
}
else {
this.#dest = null
this.#moving = false
}
}
}
}
#tickBuff(index) {
if (this.buffs[index] == null) { return }
const buff = this.getBuff(this.buffs[index].id)
const duration = this.game?.secToTick(buff.duration) ?? 0
const currentTick = this.game?.currentTick ?? 0
if (buff.timestamp + duration < currentTick) {
this.removeBuff(buff.id)
}
}
#tickBuffs() {
this.buffs.forEach((_v, i) => this.#tickBuff(i))
}
}
+212
View File
@@ -0,0 +1,212 @@
import { EventEmitter } from 'node:events'
import { Vector2 } from 'three'
import Ability from './ability.js'
import Buff from './buff.js'
import Entity from './entity.js'
import Projectile from './projectile.js'
import Terrain from './terrain.js'
export default class Game {
id = crypto.randomUUID()
abilities = Object.values({...Ability})
buffs = Object.values({...Buff})
currentTick = 0
entities = []
height = 0
projectiles = []
terrains = []
tickRate = 30
width = 0
#gameLoopIntervalId = null
#logic = null
#nextTickAt = 0
#startTimestamp = 0
#subscriptions = new Map()
#tickBudget = 1000 / this.tickRate
get logic() { return this.#logic }
get tickBudget() { return this.#tickBudget }
get subscriptions() { return this.#subscriptions }
set logic(value) { this.#logic = value }
action(id, options) {
const entity = this.entities.find((it) => it.id == id)
if (entity == null) {
console.info({ info: 'action_invalid_id', id, options })
return
}
if (options.action == 'attack') { entity.attackAction(new Vector2(options.x, options.y)) }
if (options.action == 'cast') { entity.castAction(options.slot, new Vector2(options.x, options.y)) }
if (options.action == 'halt') { entity.haltAction() }
if (options.action == 'stop') { entity.stopAction() }
if (options.action == 'move') { entity.moveAction(new Vector2(options.x, options.y)) }
}
addTerrain(terrain) {
this.terrains.push(terrain)
}
despawn(object) {
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: 'despawn_unknown_object', object }) }
}
despawnEntity(entity) {
this.entities = this.entities.filter((e) => e.id != entity.id)
entity.game = null
}
despawnProjectile(projectile) {
this.projectiles = this.projectiles.filter((p) => p.id != projectile.id)
projectile.game = null
}
joinReport() {
return {
id: this.id,
height: this.height,
width: this.width,
currentTick: this.currentTick,
abilities: this.abilities,
buffs: this.buffs,
terrains: this.terrains,
tickRate: this.tickRate,
}
}
removeTerrain(terrain) {
this.terrains = this.terrains.filter((t) => t.id != terrain.id)
}
secToTick(sec) {
return Math.floor(this.tickRate * sec)
}
spawn(object) {
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: 'spawn_unknown_object', object }) }
}
spawnEntity(entity) {
this.entities.push(entity)
entity.game = this
}
spawnProjectile(projectile) {
this.projectiles.push(projectile)
projectile.game = this
}
start() {
if (this.#gameLoopIntervalId != null) { return }
this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
console.info({ event: 'game_start', id: this.id, tickRate: this.tickRate, currentTick: this.currentTick })
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
}
stop() {
if (this.#gameLoopIntervalId == null) { return }
clearInterval(this.#gameLoopIntervalId)
this.#gameLoopIntervalId = null
console.info({ event: 'game_stop', id: this.id, currentTick: this.currentTick })
}
subscription(websocket, id) {
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) {
websocket.close()
return
}
const team = entity.team
const state = game.visionByTeam(team)
state.currentTick = game.currentTick
websocket.send(JSON.stringify(state))
}
}
update() {
for (const subscription of this.#subscriptions.values()) {
subscription()
}
const callUpdate = function callUpdate(object) { object.update() }
this.entities.forEach(callUpdate)
this.projectiles.forEach(callUpdate)
if (this.#logic != null) {
this.#logic()
}
this.currentTick++
}
visibleEntities(team) {
const visionSources = this.visionSources(team)
return Array.from(new Set(visionSources.map((it) => it.entitiesInVision).flat()))
}
visibleProjectiles(team) {
const visionSources = this.visionSources(team)
return Array.from(new Set(visionSources.map((it) => it.projectilesInVision).flat()))
}
visionSources(team) {
const entityVisionSources = this.entities.filter((it) => it.team == team)
const projectileVisionSources = this.projectiles.filter((it) => it.visionRange > 0 && (it.team == null || it.team == team))
return entityVisionSources.concat(projectileVisionSources)
}
visionByTeam(team) {
const visionSources = this.visionSources(team)
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat())
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat())
return {
entities: this.entities.filter((it) => visibleEntities.has(it.id)),
projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)),
}
}
#gameLoop() {
if (this.#nextTickAt != null) {
const tickBudget = this.#tickBudget
const nextTickAt = this.#nextTickAt
this.#nextTickAt = null
let start = 0
while (start < nextTickAt) { start = performance.now() }
const before = performance.now()
this.update()
const after = performance.now()
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 (tickTaken > tickBudget) {
console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
}
}
}
#gameLoopCall() {
this.#gameLoop()
}
}
+70
View File
@@ -0,0 +1,70 @@
import * as LEVEL from './level.js'
import { WebSocketExpress } from 'websocket-express'
import express from 'express'
import Game from './game.js'
import os from 'node:os'
try {
// WARNING: process.nice can undermine dependencies?
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
}
catch (error) {
console.warn({ warn: 'process_priority_unadjustable' })
}
const app = new WebSocketExpress()
const port = 1280
const game = new Game()
app.use(express.urlencoded({ extended: true }))
app.use('/three/', express.static('node_modules/three'))
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) => {
const websocket = await res.accept()
websocket.on('message', (rawData) => {
const message = JSON.parse(rawData)
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.info({ event: 'disconnected', id })
game.subscriptions.delete(connectionId)
})
return
}
game.action(message.id, message)
})
})
app.listen(port, () => {
console.info({ event: 'startup', visit: `http://localhost:${port}/menu/` })
LEVEL.Chase.scenario(game)
game.start()
})
+2293
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
import Entity from './entity.js'
import PriorityQueue from './priority-queue.js'
import SATX from './satx.js'
export default class Pathfind {
static precision = 0.01
static multiplier = 1000000 // (1 / this.precision) * 10^expected_digit_count / 10
static key2(a, b) {
return `${a},${b}`
}
// Fowler-Noll-Vo hash prime and offset basis for small keyspaces
static floatKey4(a, b, c, d) {
const prime = 16777619
let result = 2166136261
result ^= Math.floor(a * Pathfind.multiplier)
result *= prime
result ^= Math.floor(b * Pathfind.multiplier)
result *= prime
result ^= Math.floor(c * Pathfind.multiplier)
result *= prime
result ^= Math.floor(d * Pathfind.multiplier)
result *= prime
return result
}
static uniqueWaypoints(waypoints) {
const included = new Set()
const uniqueWaypoints = []
for (const waypoint of waypoints) {
const key = Pathfind.key2(waypoint[0], waypoint[1])
if (!included.has(key)) {
included.add(key)
uniqueWaypoints.push(waypoint)
}
}
return uniqueWaypoints
}
static shortestPath(graph, start, goal) {
const queue = new PriorityQueue((a, b) => a[1] < b[1])
const visited = new Map()
queue.push([[start], 0])
while (!queue.isEmpty()) {
const [path, cost] = queue.pop()
const waypoint = path.at(-1)
if (Math.abs(waypoint[0] - goal[0]) < Pathfind.precision && Math.abs(waypoint[1] - goal[1]) < Pathfind.precision) {
path.shift()
return path
}
const waypointKey = Pathfind.key2(waypoint[0], waypoint[1])
if (!visited.has(waypointKey) || visited.get(waypointKey) > cost) {
visited.set(waypointKey, cost)
for (let i = 0; i < graph.length; i += 5) {
if (Math.abs(waypoint[0] - graph[i]) > Pathfind.precision || Math.abs(waypoint[1] - graph[i + 1]) > Pathfind.precision) {
continue // waypoint and graph.from aren't the same (so graph.to isn't a neighbor)
}
const nextKey = Pathfind.key2(graph[i + 2], graph[i + 3])
if (!visited.has(nextKey) || visited.get(nextKey) > cost + graph[i + 4]) {
const next = new Float32Array(2)
next[0] = graph[i + 2]
next[1] = graph[i + 3]
queue.push([[...path, next], cost + graph[i + 4]])
}
}
}
}
return []
}
static buildGraph(waypoints, bboxes, obstacles, radius) {
const filteredWaypoints = []
const checked = new Set()
if (radius > 0) {
for (const waypoint of waypoints) {
const bbox = Entity.bbox(waypoint[0], waypoint[1], radius)
const bboxCheckedObstacles = []
for (let i = 0; i < bboxes.length; i += 5) {
if (bbox[0] <= bboxes[i + 2]) { continue }
if (bbox[1] <= bboxes[i + 3]) { continue }
if (bbox[2] >= bboxes[i]) { continue }
if (bbox[3] >= bboxes[i + 1]) { continue }
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
}
if (bboxCheckedObstacles.length > 0) {
const collider = Entity.collider(waypoint[0], waypoint[1], radius)
const colliding = bboxCheckedObstacles.flat().some((it) => SATX.collideObject(collider, it))
if (colliding) {
continue
}
}
filteredWaypoints.push(waypoint)
}
}
const mergedWaypoints = new Float32Array(filteredWaypoints.length * 2)
let mergedWaypointsIndex = 0
for (const waypoint of filteredWaypoints) {
mergedWaypoints[mergedWaypointsIndex] = waypoint[0]
mergedWaypoints[mergedWaypointsIndex + 1] = waypoint[1]
mergedWaypointsIndex += 2
}
const nodes = []
for (let i = 0; i < mergedWaypoints.length; i += 2) {
for (let j = 0; j < mergedWaypoints.length; j += 2) {
if (i == j) {
continue
}
if (Math.abs(mergedWaypoints[i] - mergedWaypoints[j]) < Pathfind.precision && Math.abs(mergedWaypoints[i + 1] - mergedWaypoints[j + 1]) < Pathfind.precision) {
continue
}
const key = Pathfind.floatKey4(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1])
if (checked.has(key)) {
continue
}
checked.add(key)
checked.add(Pathfind.floatKey4(mergedWaypoints[j], mergedWaypoints[j + 1], mergedWaypoints[i], mergedWaypoints[i + 1]))
const bbox = Entity.tunnelBbox(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
const bboxCheckedObstacles = []
for (let i = 0; i < bboxes.length; i += 5) {
if (bbox[0] <= bboxes[i + 2]) { continue }
if (bbox[1] <= bboxes[i + 3]) { continue }
if (bbox[2] >= bboxes[i]) { continue }
if (bbox[3] >= bboxes[i + 1]) { continue }
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
}
if (bboxCheckedObstacles.length > 0) {
const tunnel = Entity.tunnelCollider(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
const colliding = bboxCheckedObstacles.some((it) => it.some((c) => SATX.collideObject(tunnel, c)))
if (colliding) {
continue
}
}
const node = new Float32Array(5)
node[0] = mergedWaypoints[i]
node[1] = mergedWaypoints[i + 1]
node[2] = mergedWaypoints[j]
node[3] = mergedWaypoints[j + 1]
node[4] = Math.hypot(mergedWaypoints[j] - mergedWaypoints[i], mergedWaypoints[j + 1] - mergedWaypoints[i + 1])
nodes.push(node)
const reverseNode = new Float32Array(5)
reverseNode[0] = mergedWaypoints[j]
reverseNode[1] = mergedWaypoints[j + 1]
reverseNode[2] = mergedWaypoints[i]
reverseNode[3] = mergedWaypoints[i + 1]
reverseNode[4] = node[4] // distance is the same, copying is less expensive
nodes.push(reverseNode)
}
}
const graph = new Float32Array(nodes.length * 5)
let graphIndex = 0
for (const node of nodes) {
graph[graphIndex] = node[0]
graph[graphIndex + 1] = node[1]
graph[graphIndex + 2] = node[2]
graph[graphIndex + 3] = node[3]
graph[graphIndex + 4] = node[4]
graphIndex += 5
}
return graph
}
static formatFloat32Array(array, columns = 2, text = false) {
const formatted = []
let columnWidth = 0
for (let i = 0; i < array.length; i += columns) {
const row = []
for (let j = i; j < i + columns; j++) {
if (text) {
row.push(`${array[j]}`)
if (`${array[j]}`.length > columnWidth) {
columnWidth = `${array[j]}`.length
}
}
else {
row.push(array[j])
}
}
formatted.push(row)
}
if (text) {
return formatted.map((row) => row.map((v) => v.padEnd(columnWidth, ' ')).join(' | ')).join('\n')
}
return formatted
}
}
+78
View File
@@ -0,0 +1,78 @@
const top = 0;
const parent = i => ((i + 1) >>> 1) - 1;
const left = i => (i << 1) + 1;
const right = i => (i + 1) << 1;
export default class PriorityQueue {
#heap
#comparator
constructor(comparator = (a, b) => a > b) {
this.#heap = []
this.#comparator = comparator
}
get length() { return this.#heap.length }
isEmpty() {
return this.length < 1
}
peek() {
return this.#heap[top]
}
push(...values) {
values.forEach(value => {
this.#heap.push(value)
this.#siftUp();
});
return this.length;
}
pop() {
const poppedValue = this.peek()
const bottom = this.length - 1
if (bottom > top) {
this.#swap(top, bottom)
}
this.#heap.pop()
this.#siftDown()
return poppedValue
}
replace(value) {
const replacedValue = this.peek()
this.#heap[top] = value
this.#siftDown()
return replacedValue
}
#greater(i, j) {
return this.#comparator(this.#heap[i], this.#heap[j])
}
#swap(i, j) {
[this.#heap[i], this.#heap[j]] = [this.#heap[j], this.#heap[i]]
}
#siftUp() {
let node = this.length - 1
while (node > top && this.#greater(node, parent(node))) {
this.#swap(node, parent(node))
node = parent(node)
}
}
#siftDown() {
let node = top;
while (
(left(node) < this.length && this.#greater(left(node), node)) ||
(right(node) < this.length && this.#greater(right(node), node))
) {
let maxChild = (right(node) < this.length && this.#greater(right(node), left(node))) ? right(node) : left(node)
this.#swap(node, maxChild)
node = maxChild
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import { Vector2 } from 'three'
import Entity from './entity.js'
import SAT from 'sat'
import SATX from './satx.js'
export default class Projectile {
id = `projectile-${Projectile.nextId()}`
static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0
height = 50
owner = null
position = new Vector2()
radius = 0
speed = 1000
team = null
visibleThroughTerrain = true
visionRange = 0
visualRadius = null
#after = null
#bbox = new Float32Array(4)
#dest = null
#entitiesInVision = []
#game = null
#homingTarget = null
#logic = null
#onCollide = null
#projectilesInVision = []
get after() { return this.#after }
get bbox() { return this.#bbox }
get entitiesInVision() { return this.#entitiesInVision }
get game() { return this.#game }
get homingTarget() { return this.#homingTarget }
get logic() { return this.#logic }
get onCollide() { return this.#onCollide }
get projectilesInVision() { return this.#projectilesInVision }
set after(value) { this.#after = value }
set bbox(value) { this.#bbox = value }
set destination(value) { this.#dest = value }
set game(value) { this.#game = value }
set homingTarget(value) { this.#homingTarget = value }
set logic(value) { this.#logic = value }
set onCollide(value) { this.#onCollide = value }
get destination() {
return this.#dest ?? this.#homingTarget?.position
}
constructor(options = {}) {
Object.entries(options).forEach(([key, value]) => this[key] = value)
if (this.visualRadius == null) {
this.visualRadius = this.radius
}
}
collider() {
return new SAT.Circle(new SAT.Vector(this.position.x, this.position.y), this.radius)
}
despawn() {
this.game?.despawn(this)
}
isInLineOfVision(destination) {
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
const terrains = this.game?.terrains ?? []
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length < 1) { return true }
const posCollider = Entity.collider(this.position.x, this.position.y, 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 colliders = unpassableTerrain.map((it) => it.colliders()).flat()
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
return !colliders.some((it) => SATX.collideObject(collider, it))
}
setPosition(vector) {
this.position.copy(vector)
this.#calculateBbox()
}
update() {
this.#calculateVision()
this.#move()
this.#checkStationaryCollisions()
this.#checkIfArrived()
if (this.#logic != null) {
this.#logic(this)
}
}
#calculateBbox() {
this.bbox[0] = this.position.y + this.radius
this.bbox[1] = this.position.x + this.radius
this.bbox[2] = this.position.y - this.radius
this.bbox[3] = this.position.x - this.radius
}
#calculateVision() {
const entities = this.game?.entities ?? []
const projectiles = this.game?.projectiles ?? []
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
}
#checkIfArrived() {
if (this.destination == null) { return }
if (!this.position.equals(this.destination)) { return }
if (this.#after != null) {
this.#after(this, this.#homingTarget)
}
if (this.destination == null) { return }
if (!this.position.equals(this.destination)) { return }
this.despawn()
}
#checkStationaryCollisions() {
if (this.#onCollide == null) { return }
const bbox = this.bbox
const entitiesAndTerrains = this.game?.entities ?? []
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length > 0) {
const collider = this.collider()
const colliding = bboxCheckedObstacles.filter((it) => it.colliders().some((c) => SATX.collideObject(collider, c)))
colliding.forEach((it) => this.#onCollide(this, it))
}
}
#move() {
if (this.destination == null) { return }
const speed = (this.speed / (this.game?.tickRate ?? 1))
const prevPos = this.position.clone()
if (this.position.distanceTo(this.destination) < speed) {
this.setPosition(this.destination)
}
else {
const step = this.destination.clone().sub(this.position).normalize().multiplyScalar(speed)
this.position.add(step)
}
if (this.#onCollide != null) {
const bbox = Entity.tunnelBbox(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius)
const entitiesAndTerrains = this.game?.entities ?? []
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && SATX.bboxCheck(bbox, it.bbox))
if (bboxCheckedObstacles.length > 0) {
const collider = Entity.tunnelCollider(prevPos.x, prevPos.y, this.position.x, this.position.y, this.radius)
const colliding = bboxCheckedObstacles.filter((it) => it.colliders().some((c) => SATX.collideObject(collider, c)))
colliding.sort((a, b) => a.distanceTo(prevPos) > b.distanceTo(prevPos)).forEach((it) => this.#onCollide(this, it))
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { Vector2 } from 'three'
import SAT from 'sat'
export default class SATX {
static bboxCheck(bbox1, bbox2) {
if (bbox1[0] <= bbox2[2]) { return false }
if (bbox1[1] <= bbox2[3]) { return false }
if (bbox1[2] >= bbox2[0]) { return false }
if (bbox1[3] >= bbox2[1]) { return false }
return true
}
static clamp(vectorOrObject, maxX = Infinity, maxY = Infinity, radius = 0) {
let modified = null
if (vectorOrObject instanceof Vector2) {
modified = vectorOrObject.clone()
}
else if (vectorOrObject instanceof SAT.Vector) {
modified = new SAT.Vector(vectorOrObject.x, vectorOrObject.y)
}
else {
modified = { x: vectorOrObject.x, y: vectorOrObject.y }
}
modified.x = Math.min(Math.max(radius, vectorOrObject.x), (maxX ?? Infinity) - radius)
modified.y = Math.min(Math.max(radius, vectorOrObject.y), (maxY ?? Infinity) - radius)
return modified
}
static collideObject(collider1, collider2, result = null) {
if (collider1 instanceof SAT.Circle && collider2 instanceof SAT.Circle) {
return SAT.testCircleCircle(collider1, collider2, result)
}
if (collider1 instanceof SAT.Circle && collider2 instanceof SAT.Polygon) {
return SAT.testCirclePolygon(collider1, collider2, result)
}
if (collider1 instanceof SAT.Polygon && collider2 instanceof SAT.Circle) {
return SAT.testPolygonCircle(collider1, collider2, result)
}
if (collider1 instanceof SAT.Polygon && collider2 instanceof SAT.Polygon) {
return SAT.testPolygonPolygon(collider1, collider2, result)
}
return false
}
static enclosingRegularPolygonRadius(numberOfVertices) {
return 1 / Math.cos(Math.PI / numberOfVertices)
}
static line(fromX, fromY, toX, toY) {
return new SAT.Polygon(new SAT.Vector(fromX, fromY), [new SAT.Vector(), new SAT.Vector(toX - fromX, toY - fromY)])
}
static satPolygonToVectors(polygon) {
const position = new Vector2(polygon.pos.x, polygon.pos.y)
return polygon.points.map((p) => new Vector2(p.x, p.y).add(position))
}
static vectorToFloat32Array(vector) {
const array = new Float32Array(2)
array[0] = vector.x
array[1] = vector.y
return array
}
static float32ArrayToVector(array) {
return new Vector2(array[0], array[1])
}
static float32ArrayWithIndexToVector(array, index) {
return new Vector2(array[index], array[index + 1])
}
}
+5
View File
@@ -0,0 +1,5 @@
export default class Team {
static neutral = 'neutral'
static blue = 'blue'
static red = 'red'
}
+147
View File
@@ -0,0 +1,147 @@
import { Vector2 } from 'three'
import Ability from './ability.js'
import Team from './team.js'
export default class Template {
static basilisk(overrides) {
return {
abilities: { a: Ability.rangedAttack.id },
logic: this.#basiliskLogic(),
maxHealth: 300,
model: 'models/generic-bam-placeholder.gltf',
radius: 180,
speed: 230,
...overrides,
}
}
static minion(team, options = {}) {
return {
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
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: options.ranged ? 36 : 38,
speed: 325,
team,
visionRange: 1200,
}
}
static player(overrides) {
return {
abilities: {
a: Ability.rangedAttack.id,
q: Ability.straightShot.id,
w: Ability.expose.id,
e: Ability.control.id,
r: Ability.shieldThrow.id,
d: Ability.circleOfResurrection.id,
f: Ability.blink.id,
},
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,
...overrides,
}
}
static #basiliskLogic() {
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
}
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) {
const checkpointSize = 300
const recalculateDestRadius = 50
const aggroRadius = 500
const memory = {}
return function builtMinionLogic() {
const entity = this
if (entity.dead) { entity.despawn() }
const currentTick = entity.game?.currentTick ?? 0
const minionResponseTime = Math.floor(0.1 * (entity.game?.tickRate ?? 1))
if (!(currentTick % minionResponseTime == 0 && Math.floor(currentTick / minionResponseTime) % 2 == (odd ? 1 : 0))) {
return
}
const target = entity.closestTargetTo(entity.position, aggroRadius)
if (target != null) {
entity.ghosting = false
entity.attackAction(target.position)
}
if ((route.length > 0 || entity.attacking) && target == null) {
const routeIndex = memory.routeCheckpoint ?? 0
const goal = route[routeIndex].clone()
if (goal instanceof Vector2) {
if (entity.distanceTo(goal) < checkpointSize) {
if (routeIndex + 1 < route.length) {
memory.routeCheckpoint = routeIndex + 1
}
}
if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) {
entity.ghosting = true
entity.moveAction(goal)
}
}
if (entity.position.equals(route.at(-1))) {
entity.despawn()
}
}
}
}
static #playerLogic() {
const entity = this
// if (entity.dead) {
// entity.respawn()
// }
}
}
+120
View File
@@ -0,0 +1,120 @@
import { Shape, ShapeUtils, Vector2 } from 'three'
import SAT from 'sat'
export default class Terrain {
id = `terrain-${Terrain.nextId()}`
static nextId() { return this.#nextUniqueId++ }
static #nextUniqueId = 0
bbox = new Float32Array(4)
collision = true
ghostable = false
position = new Vector2()
relativeVertices = []
#colliders = []
#vertices = []
#unadjustedWaypoints = []
constructor(vertices, collision = null) {
this.#vertices = vertices.map((v) => new Vector2(v.x, v.y))
if (ShapeUtils.isClockWise(this.#vertices)) {
this.#vertices.reverse()
}
if (collision != null) {
this.collision = collision
}
this.#calculateColliders()
this.#calculatePosition()
this.#calculateRelativeVertices()
this.#calculateUnadjustedWaypoints()
this.#calculateBbox()
}
get vertices() { return this.#vertices }
get dead() { return false }
static waypointsForSide(fromVertex, toVertex, isClockwise = false) {
const from = isClockwise ? toVertex : fromVertex
const to = isClockwise ? fromVertex : toVertex
const origin = new Vector2()
const sideNormal = to.clone().sub(from).clone().normalize()
const margin = sideNormal.clone().rotateAround(origin, -3 * Math.PI / 4)
const offset = margin.clone().multiplyScalar(Math.SQRT2)
const inverseMargin = sideNormal.clone().negate().rotateAround(origin, 3 * Math.PI / 4)
const inverseOffset = inverseMargin.clone().multiplyScalar(Math.SQRT2)
return [
[margin.clone().add(from), offset],
[inverseMargin.clone().add(to), inverseOffset],
]
}
colliders() { return this.#colliders }
unadjustedWaypoints() { return this.#unadjustedWaypoints }
#shape() {
const complexShape = new Shape()
complexShape.moveTo(this.#vertices.at(0).x, this.#vertices.at(0).y)
this.#vertices.slice(1).forEach((v) => complexShape.lineTo(v.x, v.y))
return complexShape
}
#calculateBbox() {
const firstVertex = this.vertices.at(0)
if (firstVertex != null) {
this.bbox[0] = firstVertex.y
this.bbox[1] = firstVertex.x
this.bbox[2] = firstVertex.y
this.bbox[3] = firstVertex.x
}
this.vertices.forEach((v) => {
if (v.y > this.bbox[0]) {
this.bbox[0] = v.y
}
if (v.x > this.bbox[1]) {
this.bbox[1] = v.x
}
if (v.y < this.bbox[2]) {
this.bbox[2] = v.y
}
if (v.x < this.bbox[3]) {
this.bbox[3] = v.x
}
})
}
#calculateColliders() {
const points = this.#shape().extractPoints(16)
const indicesToPolygon = (indices) => {
const satPoints = [
new SAT.Vector(...points.shape[indices[0]].toArray()),
new SAT.Vector(...points.shape[indices[1]].clone().sub(points.shape[indices[0]]).toArray()),
new SAT.Vector(...points.shape[indices[2]].clone().sub(points.shape[indices[0]]).toArray()),
]
return new SAT.Polygon(satPoints[0], [new SAT.Vector(), satPoints[1], satPoints[2]])
}
this.#colliders = ShapeUtils.triangulateShape(points.shape, points.holes).map(indicesToPolygon)
}
#calculatePosition() {
this.position = this.#vertices.reduce(((sum, v) => sum.add(v)), new Vector2()).divideScalar(this.#vertices.length)
}
#calculateRelativeVertices() {
this.relativeVertices = this.#vertices.map((v) => v.clone().sub(this.position))
}
#calculateUnadjustedWaypoints() {
this.#unadjustedWaypoints = this.#vertices.map((v, i, arr) => Terrain.waypointsForSide(v, i + 1 < arr.length ? arr[i + 1] : arr[0])).flat()
}
}
+23
View File
@@ -0,0 +1,23 @@
import WebSocket from 'ws'
const numberOfClients = 10
const url = 'ws://localhost:1280/ws'
for (let i = 1; i <= numberOfClients; i++) {
const id = `${i}`
const websocket = new WebSocket(url)
websocket.onerror = () => websocket.close()
websocket.onopen = () => {
websocket.send(JSON.stringify({ action: 'join', id }))
console.log({ client: id, event: 'joined' })
}
websocket.onclose = () => {
console.log({ client: id, event: 'disconnected' })
}
websocket.onmessage = (event) => {
const byteSize = new Blob([event.data]).size
// console.log({ client: id, received: `${byteSize} B of data` })
}
}
+92
View File
@@ -0,0 +1,92 @@
<html>
<head>
<title>Terrain Creator</title>
<style>
* {
box-sizing: border-box;
}
html, body {
background-color: black;
font-family: sans-serif;
}
html {
padding: 0;
}
body {
margin: 0;
}
#map {
background-color: white;
background-image: url('./background.png');
background-size: cover;
}
.point {
position: absolute;
border-radius: 50%;
margin-top: -5px;
margin-left: -5px;
width: 10px;
height: 10px;
background-color: red;
border: 1px solid white;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
var width = null
var height = null
var scale = null
var points = []
window.addEventListener('load', () => {
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
width = params.width
height = params.height
scale = params.scale
if (width == null) {
width = prompt('Width: ')
}
if (height == null) {
height = prompt('Height: ')
}
if (scale == null) {
scale = prompt('Scale: ')
}
const map = document.getElementById('map')
map.style.width = `${width / scale}px`
map.style.height = `${height / scale}px`
map.addEventListener('contextmenu', (event) => event.preventDefault())
map.addEventListener('mousedown', (event) => {
if (event.button == 2) {
console.log(`\n\n[\n` + points.map((p) => ` new Vector2(${p.x}, ${p.y}),`).join(`\n`) + `\n],\n`)
points = []
map.innerHTML = ''
return
}
if (event.button == 0) {
const x = Math.floor(event.pageX * scale)
const y = Math.floor(height - (event.pageY * scale))
points.push({ x, y })
const point = document.createElement('div')
point.classList.add('point')
point.style.left = event.pageX
point.style.top = event.pageY
map.appendChild(point)
return
}
})
})
</script>
</body>
</html>