Compare commits
77 Commits
master
..
55e5e8117c
| Author | SHA1 | Date | |
|---|---|---|---|
|
55e5e8117c
|
|||
|
15e72a9e10
|
|||
|
4acd7a2881
|
|||
|
afa419e77a
|
|||
|
441a73355e
|
|||
|
59b5a603a0
|
|||
|
4c76d5dbde
|
|||
|
0db1ceeedc
|
|||
|
4f8dcebcd1
|
|||
|
c4c7c921d7
|
|||
|
916bc31356
|
|||
|
fa2dbb5237
|
|||
|
8ce1a2266f
|
|||
|
6b8a220f39
|
|||
|
bf38f69071
|
|||
|
634dde2a3b
|
|||
|
e4f1fe19f4
|
|||
|
072204b902
|
|||
|
04cc3f951e
|
|||
|
e75c0d2944
|
|||
|
0a4853aff9
|
|||
|
0b949683a6
|
|||
|
7824ba976b
|
|||
|
8457312f63
|
|||
|
18c3ace616
|
|||
|
7415475cb0
|
|||
|
ed6394354e
|
|||
|
8ebae0d866
|
|||
|
b4162d4e39
|
|||
|
8e95bc141c
|
|||
|
9345c7af04
|
|||
|
80ccb92815
|
|||
|
a44693aa5d
|
|||
|
1a5e811020
|
|||
|
787b48a4df
|
|||
|
20f8a2f1fe
|
|||
|
597aa204de
|
|||
|
92e06dedce
|
|||
|
9d3fbda494
|
|||
|
ffbc4d9803
|
|||
|
16429a6e1b
|
|||
|
03bbea4862
|
|||
|
49a4d3e924
|
|||
|
ea23aa3174
|
|||
|
8e861929cb
|
|||
|
6ff950640c
|
|||
|
302d2f0618
|
|||
|
d9d62d7070
|
|||
|
d9849f770b
|
|||
|
e0dd7dcaf3
|
|||
|
2eb914a680
|
|||
|
51b61ab449
|
|||
|
957b09b878
|
|||
|
462dfe7b9a
|
|||
|
4aba510ec0
|
|||
|
f1c191f61f
|
|||
|
fe4dc8b8bc
|
|||
|
8fe48fb679
|
|||
|
0f8a73911f
|
|||
|
5acc827f7b
|
|||
|
2570f32592
|
|||
|
2a9ef691fe
|
|||
|
f48a6bf9aa
|
|||
|
3bb34ed012
|
|||
|
227cc1590a
|
|||
|
fb6e75e38c
|
|||
|
05360208b0
|
|||
|
47aade7b3f
|
|||
|
37a77e902c
|
|||
|
ae6f4c2847
|
|||
|
ba0d8f606a
|
|||
|
604368b52c
|
|||
|
e23978ea90
|
|||
|
054d22d01a
|
|||
|
14212afd70
|
|||
|
69343821b6
|
|||
|
2957903cb1
|
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
*Dockerfile*
|
||||||
|
*docker-compose*
|
||||||
|
node_modules
|
||||||
+136
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -1,5 +1,86 @@
|
|||||||
# Instructions Clear
|
# 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
|
||||||
|
|||||||
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
Binary file not shown.
|
After Width: | Height: | Size: 95 B |
Generated
+1075
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import { Tween } from '@tweenjs/tween.js'
|
||||||
|
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 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 bboxMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, transparent: true, opacity: 0.2 })
|
||||||
|
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 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 entities = {}
|
||||||
|
const projectiles = {}
|
||||||
|
const positionTweens = {}
|
||||||
|
const terrains = {}
|
||||||
|
var state = { abilities: [], entities: [], terrains: [], projectiles: [] }
|
||||||
|
|
||||||
|
global.entities = entities
|
||||||
|
global.projectiles = projectiles
|
||||||
|
global.terrains = terrains
|
||||||
|
global.state = state
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
stats.begin()
|
||||||
|
cameraMovement()
|
||||||
|
Object.values(positionTweens).forEach((tween) => tween.update()) // TODO: clean up tweens
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
stats.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimapRender() {
|
||||||
|
minimapRenderer.render(scene, minimapCamera)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cameraLocked = true
|
||||||
|
function followCamera() {
|
||||||
|
const entity = entities[playerId]
|
||||||
|
if (entity == null) { return }
|
||||||
|
|
||||||
|
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 cameraSpeed = 0.03
|
||||||
|
function cameraMovement() {
|
||||||
|
if (cameraLocked) {
|
||||||
|
followCamera()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.width != null && state.height != null && (ground.geometry.attributes.width != state.width || ground.geometry.attributes.height != state.height)) {
|
||||||
|
ground.geometry = new THREE.PlaneGeometry(state.width / 100, state.height / 100)
|
||||||
|
ground.position.set(state.width / 200, state.height / 200, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of Object.values(entities)) {
|
||||||
|
e.userData.flaggedForRemoval = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of state.entities ?? []) {
|
||||||
|
let entity
|
||||||
|
if (e.id in entities) {
|
||||||
|
entity = entities[e.id]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const entityMaterial = teamMaterials[e.team]
|
||||||
|
entity = new THREE.Mesh(new THREE.CylinderGeometry(e.visualRadius / 100, e.visualRadius / 100, e.height / 50), entityMaterial)
|
||||||
|
entity.rotation.x = Math.PI / 2
|
||||||
|
entity.userData.type = 'entity'
|
||||||
|
entity.userData.id = e.id
|
||||||
|
entity.position.set(e.position.x / 100, e.position.y / 100, e.height / 100)
|
||||||
|
scene.add(entity)
|
||||||
|
|
||||||
|
const hpMargin = 0.4
|
||||||
|
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 }))
|
||||||
|
maxHp.position.set(0, (e.height / 100) + hpMargin, 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((e.radius) / 100, (e.radius) / 100, 1), teamMaterial)
|
||||||
|
const teamMarkerSize = 4000
|
||||||
|
teamMarker.scale.y = e.height / teamMarkerSize
|
||||||
|
teamMarker.position.y = (e.height / (teamMarkerSize * 2)) - (e.height / 100)
|
||||||
|
teamMarker.position.y += 0.01
|
||||||
|
teamMarker.layers.set(1)
|
||||||
|
entity.add(teamMarker)
|
||||||
|
|
||||||
|
const buffMaterial = new THREE.MeshToonMaterial({ color: 0xffff00, transparent: true, opacity: 0.4 })
|
||||||
|
const buffMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.visualRadius + 10) / 100, (e.visualRadius + 10) / 100, 1), buffMaterial)
|
||||||
|
const buffMarkerSize = 400
|
||||||
|
buffMarker.scale.y = e.height / buffMarkerSize
|
||||||
|
buffMarker.layers.set(1)
|
||||||
|
buffMarker.visible = false
|
||||||
|
entity.add(buffMarker)
|
||||||
|
|
||||||
|
const rotationBase = new THREE.Object3D()
|
||||||
|
entity.add(rotationBase)
|
||||||
|
|
||||||
|
const castingMaterial = new THREE.MeshToonMaterial({ color: 0x10dde0, transparent: true, opacity: 0.4 })
|
||||||
|
const castingMarker = new THREE.Mesh(new THREE.CylinderGeometry((e.height * 0.9) / 100, (e.height * 0.9) / 100, 1), castingMaterial)
|
||||||
|
const castingMarkerSize = 800
|
||||||
|
castingMarker.rotation.z = Math.PI / 2
|
||||||
|
castingMarker.position.x = (e.radius) / 100
|
||||||
|
castingMarker.scale.y = e.height / castingMarkerSize
|
||||||
|
castingMarker.layers.set(1)
|
||||||
|
buffMarker.visible = false
|
||||||
|
rotationBase.add(castingMarker)
|
||||||
|
|
||||||
|
const rangeMaterial = teamMaterials['range']
|
||||||
|
// const rangeSize = e.visionRange ?? 0
|
||||||
|
const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius
|
||||||
|
const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry((rangeSize) / 100, (rangeSize) / 100, 1), rangeMaterial)
|
||||||
|
const rangeMarkerSize = 5000
|
||||||
|
rangeMarker.scale.y = e.height / rangeMarkerSize
|
||||||
|
rangeMarker.position.y = (e.height / (rangeMarkerSize * 2)) - (e.height / 100)
|
||||||
|
rangeMarker.layers.set(1)
|
||||||
|
rangeMarker.visible = false
|
||||||
|
entity.add(rangeMarker)
|
||||||
|
|
||||||
|
entities[e.id] = entity
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.children.at(0).visible = !e.dead
|
||||||
|
entity.children.at(1).visible = !e.dead
|
||||||
|
entity.children.at(2).visible = e.buffs.some((it) => it.id == 'exposed') // TODO: only works for Exposed now
|
||||||
|
|
||||||
|
let z = e.height / 100
|
||||||
|
|
||||||
|
if (e.dead) {
|
||||||
|
entity.rotation.x = 0
|
||||||
|
entity.position.z = 0
|
||||||
|
z = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
entity.rotation.x = Math.PI / 2
|
||||||
|
entity.position.z = e.height / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.userData.flaggedForRemoval = false
|
||||||
|
entity.children.at(3).rotation.y = e.rotation
|
||||||
|
positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z }, tweenDuration).start()
|
||||||
|
|
||||||
|
const hp = entity.children.at(0).children.at(0)
|
||||||
|
const percentageHp = e.health / e.maxHealth
|
||||||
|
hp.scale.x = percentageHp
|
||||||
|
hp.position.x = -(1 - percentageHp) / 2
|
||||||
|
|
||||||
|
// entity.children.at(4).visible = e.id == playerId
|
||||||
|
entity.children.at(3).children.at(0).visible = e.casting != null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of Object.values(entities)) {
|
||||||
|
if (e.userData.flaggedForRemoval) {
|
||||||
|
scene.remove(e)
|
||||||
|
delete entities[e.userData.id]
|
||||||
|
delete positionTweens[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
|
||||||
|
|
||||||
|
// // TODO: bboxes aren't tracked and can leak memory
|
||||||
|
// const bboxValues = Object.values(t.bbox)
|
||||||
|
// if (bboxValues.length >= 4) {
|
||||||
|
// const width = (bboxValues[1] - bboxValues[3]) / 100
|
||||||
|
// const height = (bboxValues[0] - bboxValues[2]) / 100
|
||||||
|
|
||||||
|
// const bbox = new THREE.Mesh(new THREE.BoxGeometry(width, height, 0.2), bboxMaterial)
|
||||||
|
// bbox.position.set((bboxValues[3] / 100) + (width / 2), (bboxValues[2] / 100) + (height / 2), 0)
|
||||||
|
// bbox.layers.set(1)
|
||||||
|
// scene.add(bbox)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
|
||||||
|
playerId = params.id
|
||||||
|
if (playerId == null) {
|
||||||
|
playerId = prompt('Player ID:')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<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;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buff-body {
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 5px;
|
||||||
|
background-color: black;
|
||||||
|
width: fit-content;
|
||||||
|
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>
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 365 B |
+320
@@ -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.heal(amount, caster)
|
||||||
|
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.heal(amount, caster)
|
||||||
|
caster.heal(amount, caster) // 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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
export default class Buff {
|
||||||
|
id = `ability-${Buff.nextId()}`
|
||||||
|
static nextId() { return this.#nextUniqueId++ }
|
||||||
|
static #nextUniqueId = 0
|
||||||
|
|
||||||
|
name = 'Buff'
|
||||||
|
|
||||||
|
damageMultiplier = null
|
||||||
|
duration = 0
|
||||||
|
|
||||||
|
#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,
|
||||||
|
})
|
||||||
|
}
|
||||||
+860
@@ -0,0 +1,860 @@
|
|||||||
|
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 = 40
|
||||||
|
maxHealth = 1
|
||||||
|
position = null
|
||||||
|
radius = 0
|
||||||
|
rotation = 0
|
||||||
|
speed = 400
|
||||||
|
team = Team.neutral
|
||||||
|
visionRange = 900
|
||||||
|
visualRadius = null
|
||||||
|
|
||||||
|
#collision = true
|
||||||
|
#ghostable = true
|
||||||
|
#attacking = false
|
||||||
|
#bbox = new Float32Array(4)
|
||||||
|
#colliders = []
|
||||||
|
#entitiesInVision = []
|
||||||
|
#projectilesInVision = []
|
||||||
|
#pathfindingCooldown = 0
|
||||||
|
#pathfindingObstacleLimit = null
|
||||||
|
#dest = null
|
||||||
|
#game = null
|
||||||
|
#logic = null
|
||||||
|
#moving = false
|
||||||
|
#path = []
|
||||||
|
#noPathfindingUntil = 0
|
||||||
|
#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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: buffer skill inputs
|
||||||
|
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 index = this.buffs.findIndex((it) => it.id == id)
|
||||||
|
const source = sourceId ?? this.id
|
||||||
|
const timestamp = this.game?.currentTick ?? 0
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
this.buffs[index].timestamp = timestamp
|
||||||
|
this.buffs[index].source = source
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.buffs.push({ id, source, timestamp })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add shielding logic
|
||||||
|
damage(amount, source = null) {
|
||||||
|
if (this.dead) { return }
|
||||||
|
|
||||||
|
let customMultipliers = 0
|
||||||
|
if (this.hasBuff(Buff.exposed.id)) {
|
||||||
|
const buff = this.getBuff(Buff.exposed.id)
|
||||||
|
if (buff.source == source.id) {
|
||||||
|
customMultipliers += (buff.onHitMultiplier - 1)
|
||||||
|
this.removeBuff(Buff.exposed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const damageMultiplerBuffs = source.buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
|
||||||
|
const buffPassiveDamageMultiplier = damageMultiplerBuffs.reduce((it) => it.damageMultiplier - 1, 0)
|
||||||
|
|
||||||
|
const damageMultipler = 1 + buffPassiveDamageMultiplier + customMultipliers
|
||||||
|
const damage = amount * damageMultipler
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`Can't fix position ([${futurePosition.x}, ${futurePosition.y}]) of entity ID: ${this.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (enemyTeam == null) {
|
||||||
|
return // only blue/red teams have casting vision
|
||||||
|
}
|
||||||
|
|
||||||
|
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && it.team == enemyTeam && it.distanceTo(this.position) <= (it.visionRange + this.radius))
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 ?? 1000); failsafe++) {
|
||||||
|
if (failsafe >= 10) { console.error('Failsafe is reached!!!'); process.exit(0) }
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
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.error({ error: 'Invalid ID' })
|
||||||
|
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: { reason: 'Can\'t despawn 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: { reason: 'Can\'t spawn 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(`Started game ${this.id} with ${this.tickRate} tps. Starting on tick ${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(`Stopped game ${this.id}. Stopped on tick ${this.currentTick}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription(websocket, id) {
|
||||||
|
return function builtSubscription() {
|
||||||
|
const game = this
|
||||||
|
|
||||||
|
const entity = game.entities.find((it) => it.id == id)
|
||||||
|
if (entity == null) { 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 taken = (after - before)
|
||||||
|
|
||||||
|
const useAbsoluteBehind = true
|
||||||
|
const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
|
||||||
|
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
|
||||||
|
|
||||||
|
if (after - before > tickBudget) {
|
||||||
|
const behindNotice = absoluteBehind > 0.1 ? `(Was already behind ${absoluteBehind.toFixed(1)} ms)` : ``
|
||||||
|
console.warn(`Can't keep up! A tick took ${taken.toFixed(1)} ms / ${tickBudget.toFixed(1)} ms. ${behindNotice}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#gameLoopCall() {
|
||||||
|
this.#gameLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Dungeon } 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('Could not adjust process priority on startup.')
|
||||||
|
}
|
||||||
|
|
||||||
|
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('/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.log(message)
|
||||||
|
if (message.action == 'join') {
|
||||||
|
const id = message.id
|
||||||
|
const connectionId = crypto.randomUUID()
|
||||||
|
websocket.send(JSON.stringify(game.joinReport()))
|
||||||
|
const subscription = game.subscription(websocket, id).bind(game)
|
||||||
|
game.subscriptions.set(connectionId, subscription)
|
||||||
|
|
||||||
|
websocket.on('close', () => {
|
||||||
|
console.log({ event: 'disconnected', id })
|
||||||
|
game.subscriptions.delete(connectionId)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
game.action(message.id, message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.info(`Server started! Visit http://localhost:${port}`)
|
||||||
|
|
||||||
|
Dungeon.scenario(game)
|
||||||
|
})
|
||||||
+2282
File diff suppressed because it is too large
Load Diff
+222
@@ -0,0 +1,222 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// const niceGraph = []
|
||||||
|
// for (let i = 0; i < graph.length / 5; i += 5) {
|
||||||
|
// niceGraph.push({
|
||||||
|
// from: [graph[i], graph[i + 1]],
|
||||||
|
// to: [graph[i + 2], graph[i + 3]],
|
||||||
|
// distance: graph[i + 4],
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// console.log(niceGraph)
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default class Team {
|
||||||
|
static neutral = 'neutral'
|
||||||
|
static blue = 'blue'
|
||||||
|
static red = 'red'
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import { Vector2 } from 'three'
|
||||||
|
import Ability from './ability.js'
|
||||||
|
import Team from './team.js'
|
||||||
|
|
||||||
|
export default class Template {
|
||||||
|
static basilisk(overrides) {
|
||||||
|
return {
|
||||||
|
abilities: {},
|
||||||
|
height: 100,
|
||||||
|
logic: this.#basiliskLogic,
|
||||||
|
radius: 180,
|
||||||
|
speed: 230,
|
||||||
|
visualRadius: 170,
|
||||||
|
maxHealth: 3000,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static minion(team, options = {}) {
|
||||||
|
return {
|
||||||
|
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
|
||||||
|
height: options.ranged ? 40 : 38,
|
||||||
|
logic: this.#minionLogic(options.route, (team != Team.blue)),
|
||||||
|
maxHealth: options.ranged ? 300 : 450,
|
||||||
|
pathfindingCooldown: 0.2,
|
||||||
|
pathfindingObstacleLimit: 0,
|
||||||
|
position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0),
|
||||||
|
radius: 48,
|
||||||
|
speed: 325,
|
||||||
|
team,
|
||||||
|
visionRange: 1200,
|
||||||
|
visualRadius: options.ranged ? 36 : 38,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
height: 80,
|
||||||
|
logic: this.#playerLogic,
|
||||||
|
maxHealth: 600,
|
||||||
|
pathfindingObstacleLimit: 3,
|
||||||
|
radius: 65,
|
||||||
|
spawnPosition: new Vector2(500, 150),
|
||||||
|
visionRange: 1350,
|
||||||
|
visualRadius: 40,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static #basiliskLogic() {
|
||||||
|
const entity = this
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user