Compare commits
2 Commits
8ae113b2cf
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
5cb6c398ae
|
|||
|
8e88fef353
|
@@ -1,4 +0,0 @@
|
|||||||
.git
|
|
||||||
*Dockerfile*
|
|
||||||
*docker-compose*
|
|
||||||
node_modules
|
|
||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
# Files generated by the app
|
|
||||||
public/temp
|
|
||||||
|
|
||||||
# Flamegraphs
|
|
||||||
*.0X
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
nodejs 23.6.1
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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,86 +1,5 @@
|
|||||||
# Instructions Clear
|
# Instructions Clear
|
||||||
|
|
||||||
Instructions Clear is a Multiplayer Online Battle Arena (MOBA) where two teams of five players fight for victory.
|
Node JS MOBA attempt: https://git.uncensored.hu/thayol/instructions-clear/src/branch/nodejs/
|
||||||
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.
|
|
||||||
|
|
||||||
## Design Pillars
|
Godot Async Survival attempt: https://git.uncensored.hu/thayol/instructions-clear/src/branch/godot/
|
||||||
|
|
||||||
| 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.
|
Before Width: | Height: | Size: 95 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 543 B |
Binary file not shown.
|
Before Width: | Height: | Size: 95 B |
Binary file not shown.
|
Before Width: | Height: | Size: 95 B |
Binary file not shown.
|
Before Width: | Height: | Size: 95 B |
Generated
-1075
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
|
||||||
import { Tween } from '@tweenjs/tween.js'
|
|
||||||
import * as THREE from 'three'
|
|
||||||
import Stats from 'stats.js'
|
|
||||||
|
|
||||||
const global = (0,eval)('this')
|
|
||||||
const scene = new THREE.Scene()
|
|
||||||
const raycaster = new THREE.Raycaster()
|
|
||||||
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000)
|
|
||||||
const clock = new THREE.Clock()
|
|
||||||
const renderer = new THREE.WebGLRenderer()
|
|
||||||
const backgroundColor = new THREE.Color().setHex(0x112233)
|
|
||||||
scene.background = backgroundColor
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
|
||||||
renderer.setAnimationLoop(render)
|
|
||||||
const cameraOffsetX = 0
|
|
||||||
const cameraOffsetY = -13.5
|
|
||||||
const cameraOffsetZ = 20
|
|
||||||
camera.position.set(cameraOffsetX, cameraOffsetY, cameraOffsetZ)
|
|
||||||
camera.rotation.set((34 / 180) * Math.PI, 0, 0)
|
|
||||||
camera.zoom += 0.2
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
camera.layers.enable(1)
|
|
||||||
camera.layers.enable(2)
|
|
||||||
|
|
||||||
const projectileMaterial = new THREE.MeshToonMaterial({ color: 0xcccccc })
|
|
||||||
const terrainMaterial = new THREE.MeshToonMaterial({ color: 0x5c4033 })
|
|
||||||
const passableTerrainMaterial = new THREE.MeshToonMaterial({ color: 0x228822, transparent: true, opacity: 0.65 })
|
|
||||||
const opacity = 0.3
|
|
||||||
const teamMaterials = {
|
|
||||||
blue: new THREE.MeshToonMaterial({ color: 0x4444ff }),
|
|
||||||
blueTransparent: new THREE.MeshToonMaterial({ color: 0x4444ff, transparent: true, opacity }),
|
|
||||||
neutral: new THREE.MeshToonMaterial({ color: 0xcccccc }),
|
|
||||||
neutralTransparent: new THREE.MeshToonMaterial({ color: 0xcccccc, transparent: true, opacity }),
|
|
||||||
red: new THREE.MeshToonMaterial({ color: 0xff4444 }),
|
|
||||||
redTransparent: new THREE.MeshToonMaterial({ color: 0xff4444, transparent: true, opacity }),
|
|
||||||
projectile: new THREE.MeshToonMaterial({ color: 0xff00ff, transparent: true, opacity }),
|
|
||||||
range: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 2 }),
|
|
||||||
visionRange: new THREE.MeshToonMaterial({ color: 0x00ffff, transparent: true, opacity: opacity / 6 }),
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: draw lines of path for minimap camera
|
|
||||||
const minimapCameraZ = 10
|
|
||||||
const minimapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10)
|
|
||||||
const minimapRenderer = new THREE.WebGLRenderer()
|
|
||||||
|
|
||||||
minimapRenderer.setSize(300, 300)
|
|
||||||
minimapRenderer.setAnimationLoop(minimapRender)
|
|
||||||
minimapCamera.position.set(10, 10, 10)
|
|
||||||
|
|
||||||
const animationActions = {}
|
|
||||||
const entities = {}
|
|
||||||
const gltf = {}
|
|
||||||
const mixers = {}
|
|
||||||
const positionTweens = {}
|
|
||||||
const projectiles = {}
|
|
||||||
const rotationTweens = {}
|
|
||||||
const terrains = {}
|
|
||||||
var state = { abilities: [], entities: [], terrains: [], projectiles: [] }
|
|
||||||
|
|
||||||
global.animationActions = animationActions
|
|
||||||
global.entities = entities
|
|
||||||
global.gltf = gltf
|
|
||||||
global.mixers = mixers
|
|
||||||
global.projectiles = projectiles
|
|
||||||
global.state = state
|
|
||||||
global.terrains = terrains
|
|
||||||
|
|
||||||
const gltfLoader = new GLTFLoader()
|
|
||||||
const preloadGLTF = function loadTemplate(path) {
|
|
||||||
gltfLoader.load(path, (loadedGLTF) => gltf[path] = loadedGLTF)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addGLTF = function addGLTF(scene, path, id, additionalSteps = function noAdditionalSteps() {}) {
|
|
||||||
if (gltf[path] == null) {
|
|
||||||
setTimeout(() => addGLTF(scene, path, id, additionalSteps), 200)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = 2
|
|
||||||
const model = gltf[path].scene.clone()
|
|
||||||
|
|
||||||
const mixer = new THREE.AnimationMixer(model)
|
|
||||||
mixers[id] = mixer
|
|
||||||
|
|
||||||
animationActions[id] = {}
|
|
||||||
gltf[path].animations.forEach((it) => {
|
|
||||||
const animation = mixer.clipAction(it)
|
|
||||||
animationActions[id][it.name] = animation
|
|
||||||
})
|
|
||||||
|
|
||||||
model.scale.set(scale, scale, scale)
|
|
||||||
additionalSteps(model)
|
|
||||||
scene.add(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(0, 0)
|
|
||||||
const material = new THREE.MeshToonMaterial({ color: 0x115011 })
|
|
||||||
const ground = new THREE.Mesh(geometry, material)
|
|
||||||
scene.add(ground)
|
|
||||||
|
|
||||||
const ambientLight = new THREE.AmbientLight(0x404040, 10)
|
|
||||||
scene.add(ambientLight)
|
|
||||||
|
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5)
|
|
||||||
directionalLight.position.set(-0.5, -0.05, 1)
|
|
||||||
directionalLight.power = 3000
|
|
||||||
scene.add(directionalLight)
|
|
||||||
|
|
||||||
global.THREE = THREE
|
|
||||||
global.renderer = renderer
|
|
||||||
global.camera = camera
|
|
||||||
global.scene = scene
|
|
||||||
|
|
||||||
var tweenDuration = 1
|
|
||||||
const keysDown = {}
|
|
||||||
const mouse = {}
|
|
||||||
|
|
||||||
var stats = new Stats()
|
|
||||||
stats.showPanel(0)
|
|
||||||
|
|
||||||
var delta = 0
|
|
||||||
function render() {
|
|
||||||
stats.begin()
|
|
||||||
delta = clock.getDelta()
|
|
||||||
cameraMovement()
|
|
||||||
Object.values(positionTweens).forEach((tween) => tween.update())
|
|
||||||
Object.values(rotationTweens).forEach((tween) => tween.update())
|
|
||||||
Object.values(mixers).forEach((mixer) => mixer.update(delta))
|
|
||||||
renderer.render(scene, camera)
|
|
||||||
stats.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
function minimapRender() {
|
|
||||||
minimapRenderer.render(scene, minimapCamera)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockedCameraSpeedMultiplier = 3
|
|
||||||
var cameraLocked = true
|
|
||||||
function followCamera() {
|
|
||||||
const entity = entities[playerId]
|
|
||||||
if (entity == null) { return }
|
|
||||||
|
|
||||||
const cameraSpeed = lockedCameraSpeedMultiplier * delta
|
|
||||||
|
|
||||||
const distanceX = Math.abs((entity.position.x + cameraOffsetX) - camera.position.x)
|
|
||||||
const distanceY = Math.abs((entity.position.y + cameraOffsetY) - camera.position.y)
|
|
||||||
|
|
||||||
camera.position.z = cameraOffsetZ
|
|
||||||
if (distanceX > 0.01) {
|
|
||||||
if (entity.position.x + cameraOffsetX > camera.position.x) {
|
|
||||||
camera.position.x += cameraSpeed * distanceX
|
|
||||||
}
|
|
||||||
if (entity.position.x + cameraOffsetX < camera.position.x) {
|
|
||||||
camera.position.x -= cameraSpeed * distanceX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (distanceX != 0) {
|
|
||||||
camera.position.x = entity.position.x + cameraOffsetX
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distanceY > 0.01) {
|
|
||||||
if (entity.position.y + cameraOffsetY > camera.position.y) {
|
|
||||||
camera.position.y += cameraSpeed * distanceY
|
|
||||||
}
|
|
||||||
if (entity.position.y + cameraOffsetY < camera.position.y) {
|
|
||||||
camera.position.y -= cameraSpeed * distanceY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (distanceY != 0) {
|
|
||||||
camera.position.y = entity.position.y + cameraOffsetY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraSpeedMultiplier = 10
|
|
||||||
function cameraMovement() {
|
|
||||||
if (cameraLocked) {
|
|
||||||
followCamera()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraSpeed = cameraSpeedMultiplier * delta
|
|
||||||
|
|
||||||
if (keysDown.ArrowLeft) { camera.position.x -= cameraSpeed }
|
|
||||||
else if (keysDown.ArrowRight) { camera.position.x += cameraSpeed }
|
|
||||||
|
|
||||||
if (keysDown.ArrowUp) { camera.position.y += cameraSpeed }
|
|
||||||
else if (keysDown.ArrowDown) { camera.position.y -= cameraSpeed }
|
|
||||||
|
|
||||||
if (keysDown.Space) {
|
|
||||||
camera.position.set(entities[playerId].position.x + cameraOffsetX, entities[playerId].position.y + cameraOffsetY, cameraOffsetZ)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function raycastToGround() {
|
|
||||||
const canvas = renderer.domElement
|
|
||||||
raycaster.setFromCamera(new THREE.Vector2((mouse.x / canvas.clientWidth) * 2 - 1, (mouse.y / canvas.clientHeight) * -2 + 1), camera)
|
|
||||||
const intersect = raycaster.intersectObject(ground).at(0)?.point
|
|
||||||
if (intersect != null) {
|
|
||||||
return {
|
|
||||||
x: Math.round(intersect.x * 100),
|
|
||||||
y: Math.round(intersect.y * 100),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
var websocket = null
|
|
||||||
global.websocket = null
|
|
||||||
var timerId = null
|
|
||||||
var playerId = null
|
|
||||||
var playerTeam = null
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
|
|
||||||
global.websocket = websocket
|
|
||||||
websocket.onerror = () => websocket.close()
|
|
||||||
websocket.onopen = () => {
|
|
||||||
document.getElementById('connection').innerHTML = 'open'
|
|
||||||
clearInterval(timerId)
|
|
||||||
websocket.send(JSON.stringify({ action: 'join', id: playerId }))
|
|
||||||
}
|
|
||||||
websocket.onclose = () => {
|
|
||||||
websocket = null
|
|
||||||
document.getElementById('connection').innerHTML = 'closed'
|
|
||||||
timerId = setInterval(() => {
|
|
||||||
if (websocket == null) {
|
|
||||||
connectWebSocket()
|
|
||||||
}
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
state.byteSize = new Blob([event.data]).size
|
|
||||||
const stateUpdates = JSON.parse(event.data)
|
|
||||||
|
|
||||||
if (stateUpdates.tickRate != null) {
|
|
||||||
tweenDuration = 1000 / stateUpdates.tickRate
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.width != null && stateUpdates.height != null && stateUpdates.width != (state.width ?? -1) && stateUpdates.height != (state.height ?? -1) ) {
|
|
||||||
ground.geometry = new THREE.PlaneGeometry(stateUpdates.width / 100, stateUpdates.height / 100)
|
|
||||||
ground.position.set(stateUpdates.width / 200, stateUpdates.height / 200, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.width != null && stateUpdates.height != null) {
|
|
||||||
state.width = stateUpdates.width
|
|
||||||
state.height = stateUpdates.height
|
|
||||||
|
|
||||||
minimapCamera.top = state.height / 200
|
|
||||||
minimapCamera.right = state.width / 200
|
|
||||||
minimapCamera.bottom = -state.height / 200
|
|
||||||
minimapCamera.left = -state.width / 200
|
|
||||||
minimapCamera.updateProjectionMatrix()
|
|
||||||
minimapCamera.position.set(state.width / 200, state.height / 200, minimapCameraZ)
|
|
||||||
|
|
||||||
const size = 300
|
|
||||||
const wide = state.width > state.height
|
|
||||||
minimapRenderer.setSize(
|
|
||||||
wide ? size : (state.width / state.height) * size,
|
|
||||||
wide ? (state.height / state.width) * size : size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(stateUpdates)) {
|
|
||||||
if (!['abilities', 'terrains', 'entities', 'projectiles', 'width', 'height'].includes(key)) {
|
|
||||||
state[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.abilities != null) {
|
|
||||||
const ids = stateUpdates.abilities.map((it) => it.id)
|
|
||||||
state.abilities = state.abilities.filter((it) => ids.includes(it.id))
|
|
||||||
for (const ability of stateUpdates.abilities ?? []) {
|
|
||||||
const index = state?.abilities?.findIndex((it) => it.id == ability.id)
|
|
||||||
if (index > -1) {
|
|
||||||
state.abilities[index] = {...state.abilities[index], ...ability}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state.abilities.push(ability)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.entities != null) {
|
|
||||||
const ids = stateUpdates.entities.map((it) => it.id)
|
|
||||||
state.entities = state.entities.filter((it) => ids.includes(it.id))
|
|
||||||
for (const entity of stateUpdates.entities ?? []) {
|
|
||||||
const index = state?.entities?.findIndex((it) => it.id == entity.id)
|
|
||||||
if (index > -1) {
|
|
||||||
state.entities[index] = {...state.entities[index], ...entity}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state.entities.push(entity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.terrains != null) {
|
|
||||||
const ids = stateUpdates.terrains.map((it) => it.id)
|
|
||||||
state.terrains = state.terrains.filter((it) => ids.includes(it.id))
|
|
||||||
for (const terrain of stateUpdates.terrains ?? []) {
|
|
||||||
const index = state?.terrains?.findIndex((it) => it.id == terrain.id)
|
|
||||||
if (index > -1) {
|
|
||||||
state.terrains[index] = {...state.terrains[index], ...terrain}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state.terrains.push(terrain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateUpdates.projectiles != null) {
|
|
||||||
const ids = stateUpdates.projectiles.map((it) => it.id)
|
|
||||||
state.projectiles = state.projectiles.filter((it) => ids.includes(it.id))
|
|
||||||
for (const projectile of stateUpdates.projectiles) {
|
|
||||||
const index = state?.projectiles?.findIndex((it) => it.id == projectile.id)
|
|
||||||
if (index > -1) {
|
|
||||||
state.projectiles[index] = {...state.projectiles[index], ...projectile}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state.projectiles.push(projectile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of Object.values(entities)) {
|
|
||||||
e.userData.flaggedForRemoval = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of state.entities ?? []) {
|
|
||||||
let entity
|
|
||||||
let created = false
|
|
||||||
|
|
||||||
if (e.id == playerId && playerTeam != e.team) {
|
|
||||||
playerTeam = e.team
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.id in entities) {
|
|
||||||
entity = entities[e.id]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
created = true
|
|
||||||
|
|
||||||
entity = new THREE.Group()
|
|
||||||
entity.rotation.x = Math.PI / 2
|
|
||||||
entity.scale.set(e.visualRadius / 100, e.visualRadius / 100, e.visualRadius / 100)
|
|
||||||
entity.userData.type = 'entity'
|
|
||||||
entity.userData.id = e.id
|
|
||||||
entity.position.set(e.position.x / 100, e.position.y / 100, 0)
|
|
||||||
scene.add(entity)
|
|
||||||
|
|
||||||
const hpMargin = 0.5
|
|
||||||
const maxHp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xd03333 }))
|
|
||||||
maxHp.position.set(0, 0, 0)
|
|
||||||
maxHp.scale.set(1.5, 0.2, 1)
|
|
||||||
maxHp.layers.set(1)
|
|
||||||
entity.add(maxHp)
|
|
||||||
|
|
||||||
const hp = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0x77ff77 }))
|
|
||||||
hp.position.set(0, 0, 0)
|
|
||||||
hp.scale.set(1, 1, 1)
|
|
||||||
hp.layers.set(1)
|
|
||||||
maxHp.add(hp)
|
|
||||||
|
|
||||||
const teamMaterial = teamMaterials[`${e.team}Transparent`]
|
|
||||||
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry(1, 0.00001, 1), teamMaterial)
|
|
||||||
teamMarker.position.y = -0.493
|
|
||||||
entity.add(teamMarker)
|
|
||||||
|
|
||||||
const buffMaterial = new THREE.MeshToonMaterial({ color: 0xffff00, transparent: true, opacity: 0.4 })
|
|
||||||
const buffMarker = new THREE.Mesh(new THREE.TorusGeometry(0.95, 0.15), buffMaterial)
|
|
||||||
buffMarker.rotation.x = Math.PI / 2
|
|
||||||
buffMarker.layers.set(1)
|
|
||||||
buffMarker.visible = false
|
|
||||||
entity.add(buffMarker)
|
|
||||||
|
|
||||||
const rangeMaterial = teamMaterials['range']
|
|
||||||
const rangeSize = (state.abilities.find((it) => it.id == e.abilities?.a)?.range ?? 0) + e.radius
|
|
||||||
const rangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(rangeSize / e.visualRadius, rangeSize / e.visualRadius, 0.001), rangeMaterial)
|
|
||||||
rangeMarker.position.y = 0.004
|
|
||||||
rangeMarker.layers.set(1)
|
|
||||||
rangeMarker.visible = false
|
|
||||||
entity.add(rangeMarker)
|
|
||||||
|
|
||||||
const modelRotationBase = new THREE.Object3D()
|
|
||||||
modelRotationBase.rotation.y = e.rotation - (Math.PI / 2)
|
|
||||||
modelRotationBase.layers.set(1)
|
|
||||||
entity.add(modelRotationBase)
|
|
||||||
|
|
||||||
const visionRangeMaterial = teamMaterials['visionRange']
|
|
||||||
const visionRangeSize = e.visionRange ?? 0
|
|
||||||
const visionRangeMarker = new THREE.Mesh(new THREE.CylinderGeometry(visionRangeSize / e.visualRadius, visionRangeSize / e.visualRadius, 0.001), visionRangeMaterial)
|
|
||||||
visionRangeMarker.position.y = 0.002
|
|
||||||
visionRangeMarker.layers.set(1)
|
|
||||||
visionRangeMarker.visible = false
|
|
||||||
entity.add(visionRangeMarker)
|
|
||||||
|
|
||||||
if (e.model != null) {
|
|
||||||
addGLTF(modelRotationBase, e.model, e.id, function(model) {
|
|
||||||
const box = new THREE.Box3().setFromObject(model)
|
|
||||||
const size = box.getSize(new THREE.Vector3())
|
|
||||||
maxHp.position.set(0, size.y + hpMargin, 0)
|
|
||||||
buffMarker.position.y = size.y / 2
|
|
||||||
buffMarker.scale.z = size.y / 10
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
entities[e.id] = entity
|
|
||||||
}
|
|
||||||
|
|
||||||
entity.children.at(0).visible = !e.dead
|
|
||||||
entity.children.at(1).visible = !e.dead
|
|
||||||
entity.children.at(2).visible = !e.dead && e.buffs.some((it) => it.id == 'exposed')
|
|
||||||
|
|
||||||
const animations = animationActions[e.id] ?? {}
|
|
||||||
const fadeIn = created ? 0 : 0.15
|
|
||||||
|
|
||||||
if (e.dead) {
|
|
||||||
if (!animations.dead?.isRunning()) {
|
|
||||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
|
||||||
animations.dead?.reset().fadeIn(fadeIn).play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.casting != null) {
|
|
||||||
if (!animations.cast?.isRunning()) {
|
|
||||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
|
||||||
animations.cast?.reset().fadeIn(fadeIn).play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (!animations.default?.isRunning()) {
|
|
||||||
Object.values(animations).forEach((it) => it.isRunning() && it.reset().fadeOut(fadeIn).play())
|
|
||||||
animations.default?.reset().fadeIn(fadeIn).play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entity.userData.flaggedForRemoval = false
|
|
||||||
const oldRotationY = entity.children.at(4).rotation.y
|
|
||||||
const newRotationY = e.rotation - (Math.PI / 2)
|
|
||||||
if (Math.abs((oldRotationY - (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
|
|
||||||
entity.children.at(4).rotation.y = oldRotationY - (2 * Math.PI)
|
|
||||||
}
|
|
||||||
if (Math.abs((oldRotationY + (2 * Math.PI)) - newRotationY) < Math.abs(oldRotationY - newRotationY)) {
|
|
||||||
entity.children.at(4).rotation.y = oldRotationY + (2 * Math.PI)
|
|
||||||
}
|
|
||||||
|
|
||||||
positionTweens[entity.id] = new Tween(entity.position).to({ x: e.position.x / 100, y: e.position.y / 100, z: 0 }, tweenDuration).start()
|
|
||||||
rotationTweens[entity.id] = new Tween(entity.children.at(4).rotation).to({ x: 0, y: newRotationY, z: 0 }, tweenDuration).start()
|
|
||||||
|
|
||||||
const hp = entity.children.at(0).children.at(0)
|
|
||||||
const percentageHp = e.health / e.maxHealth
|
|
||||||
hp.scale.x = percentageHp
|
|
||||||
hp.position.x = -(1 - percentageHp) / 2
|
|
||||||
|
|
||||||
entity.children.at(3).visible = !e.dead && e.id == playerId
|
|
||||||
// entity.children.at(5).visible = !e.dead && e.team == playerTeam // TODO: clipping makes the screen unviewable
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of Object.values(entities)) {
|
|
||||||
if (e.userData.flaggedForRemoval) {
|
|
||||||
scene.remove(e)
|
|
||||||
delete animationActions[e.userData.id]
|
|
||||||
delete entities[e.userData.id]
|
|
||||||
delete mixers[e.userData.id]
|
|
||||||
delete positionTweens[e.userData.id]
|
|
||||||
delete rotationTweens[e.userData.id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of Object.values(projectiles)) {
|
|
||||||
p.userData.flaggedForRemoval = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of state.projectiles ?? []) {
|
|
||||||
let projectile
|
|
||||||
if (p.id in projectiles) {
|
|
||||||
projectile = projectiles[p.id]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
projectile = new THREE.Mesh(new THREE.SphereGeometry(p.visualRadius / 100), projectileMaterial)
|
|
||||||
projectile.userData.type = 'projectile'
|
|
||||||
projectile.userData.id = p.id
|
|
||||||
projectile.position.set(p.position.x / 100, p.position.y / 100, p.height / 100)
|
|
||||||
projectile.layers.set(2)
|
|
||||||
scene.add(projectile)
|
|
||||||
|
|
||||||
projectile.rotation.x = Math.PI / 2 // needed for the team marker...
|
|
||||||
const teamMaterial = teamMaterials[`${p.team}Transparent`] ?? teamMaterials['projectile']
|
|
||||||
const teamMarker = new THREE.Mesh(new THREE.CylinderGeometry((p.radius) / 100, (p.radius) / 100, 1), teamMaterial)
|
|
||||||
const teamMarkerSize = 4000
|
|
||||||
teamMarker.scale.y = p.height / teamMarkerSize
|
|
||||||
teamMarker.position.y = (p.height / (teamMarkerSize * 2)) - (p.height / 100)
|
|
||||||
teamMarker.position.y += 0.01
|
|
||||||
teamMarker.layers.set(2)
|
|
||||||
projectile.add(teamMarker)
|
|
||||||
|
|
||||||
projectiles[p.id] = projectile
|
|
||||||
}
|
|
||||||
|
|
||||||
projectile.userData.flaggedForRemoval = false
|
|
||||||
positionTweens[projectile.id] = new Tween(projectile.position).to({ x: p.position.x / 100, y: p.position.y / 100, z: p.height / 100 }, tweenDuration).start()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of Object.values(projectiles)) {
|
|
||||||
if (p.userData.flaggedForRemoval) {
|
|
||||||
scene.remove(p)
|
|
||||||
delete projectiles[p.userData.id]
|
|
||||||
delete positionTweens[p.userData.id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const t of state.terrains ?? []) {
|
|
||||||
let terrain
|
|
||||||
if (t.id in terrains) {
|
|
||||||
terrain = terrains[t.id]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const vertices = t.relativeVertices
|
|
||||||
const shape = new THREE.Shape()
|
|
||||||
shape.moveTo(vertices.at(0).x / 100, vertices.at(0).y / 100)
|
|
||||||
vertices.slice(1).forEach((v) => shape.lineTo(v.x / 100, v.y / 100))
|
|
||||||
terrain = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { bevelEnabled: false, depth: t.collision ? 0.5 : 0.35 }), t.collision ? terrainMaterial : passableTerrainMaterial)
|
|
||||||
terrain.userData.type = 'terrain'
|
|
||||||
terrain.userData.id = t.id
|
|
||||||
scene.add(terrain)
|
|
||||||
terrains[t.id] = terrain
|
|
||||||
}
|
|
||||||
|
|
||||||
terrain.position.set(t.position.x / 100, t.position.y / 100, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerId != null) {
|
|
||||||
const player = state.entities.find((e) => e.id == playerId)
|
|
||||||
if (player != null) {
|
|
||||||
const playerAbilities = player.abilities
|
|
||||||
|
|
||||||
let abilitiesHTML = ''
|
|
||||||
|
|
||||||
let i = 0
|
|
||||||
for (const [abilityKey, _abilityId] of Object.entries(playerAbilities)) {
|
|
||||||
i++
|
|
||||||
const abilityKeyText = abilityKey.toUpperCase()
|
|
||||||
const abilityTemplate = `<div id="ability-${i}" class="ability">${abilityKeyText}<div id="ability-${i}-cooldown" class="cooldown"></div><div id="ability-${i}-cooldown-text" class="cooldown-text"></div></div>`
|
|
||||||
abilitiesHTML += abilityTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById(`abilities`).innerHTML != abilitiesHTML) {
|
|
||||||
document.getElementById(`abilities`).innerHTML = abilitiesHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
let abilityIndex = 0
|
|
||||||
for (const [_abilityKey, abilityId] of Object.entries(playerAbilities)) {
|
|
||||||
abilityIndex++
|
|
||||||
const ability = state.abilities.find((it) => it.id == abilityId)
|
|
||||||
const lastCast = player.cooldowns[ability.id] ?? -Infinity
|
|
||||||
const cooldownDuration = (ability.cooldown * state.tickRate) ?? 0
|
|
||||||
const remainingCooldown = (lastCast + cooldownDuration) - state.currentTick
|
|
||||||
let cssPercentage = '100%'
|
|
||||||
let text = ''
|
|
||||||
if (remainingCooldown > 0) {
|
|
||||||
const cooldownPercentage = 1 - (remainingCooldown / cooldownDuration)
|
|
||||||
cssPercentage = `${Math.round(100 * cooldownPercentage)}%`
|
|
||||||
if (remainingCooldown / state.tickRate <= 5) {
|
|
||||||
text = `${(Math.round(10 * remainingCooldown / state.tickRate) / 10).toFixed(1)}`
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
text = `${Math.round(remainingCooldown / state.tickRate)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.casting?.ability == ability.id) {
|
|
||||||
document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(50% 0%, 0% 100%, 100% 100%)` // triangle
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
document.getElementById(`ability-${abilityIndex}-cooldown`).style.clipPath = `polygon(0 ${cssPercentage}, 100% ${cssPercentage}, 100% 100%, 0 100%)`
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById(`ability-${abilityIndex}-cooldown-text`).innerHTML = text
|
|
||||||
}
|
|
||||||
|
|
||||||
let buffs = ``
|
|
||||||
player.buffs.forEach((b) => {
|
|
||||||
buffs += `<div class="buff"><div class="buff-body">${state.buffs.find((it) => it.id == b.id).name}</div></div>`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (document.getElementById('buffs').innerHTML != buffs) {
|
|
||||||
document.getElementById('buffs').innerHTML = buffs
|
|
||||||
}
|
|
||||||
|
|
||||||
let castIndicatorDisplay = 'none'
|
|
||||||
if (player.casting != null) {
|
|
||||||
castIndicatorDisplay = 'block'
|
|
||||||
const ability = state.abilities.find((it) => it.id == player.casting.ability)
|
|
||||||
if (ability != null) {
|
|
||||||
const castDuration = (ability.castTime * state.tickRate) ?? 0
|
|
||||||
const remainingCastTime = (player.casting.timestamp + castDuration) - state.currentTick
|
|
||||||
let cssPercentage = '100%'
|
|
||||||
if (remainingCastTime > 0) {
|
|
||||||
const castPercentage = 1 - (remainingCastTime / castDuration)
|
|
||||||
cssPercentage = `${Math.round(100 * castPercentage)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('cast_indicator_progress').style.clipPath = `polygon(0 0, ${cssPercentage} 0, ${cssPercentage} 100%, 0% 100%)`
|
|
||||||
document.getElementById('cast_indicator_name').innerHTML = ability.name ?? ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('cast_indicator').style.display = castIndicatorDisplay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('state').innerHTML = JSON.stringify(stateUpdates, null, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
preloadGLTF('models/generic-bam-placeholder.gltf')
|
|
||||||
preloadGLTF('models/generic-player-placeholder.gltf')
|
|
||||||
preloadGLTF('models/generic-player-placeholder-red.gltf')
|
|
||||||
|
|
||||||
const params = Object.fromEntries(new URLSearchParams(window.location.search).entries())
|
|
||||||
playerId = params.id
|
|
||||||
if (playerId == null) {
|
|
||||||
playerId = prompt('Player ID:')
|
|
||||||
if (playerId == '') {
|
|
||||||
window.location.href = '/menu/'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectWebSocket()
|
|
||||||
|
|
||||||
const canvas = renderer.domElement
|
|
||||||
canvas.classList.add('canvas')
|
|
||||||
|
|
||||||
window.addEventListener('mousedown', (event) => {
|
|
||||||
const intersect = raycastToGround()
|
|
||||||
if (intersect != null) {
|
|
||||||
const { x, y } = intersect
|
|
||||||
if (event.button == 0) {
|
|
||||||
websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y }))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.button == 2) {
|
|
||||||
websocket.send(JSON.stringify({ action: 'move', id: playerId, x, y }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
const intersect = raycastToGround()
|
|
||||||
if (intersect != null) {
|
|
||||||
const { x, y } = intersect
|
|
||||||
if (event.code == 'KeyA') {
|
|
||||||
websocket.send(JSON.stringify({ action: 'attack', id: playerId, x, y }))
|
|
||||||
}
|
|
||||||
if (event.code == 'KeyX') {
|
|
||||||
websocket.send(JSON.stringify({ action: 'cast', slot: 'a', id: playerId, x, y }))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code == 'KeyS') {
|
|
||||||
websocket.send(JSON.stringify({ action: 'stop', id: playerId }))
|
|
||||||
}
|
|
||||||
if (event.code == 'KeyH') {
|
|
||||||
websocket.send(JSON.stringify({ action: 'halt', id: playerId }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyBound = ['A', 'X', 'S', 'H']
|
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter) => {
|
|
||||||
if (alreadyBound.includes(letter)) { return }
|
|
||||||
|
|
||||||
if (event.code == `Key${letter}`) {
|
|
||||||
websocket.send(JSON.stringify({ action: 'cast', slot: letter.toLowerCase(), id: playerId, x, y }))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('wheel', (event) => {
|
|
||||||
if (event.deltaY < 0) {
|
|
||||||
camera.zoom += 0.2
|
|
||||||
if (camera.zoom > 3) {
|
|
||||||
camera.zoom = 3
|
|
||||||
}
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
if (event.deltaY > 0) {
|
|
||||||
camera.zoom -= 0.2
|
|
||||||
if (camera.zoom < 1) {
|
|
||||||
camera.zoom = 1
|
|
||||||
}
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('resize', (event) => {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('contextmenu', (event) => event.preventDefault())
|
|
||||||
window.addEventListener('keydown', (event) => keysDown[event.code] = true)
|
|
||||||
window.addEventListener('keyup', (event) => keysDown[event.code] = false)
|
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
if (event.code == 'Space') {
|
|
||||||
cameraLocked = !cameraLocked
|
|
||||||
}
|
|
||||||
})
|
|
||||||
window.addEventListener('mousemove', (event) => {
|
|
||||||
mouse.x = event.clientX
|
|
||||||
mouse.y = event.clientY
|
|
||||||
})
|
|
||||||
|
|
||||||
document.body.appendChild(canvas)
|
|
||||||
|
|
||||||
const minimap = minimapRenderer.domElement
|
|
||||||
minimap.classList.add('minimap')
|
|
||||||
document.body.appendChild(minimap)
|
|
||||||
|
|
||||||
document.body.appendChild(stats.dom)
|
|
||||||
})
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="author" content="Thayol">
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"three": "/three/build/three.module.js",
|
|
||||||
"three/addons/": "/three/examples/jsm/",
|
|
||||||
"@tweenjs/tween.js": "/@tweenjs/tween.js/dist/tween.esm.js",
|
|
||||||
"stats.js": "/stats.js/src/Stats.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-panel {
|
|
||||||
display: none;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
position: fixed;
|
|
||||||
opacity: 0.2;
|
|
||||||
overflow-y: scroll;
|
|
||||||
inset: 0 0 290px auto;
|
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
padding: 10px 10px 20px 20px;
|
|
||||||
background-color: white;
|
|
||||||
border: 5px solid gray;
|
|
||||||
border-top: none;
|
|
||||||
border-right: none;
|
|
||||||
width: 300px;
|
|
||||||
transition-duration: 0.2s;
|
|
||||||
transition-property: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-panel:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minimap {
|
|
||||||
position: fixed;
|
|
||||||
inset: auto 0 0 auto;
|
|
||||||
border: 5px solid gray;
|
|
||||||
border-top-left-radius: 10px;
|
|
||||||
border-bottom: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abilities {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
inset: auto 0 0 0;
|
|
||||||
width: fit-content;
|
|
||||||
margin: auto;
|
|
||||||
border: 5px solid gray;
|
|
||||||
background-color: black;
|
|
||||||
border-top-left-radius: 10px;
|
|
||||||
border-top-right-radius: 10px;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abilities:has(.ability) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ability {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 0 0;
|
|
||||||
border: 1px solid white;
|
|
||||||
width: 75px;
|
|
||||||
height: 75px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cooldown {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 73px;
|
|
||||||
height: 73px;
|
|
||||||
background-color: grey;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cooldown-text {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 73px;
|
|
||||||
height: 73px;
|
|
||||||
line-height: 73px;
|
|
||||||
text-align: center;
|
|
||||||
color: white;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cast-indicator-wrapper {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
inset: auto 0 30%;
|
|
||||||
width: 400px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cast-indicator-progress {
|
|
||||||
position: absolute;
|
|
||||||
background-color: #edd9ff;
|
|
||||||
width: calc(100% - 4px);
|
|
||||||
height: calc(100% - 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cast-indicator-name {
|
|
||||||
text-align: center;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 1px 1px 2px black, 0 0 1em dimgray, 0 0 0.2em dimgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cast-indicator-bar {
|
|
||||||
position: relative;
|
|
||||||
background-color: dimgray;
|
|
||||||
width: 100%;
|
|
||||||
height: 20px;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buffs {
|
|
||||||
position: fixed;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
inset: auto 0 120px calc(50vw - 165px);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff {
|
|
||||||
flex: 1 0 0;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background-color: black;
|
|
||||||
/* border: 1px solid gray; */
|
|
||||||
border-right: 1px solid gray;
|
|
||||||
color: white;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff:hover {
|
|
||||||
overflow: visible;
|
|
||||||
height: fit-content;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buff-body {
|
|
||||||
border: 1px solid gray;
|
|
||||||
padding: 5px;
|
|
||||||
background-color: black;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 200px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="debug-panel">
|
|
||||||
<p>Connection: <span id="connection"></span></p>
|
|
||||||
<pre id="state"></pre>
|
|
||||||
</div>
|
|
||||||
<div id="cast_indicator" class="cast-indicator-wrapper">
|
|
||||||
<div id="cast_indicator_name" class="cast-indicator-name"></div>
|
|
||||||
<div class="cast-indicator-bar">
|
|
||||||
<div id="cast_indicator_progress" class="cast-indicator-progress"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="abilities" class="abilities">
|
|
||||||
</div>
|
|
||||||
<div id="buffs" class="buffs"></div>
|
|
||||||
<script type="module" src="client.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<style>
|
|
||||||
html {
|
|
||||||
background-color: black;
|
|
||||||
color: white;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
a:link, a:hover, a:active, a:visited {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<h1>Take control of a unit:</h1>
|
|
||||||
<ul id="links"></ul>
|
|
||||||
<script>
|
|
||||||
websocket = new WebSocket(`ws://${window.location.hostname}:1280/ws`)
|
|
||||||
websocket.onopen = () => { websocket.send(JSON.stringify({ action: 'entities' })) }
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data)
|
|
||||||
const entityIds = message?.entities
|
|
||||||
if (entityIds == null) { return }
|
|
||||||
|
|
||||||
websocket.close()
|
|
||||||
let links = ''
|
|
||||||
entityIds.forEach((entityId) => links += `<li><a href="/?id=${encodeURI(entityId)}">${entityId}</a></li>`)
|
|
||||||
document.getElementById('links').innerHTML = links
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 365 B |
-320
@@ -1,320 +0,0 @@
|
|||||||
import Buff from './buff.js'
|
|
||||||
import Projectile from './projectile.js'
|
|
||||||
|
|
||||||
// Three classes: Blade, Armor, Charm
|
|
||||||
|
|
||||||
export default class Ability {
|
|
||||||
id = `ability-${Ability.nextId()}`
|
|
||||||
static nextId() { return this.#nextUniqueId++ }
|
|
||||||
static #nextUniqueId = 0
|
|
||||||
|
|
||||||
name = 'Ability'
|
|
||||||
|
|
||||||
castTime = null
|
|
||||||
cooldown = 0
|
|
||||||
damage = 0
|
|
||||||
moveCancelable = false
|
|
||||||
radius = 1
|
|
||||||
range = 0
|
|
||||||
speed = 1000
|
|
||||||
|
|
||||||
#effect = null
|
|
||||||
|
|
||||||
get effect() { return this.#effect ?? Ability.noEffect }
|
|
||||||
set effect(value) { this.#effect = value }
|
|
||||||
|
|
||||||
constructor(options = {}) {
|
|
||||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
static get noEffect() { return function noEffect() {} }
|
|
||||||
|
|
||||||
static blink = new Ability({
|
|
||||||
id: 'blink',
|
|
||||||
name: 'Blink',
|
|
||||||
cooldown: 10,
|
|
||||||
range: 475,
|
|
||||||
effect: function blinkEffect(caster, cursor) {
|
|
||||||
const ability = this
|
|
||||||
const direction = cursor.clone().sub(caster.position)
|
|
||||||
const realRange = ability.range + caster.radius
|
|
||||||
if (direction.length() > realRange) {
|
|
||||||
direction.normalize().multiplyScalar(realRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
const destination = caster.position.clone().add(direction)
|
|
||||||
|
|
||||||
caster.teleport(destination)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static circleOfResurrection = new Ability({
|
|
||||||
id: 'circle_of_resurrection',
|
|
||||||
name: 'Circle of Resurrection',
|
|
||||||
castTime: 0.5,
|
|
||||||
cooldown: 100,
|
|
||||||
duration: 3,
|
|
||||||
moveCancelable: true,
|
|
||||||
radius: 300,
|
|
||||||
range: 300,
|
|
||||||
effect: function circleOfResurrectionEffect(caster, cursor) {
|
|
||||||
const ability = this
|
|
||||||
caster.haltAction()
|
|
||||||
|
|
||||||
const direction = cursor.clone().sub(caster.position)
|
|
||||||
if (direction.length() > ability.range) {
|
|
||||||
direction.normalize().multiplyScalar(ability.range)
|
|
||||||
}
|
|
||||||
|
|
||||||
const destination = caster.position.clone().add(direction)
|
|
||||||
|
|
||||||
const team = caster.team
|
|
||||||
const currentTick = caster.game?.currentTick ?? 0
|
|
||||||
const duration = caster.game?.secToTick(ability.duration) ?? 0
|
|
||||||
const despawnAfter = currentTick + duration
|
|
||||||
const casterPosition = caster.position.clone()
|
|
||||||
|
|
||||||
const circleOfResurrectionLogic = function castingVisionLogic(projectile) {
|
|
||||||
const currentTick = projectile.game?.currentTick ?? 0
|
|
||||||
if (casterPosition.distanceTo(caster.position) > 1) {
|
|
||||||
projectile.despawn()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTick > despawnAfter) {
|
|
||||||
const entities = projectile.game?.entities ?? []
|
|
||||||
const pos = projectile.position
|
|
||||||
projectile.despawn()
|
|
||||||
const nearbyDeadTeammates = entities.filter((it) => it.dead && it.team == team && it.distanceTo(pos) <= ability.radius)
|
|
||||||
const closestDeadTeammate = nearbyDeadTeammates.reduce((e1, e2) => (e1?.distanceTo(pos) ?? Infinity) < e2.distanceTo(pos) ? e1 : e2, null)
|
|
||||||
if (closestDeadTeammate != null) {
|
|
||||||
closestDeadTeammate.revive(closestDeadTeammate.maxHealth / 4)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
logic: circleOfResurrectionLogic,
|
|
||||||
owner: caster.id,
|
|
||||||
position: destination,
|
|
||||||
radius: ability.radius,
|
|
||||||
visualRadius: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
caster.game?.spawnProjectile(projectile)
|
|
||||||
if (caster.casting != null) {
|
|
||||||
caster.forceCast(Ability.circleOfResurrectionChannel.id, destination)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static circleOfResurrectionChannel = new Ability({
|
|
||||||
id: 'channel:circle_of_resurrection',
|
|
||||||
name: 'Channeling: Circle of Resurrection',
|
|
||||||
castTime: 3,
|
|
||||||
moveCancelable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
static control = new Ability({
|
|
||||||
id: 'control',
|
|
||||||
name: 'Control',
|
|
||||||
castTime: 1,
|
|
||||||
cooldown: 5,
|
|
||||||
effect: function controlEffect(caster, cursor) { },
|
|
||||||
})
|
|
||||||
|
|
||||||
static expose = new Ability({
|
|
||||||
id: 'expose',
|
|
||||||
name: 'Expose',
|
|
||||||
castTime: 0.25,
|
|
||||||
cooldown: 6,
|
|
||||||
radius: 80,
|
|
||||||
range: 1200,
|
|
||||||
speed: 1700,
|
|
||||||
visualRadius: 50,
|
|
||||||
effect: function exposeEffect(caster, cursor) {
|
|
||||||
const ability = this
|
|
||||||
const exposeCollision = function exposeCollision(projectile, collidingEntity) {
|
|
||||||
if (projectile.game == null) { return }
|
|
||||||
if (collidingEntity == null) { return }
|
|
||||||
if (collidingEntity.team == caster.id) { return }
|
|
||||||
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
|
|
||||||
|
|
||||||
collidingEntity.applyBuff(Buff.exposed.id, caster.id)
|
|
||||||
projectile.despawn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
onCollide: exposeCollision,
|
|
||||||
owner: caster.id,
|
|
||||||
position: caster.position.clone(),
|
|
||||||
radius: ability.radius,
|
|
||||||
speed: ability.speed,
|
|
||||||
visualRadius: ability.visualRadius,
|
|
||||||
})
|
|
||||||
|
|
||||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
|
||||||
caster.game?.spawnProjectile(projectile)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static meleeAttack = new Ability({
|
|
||||||
id: 'melee_attack',
|
|
||||||
name: 'Melee Attack',
|
|
||||||
castTime: (1.4 * 0.22),
|
|
||||||
cooldown: 1.4,
|
|
||||||
moveCancelable: true,
|
|
||||||
damage: 60,
|
|
||||||
radius: 5,
|
|
||||||
range: 100,
|
|
||||||
effect: function meleeAttackEffect(caster, targetId) {
|
|
||||||
const ability = this
|
|
||||||
const target = caster.game?.entities.find((it) => it.id == targetId)
|
|
||||||
if (target == null) { return }
|
|
||||||
|
|
||||||
target.damage(ability.damage, caster)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static rangedAttack = new Ability({
|
|
||||||
id: 'ranged_attack',
|
|
||||||
name: 'Ranged Attack',
|
|
||||||
castTime: (1.6 * 0.18839),
|
|
||||||
cooldown: 1.6,
|
|
||||||
damage: 60,
|
|
||||||
moveCancelable: true,
|
|
||||||
radius: 5,
|
|
||||||
range: 500,
|
|
||||||
speed: 2000,
|
|
||||||
effect: function rangedAttackEffect(caster, targetId) {
|
|
||||||
const ability = this
|
|
||||||
const target = caster.game?.entities.find((it) => it.id == targetId)
|
|
||||||
if (target == null) { return }
|
|
||||||
|
|
||||||
const rangedAttackAfter = function rangedAttackAfter() {
|
|
||||||
target.damage(ability.damage, caster)
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
after: rangedAttackAfter,
|
|
||||||
homingTarget: target,
|
|
||||||
owner: caster.id,
|
|
||||||
position: caster.position.clone(),
|
|
||||||
radius: ability.radius,
|
|
||||||
speed: ability.speed,
|
|
||||||
})
|
|
||||||
|
|
||||||
caster.game?.spawnProjectile(projectile)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static shieldThrow = new Ability({
|
|
||||||
id: 'shield_throw',
|
|
||||||
name: 'Shield Throw',
|
|
||||||
castTime: 0.25,
|
|
||||||
cooldown: 5,
|
|
||||||
damage: 90,
|
|
||||||
deceleratePerTick: 90,
|
|
||||||
radius: 110,
|
|
||||||
range: 1025,
|
|
||||||
speed: 2400,
|
|
||||||
effect: function shieldThrowEffect(caster, cursor) {
|
|
||||||
const ability = this
|
|
||||||
const amount = ability.damage
|
|
||||||
let onTheWayBack = false
|
|
||||||
let collided = new Set()
|
|
||||||
|
|
||||||
const shieldThrowCollision = function shieldThrowCollision(projectile, collidingEntity) {
|
|
||||||
if (collidingEntity == null) { return }
|
|
||||||
if (collidingEntity.id == caster.id) { return }
|
|
||||||
if (caster.team == null || collidingEntity.team == null || collidingEntity.team != caster.team) { return }
|
|
||||||
const entityId = collidingEntity.id
|
|
||||||
|
|
||||||
if (!collided.has(entityId)) {
|
|
||||||
collidingEntity.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
|
||||||
collided.add(entityId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const accelerateLogic = function accelerateLogic(projectile) {
|
|
||||||
if (onTheWayBack) {
|
|
||||||
projectile.speed += ability.deceleratePerTick
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (projectile.speed - ability.deceleratePerTick >= ability.deceleratePerTick) {
|
|
||||||
projectile.speed = projectile.speed - ability.deceleratePerTick
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shieldThrowSecondAfter = function shieldThrowSecondAfter(projectile, homingTarget) {
|
|
||||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id)
|
|
||||||
caster.applyBuff(Buff.shieldThrowShield.id, caster.id) // NOTE: duplicated on purpose
|
|
||||||
}
|
|
||||||
|
|
||||||
const shieldThrowFirstAfter = function shieldThrowFirstAfter(projectile, homingTarget) {
|
|
||||||
projectile.destination = null
|
|
||||||
projectile.homingTarget = caster
|
|
||||||
onTheWayBack = true
|
|
||||||
collided.clear()
|
|
||||||
projectile.after = shieldThrowSecondAfter
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
after: shieldThrowFirstAfter,
|
|
||||||
logic: accelerateLogic,
|
|
||||||
onCollide: shieldThrowCollision,
|
|
||||||
owner: caster.id,
|
|
||||||
position: caster.position.clone(),
|
|
||||||
radius: ability.radius,
|
|
||||||
speed: ability.speed,
|
|
||||||
visionRange: ability.radius * 1.5,
|
|
||||||
})
|
|
||||||
|
|
||||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
|
||||||
caster.game?.spawnProjectile(projectile)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
static straightShot = new Ability({
|
|
||||||
id: 'straight_shot',
|
|
||||||
name: 'Straight Shot',
|
|
||||||
castTime: 0.25,
|
|
||||||
cooldown: 1,
|
|
||||||
damage: 83,
|
|
||||||
radius: 60,
|
|
||||||
range: 1200,
|
|
||||||
visualRadius: 20,
|
|
||||||
speed: 2000,
|
|
||||||
effect: function straightShotEffect(caster, cursor) {
|
|
||||||
const ability = this
|
|
||||||
const straightShotCollision = function straightShotCollision(projectile, collidingEntity) {
|
|
||||||
if (projectile.game == null) { return }
|
|
||||||
if (collidingEntity == null) { return }
|
|
||||||
if (collidingEntity.id == caster.id) { return }
|
|
||||||
if (collidingEntity.team == (caster.team ?? 'unknown')) { return }
|
|
||||||
|
|
||||||
collidingEntity.damage(ability.damage, caster)
|
|
||||||
projectile.despawn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
onCollide: straightShotCollision,
|
|
||||||
owner: caster.id,
|
|
||||||
position: caster.position.clone(),
|
|
||||||
radius: ability.radius,
|
|
||||||
speed: ability.speed,
|
|
||||||
visualRadius: ability.visualRadius,
|
|
||||||
})
|
|
||||||
|
|
||||||
projectile.destination = caster.position.clone().add(cursor.clone().sub(caster.position).normalize().multiplyScalar(ability.range + caster.radius))
|
|
||||||
caster.game?.spawnProjectile(projectile)
|
|
||||||
caster.cooldown(ability.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
export default class Buff {
|
|
||||||
id = `ability-${Buff.nextId()}`
|
|
||||||
static nextId() { return this.#nextUniqueId++ }
|
|
||||||
static #nextUniqueId = 0
|
|
||||||
|
|
||||||
name = 'Buff'
|
|
||||||
|
|
||||||
damageMultiplier = null
|
|
||||||
duration = 0
|
|
||||||
shield = null
|
|
||||||
|
|
||||||
#effect = null
|
|
||||||
|
|
||||||
get effect() { return this.#effect ?? Buff.noEffect }
|
|
||||||
set effect(value) { this.#effect = value }
|
|
||||||
|
|
||||||
static get noEffect() { return function noEffect() {} }
|
|
||||||
|
|
||||||
constructor(options = {}) {
|
|
||||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
static exposed = new Buff({
|
|
||||||
id: 'exposed',
|
|
||||||
name: 'Exposed',
|
|
||||||
duration: 4,
|
|
||||||
onHitMultiplier: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
static shieldThrowShield = new Buff({
|
|
||||||
id: 'shield_throw_shield',
|
|
||||||
name: 'Shield (of Shield Throw)',
|
|
||||||
duration: 5,
|
|
||||||
shield: 200,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
-886
@@ -1,886 +0,0 @@
|
|||||||
import { Vector2 } from 'three'
|
|
||||||
import Buff from './buff.js'
|
|
||||||
import Pathfind from './pathfind.js'
|
|
||||||
import Projectile from './projectile.js'
|
|
||||||
import SAT from 'sat'
|
|
||||||
import SATX from './satx.js'
|
|
||||||
import Team from './team.js'
|
|
||||||
|
|
||||||
export default class Entity {
|
|
||||||
id = `entity-${Entity.nextId()}`
|
|
||||||
static nextId() { return this.#nextUniqueId++ }
|
|
||||||
static #nextUniqueId = 0
|
|
||||||
|
|
||||||
abilities = {}
|
|
||||||
buffs = []
|
|
||||||
casting = null
|
|
||||||
cooldowns = {}
|
|
||||||
dead = false
|
|
||||||
ghosting = false
|
|
||||||
health = null
|
|
||||||
height = null
|
|
||||||
maxHealth = 1
|
|
||||||
model = null
|
|
||||||
position = null
|
|
||||||
radius = 0
|
|
||||||
rotation = 0
|
|
||||||
speed = 400
|
|
||||||
team = Team.neutral
|
|
||||||
visionRange = 900
|
|
||||||
visualRadius = null
|
|
||||||
|
|
||||||
#attacking = false
|
|
||||||
#bbox = new Float32Array(4)
|
|
||||||
#colliders = []
|
|
||||||
#collision = true
|
|
||||||
#dest = null
|
|
||||||
#entitiesInVision = []
|
|
||||||
#game = null
|
|
||||||
#ghostable = true
|
|
||||||
#logic = null
|
|
||||||
#moving = false
|
|
||||||
#noPathfindingUntil = 0
|
|
||||||
#path = []
|
|
||||||
#pathfindingCooldown = 0
|
|
||||||
#pathfindingObstacleLimit = null
|
|
||||||
#projectilesInVision = []
|
|
||||||
#spawnPosition = new Vector2()
|
|
||||||
|
|
||||||
static bbox(x, y, radius) {
|
|
||||||
return new Float32Array([y + radius, x + radius, y - radius, x - radius])
|
|
||||||
}
|
|
||||||
|
|
||||||
static collider(x, y, radius) {
|
|
||||||
return new SAT.Circle(new SAT.Vector(x, y), radius)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliberate code duplication for performance
|
|
||||||
static tunnelCollider(fromX, fromY, toX, toY, radius) {
|
|
||||||
if (radius <= 0) {
|
|
||||||
return SATX.line(fromX, fromY, toX, toY)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sides = new Float32Array(5)
|
|
||||||
sides[0] = toX - fromX
|
|
||||||
sides[1] = toY - fromY
|
|
||||||
sides[4] = Math.hypot(sides[0], sides[1])
|
|
||||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
|
||||||
sides[3] = (sides[0] / sides[4]) * radius
|
|
||||||
|
|
||||||
return new SAT.Polygon(new SAT.Vector(fromX - sides[2], fromY - sides[3]), [
|
|
||||||
new SAT.Vector(),
|
|
||||||
new SAT.Vector(sides[0], sides[1]),
|
|
||||||
new SAT.Vector(sides[0] + (2 * sides[2]), sides[1] + (2 * sides[3])),
|
|
||||||
new SAT.Vector(2 * sides[2], 2 * sides[3]),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliberate code duplication for performance
|
|
||||||
static tunnelVertices(fromX, fromY, toX, toY, radius) {
|
|
||||||
const sides = new Float32Array(5)
|
|
||||||
sides[0] = toX - fromX
|
|
||||||
sides[1] = toY - fromY
|
|
||||||
sides[4] = Math.hypot(sides[0], sides[1])
|
|
||||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
|
||||||
sides[3] = (sides[0] / sides[4]) * radius
|
|
||||||
|
|
||||||
return [
|
|
||||||
new Vector2(fromX - sides[2], fromY - sides[3]),
|
|
||||||
new Vector2(fromX - sides[2] + sides[0], fromY - sides[3] + sides[1]),
|
|
||||||
new Vector2(fromX + sides[2] + sides[0], fromY + sides[3] + sides[1]),
|
|
||||||
new Vector2(fromX + sides[2], fromY + sides[3]),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliberate code duplication for performance
|
|
||||||
static tunnelBbox(fromX, fromY, toX, toY, radius) {
|
|
||||||
if (radius <= 0) {
|
|
||||||
return new Float32Array([
|
|
||||||
Math.max(fromY, toY),
|
|
||||||
Math.max(fromX, toX),
|
|
||||||
Math.min(fromY, toY),
|
|
||||||
Math.min(fromX, toX),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const sides = new Float32Array(5)
|
|
||||||
sides[0] = toX - fromX
|
|
||||||
sides[1] = toY - fromY
|
|
||||||
sides[4] = Math.hypot(sides[0], sides[1])
|
|
||||||
sides[2] = (sides[1] / sides[4]) * -radius // optimization: negation and swapping rotates
|
|
||||||
sides[3] = (sides[0] / sides[4]) * radius
|
|
||||||
|
|
||||||
const offsetX = fromX + sides[0]
|
|
||||||
const x1 = fromX - sides[2]
|
|
||||||
const x2 = fromX + sides[2]
|
|
||||||
const x3 = offsetX - sides[2]
|
|
||||||
const x4 = offsetX + sides[2]
|
|
||||||
|
|
||||||
const offsetY = fromY + sides[1]
|
|
||||||
const y1 = fromY - sides[3]
|
|
||||||
const y2 = fromY + sides[3]
|
|
||||||
const y3 = offsetY - sides[3]
|
|
||||||
const y4 = offsetY + sides[3]
|
|
||||||
|
|
||||||
return new Float32Array([
|
|
||||||
Math.max(y1, y2, y3, y4),
|
|
||||||
Math.max(x1, x2, x3, x4),
|
|
||||||
Math.min(y1, y2, y3, y4),
|
|
||||||
Math.min(x1, x2, x3, x4),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(options = {}) {
|
|
||||||
Object.entries(options).forEach(([key, value]) => this[key] = value)
|
|
||||||
if (this.position == null) {
|
|
||||||
this.position = this.#spawnPosition.clone()
|
|
||||||
}
|
|
||||||
if (this.health == null) {
|
|
||||||
this.health = this.maxHealth
|
|
||||||
}
|
|
||||||
if (this.visualRadius == null) {
|
|
||||||
this.visualRadius = this.radius
|
|
||||||
}
|
|
||||||
if (this.height == null) {
|
|
||||||
this.height = this.visualRadius ?? this.radius
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#calculateCollider()
|
|
||||||
}
|
|
||||||
|
|
||||||
get attacking() { return this.#attacking }
|
|
||||||
get bbox() { return this.#bbox }
|
|
||||||
get collision() { return this.#collision }
|
|
||||||
get destination() { return this.#dest }
|
|
||||||
get entitiesInVision() { return this.#entitiesInVision }
|
|
||||||
get game() { return this.#game }
|
|
||||||
get ghostable() { return this.#ghostable }
|
|
||||||
get logic() { return this.#logic }
|
|
||||||
get pathfindingCooldown() { return this.#pathfindingCooldown }
|
|
||||||
get pathfindingObstacleLimit() { return this.#pathfindingObstacleLimit }
|
|
||||||
get projectilesInVision() { return this.#projectilesInVision }
|
|
||||||
get spawnPosition() { return this.#spawnPosition }
|
|
||||||
get x() { return this.position.x }
|
|
||||||
get y() { return this.position.y }
|
|
||||||
|
|
||||||
set bbox(value) { this.#bbox = value }
|
|
||||||
set collision(value) { this.#collision = value }
|
|
||||||
set destination(value) { this.#dest = value }
|
|
||||||
set game(value) { this.#game = value }
|
|
||||||
set ghostable(value) { this.#ghostable = value }
|
|
||||||
set logic(value) { this.#logic = value }
|
|
||||||
set pathfindingCooldown(value) { this.#pathfindingCooldown = value }
|
|
||||||
set pathfindingObstacleLimit(value) { this.#pathfindingObstacleLimit = value }
|
|
||||||
set spawnPosition(value) { this.#spawnPosition = value }
|
|
||||||
set x(value) { this.position.x = value }
|
|
||||||
set y(value) { this.position.y = value }
|
|
||||||
|
|
||||||
attackAction(cursor) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
this.moveAction(cursor, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
castAction(slot, cursor, halt = false) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
const ability = this.ability(slot)
|
|
||||||
if (ability == null) { return }
|
|
||||||
|
|
||||||
if (this.casting != null) {
|
|
||||||
const abilityBeingCasted = this.game?.abilities.find((it) => it.id == this.casting.ability)
|
|
||||||
if (abilityBeingCasted != null && abilityBeingCasted.id == ability.id) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (halt) {
|
|
||||||
this.#moving = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position
|
|
||||||
if (targetPosition instanceof Vector2) {
|
|
||||||
this.rotation = targetPosition.clone().sub(this.position).angle()
|
|
||||||
}
|
|
||||||
|
|
||||||
const cooldown = this.game?.secToTick(ability.cooldown) ?? 0
|
|
||||||
const lastCast = this.cooldowns[ability.id]
|
|
||||||
const timestamp = this.game?.currentTick ?? 0
|
|
||||||
if (lastCast != null && lastCast + cooldown > timestamp) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ability.castTime == null) {
|
|
||||||
this.#castingVision()
|
|
||||||
ability.effect(this, cursor)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.casting = { ability: ability.id, cursor, timestamp }
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
haltAction() {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
this.#moving = false
|
|
||||||
}
|
|
||||||
|
|
||||||
moveAction(cursor, attack = false) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
if (this.casting != null && this.game?.abilities.find((it) => it.id == this.casting.ability)?.moveCancelable) {
|
|
||||||
if (!attack && !(this.casting != null && this.casting.ability == this.abilities[0])) {
|
|
||||||
this.casting = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#attacking = attack
|
|
||||||
this.#moving = true
|
|
||||||
this.#dest = cursor.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAction() {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
this.casting = null
|
|
||||||
this.#moving = true
|
|
||||||
this.#attacking = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Actions above --- //
|
|
||||||
|
|
||||||
ability(slot) {
|
|
||||||
if (this.abilities[slot] != null) {
|
|
||||||
return this.game?.abilities.find((it) => it.id == this.abilities[slot])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adjustWaypoint(waypoint, direction) {
|
|
||||||
return SATX.clamp(
|
|
||||||
waypoint.clone().add(direction.clone().multiplyScalar(this.radius)),
|
|
||||||
this.game?.width,
|
|
||||||
this.game?.height,
|
|
||||||
this.radius,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyBuff(id, sourceId = null) {
|
|
||||||
const buff = (this.game?.buffs ?? []).find((it) => it.id == id)
|
|
||||||
if (buff == null) { return false }
|
|
||||||
|
|
||||||
const index = this.buffs.findIndex((it) => it.id == id)
|
|
||||||
const source = sourceId ?? this.id
|
|
||||||
const timestamp = this.game?.currentTick ?? 0
|
|
||||||
|
|
||||||
if (index < 0) {
|
|
||||||
const entityBuff = { id, source, timestamp }
|
|
||||||
if (buff.shield != null) {
|
|
||||||
entityBuff.shield = buff.shield
|
|
||||||
}
|
|
||||||
|
|
||||||
this.buffs.push(entityBuff)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.buffs[index].timestamp = timestamp
|
|
||||||
this.buffs[index].source = source
|
|
||||||
if (buff.shield != null) {
|
|
||||||
this.buffs[index].shield = (this.buffs[index].shield ?? 0) + buff.shield
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collidables() {
|
|
||||||
return this.customBboxCollidables(this.bbox)
|
|
||||||
}
|
|
||||||
|
|
||||||
collider() {
|
|
||||||
return this.#colliders.at(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
colliders() {
|
|
||||||
return this.#colliders
|
|
||||||
}
|
|
||||||
|
|
||||||
cooldown(id) {
|
|
||||||
this.cooldowns[id] = this.game?.currentTick ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
closestTargetTo(cursor, range, targetAllies = false) {
|
|
||||||
const entities = this.game?.entities
|
|
||||||
if (entities == null) { return }
|
|
||||||
const targetsInRange = targetAllies
|
|
||||||
? entities.filter((it) => !it.dead && this.team == it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
|
|
||||||
: entities.filter((it) => !it.dead && this.team != it.team && it.distanceTo(cursor) <= range + this.radius + it.radius)
|
|
||||||
|
|
||||||
if (targetsInRange.length < 1) { return }
|
|
||||||
|
|
||||||
const absoluteClosestTarget = targetsInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
|
|
||||||
const entityIdsInDirectVision = this.#entitiesInVision
|
|
||||||
if (entityIdsInDirectVision.includes(absoluteClosestTarget.id)) {
|
|
||||||
return absoluteClosestTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleEntityIds = this.visibleEntities()
|
|
||||||
const visibleEntitiesInRange = targetsInRange.filter((it) => visibleEntityIds.includes(it.id))
|
|
||||||
|
|
||||||
return visibleEntitiesInRange.reduce((e1, e2) => (e1?.distanceTo(cursor) ?? Infinity) < e2.distanceTo(cursor) ? e1 : e2, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
customBboxCollidables(bbox) {
|
|
||||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
|
||||||
return entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
|
||||||
}
|
|
||||||
|
|
||||||
damage(amount, source = null) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
let customMultipliers = 0
|
|
||||||
if (this.hasBuff(Buff.exposed.id)) {
|
|
||||||
const buff = this.getBuff(Buff.exposed.id)
|
|
||||||
if (buff.source == source?.id) {
|
|
||||||
customMultipliers += (buff.onHitMultiplier - 1)
|
|
||||||
this.removeBuff(Buff.exposed.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffs = this.buffs ?? []
|
|
||||||
const damageMultiplerBuffs = buffs.map((it) => it.getBuff).filter((it) => it != null && it.damageMultiplier != null)
|
|
||||||
const buffPassiveDamageMultiplier = damageMultiplerBuffs.reduce((it) => it.damageMultiplier - 1, 0)
|
|
||||||
|
|
||||||
const damageMultipler = 1 + buffPassiveDamageMultiplier + customMultipliers
|
|
||||||
let damage = amount * damageMultipler
|
|
||||||
|
|
||||||
if (damage >= 0) {
|
|
||||||
buffs.filter((it) => it.shield != null && it.shield > 0).forEach((it) => {
|
|
||||||
if (damage <= 0) { return }
|
|
||||||
|
|
||||||
const shielded = Math.max(0, Math.min(damage, it.shield))
|
|
||||||
it.shield -= shielded
|
|
||||||
damage -= shielded
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.health = Math.min(Math.max(0, this.health - damage), this.maxHealth)
|
|
||||||
}
|
|
||||||
|
|
||||||
despawn() {
|
|
||||||
this.game?.despawn(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
distanceTo(cursor) {
|
|
||||||
return this.position.distanceTo(cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
forceCast(abilityId, cursor) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
const ability = this.game?.abilities.find((it) => it.id == abilityId)
|
|
||||||
if (ability == null) { return }
|
|
||||||
|
|
||||||
const targetPosition = (cursor instanceof Vector2) ? cursor : this.game?.entities.find((it) => it.id == cursor)?.position
|
|
||||||
if (targetPosition instanceof Vector2) {
|
|
||||||
this.rotation = targetPosition.clone().sub(this.position).angle()
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = this.game?.currentTick ?? 0
|
|
||||||
|
|
||||||
if (ability.castTime == null) {
|
|
||||||
this.#castingVision()
|
|
||||||
ability.effect(this, cursor)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.casting = { ability: ability.id, cursor, timestamp }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
futureCollidables(futurePosition) {
|
|
||||||
return this.customBboxCollidables(new Float32Array([
|
|
||||||
futurePosition.y + this.radius,
|
|
||||||
futurePosition.x + this.radius,
|
|
||||||
futurePosition.y - this.radius,
|
|
||||||
futurePosition.x - this.radius,
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
getBuff(id) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
const entityBuff = this.buffs.find((it) => it.id == id)
|
|
||||||
if (entityBuff == null) { return }
|
|
||||||
|
|
||||||
const buffDefinition = this.game?.buffs.find((it) => it.id == entityBuff.id)
|
|
||||||
if (buffDefinition == null) { return }
|
|
||||||
|
|
||||||
return { ...buffDefinition, ...entityBuff }
|
|
||||||
}
|
|
||||||
|
|
||||||
getBuffs() {
|
|
||||||
return this.buffs.map((it) => this.getBuff(it.id)).filter((it) => it != null)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasBuff(id) {
|
|
||||||
if (this.dead) { return false }
|
|
||||||
|
|
||||||
return this.buffs.some((it) => it.id == id) && this.game?.buffs.some((it) => it.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
heal(amount, _source) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
this.health = Math.min(Math.max(0, this.health + amount), this.maxHealth)
|
|
||||||
}
|
|
||||||
|
|
||||||
fixPosition() {
|
|
||||||
const fixedPosition = this.fixFuturePosition(this.position)
|
|
||||||
if (this.position.equals(fixedPosition)) { return }
|
|
||||||
|
|
||||||
this.setPosition(fixedPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
fixFuturePosition(futurePosition) {
|
|
||||||
const maxX = this.game?.width ?? Infinity
|
|
||||||
const maxY = this.game?.height ?? Infinity
|
|
||||||
const radius = this.radius
|
|
||||||
if (!this.willCollide(futurePosition)) {
|
|
||||||
return SATX.clamp(futurePosition, maxX, maxY, radius)
|
|
||||||
}
|
|
||||||
|
|
||||||
let direction = new Vector2(0, 5)
|
|
||||||
let multiplier = 1
|
|
||||||
const rotationSlices = 16
|
|
||||||
const origin = new Vector2()
|
|
||||||
|
|
||||||
for (let limit = 1; limit <= 10000; limit++) {
|
|
||||||
const rads = (limit % rotationSlices) * 2 * Math.PI / rotationSlices
|
|
||||||
const offset = direction.clone().rotateAround(origin, rads).multiplyScalar(multiplier)
|
|
||||||
const position = SATX.clamp(futurePosition.clone().add(offset), maxX, maxY, radius)
|
|
||||||
if (!this.willCollide(position)) {
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limit % rotationSlices == 0) {
|
|
||||||
multiplier++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error({ error: 'position_unfixable', id: this.id, futurePosition })
|
|
||||||
}
|
|
||||||
|
|
||||||
isColliding() {
|
|
||||||
const collidables = this.collidables()
|
|
||||||
if (collidables.length < 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const colliders = collidables.map((it) => it.colliders()).flat()
|
|
||||||
const collider = this.collider()
|
|
||||||
|
|
||||||
return colliders.some((it) => SATX.collideObject(collider, it))
|
|
||||||
}
|
|
||||||
|
|
||||||
isInLineOfSight(destination, position = this.position) {
|
|
||||||
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
|
|
||||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
|
||||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
|
||||||
if (bboxCheckedObstacles.length < 1) { return true }
|
|
||||||
|
|
||||||
const colliders = bboxCheckedObstacles.map((it) => it.colliders()).flat()
|
|
||||||
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
|
|
||||||
return !colliders.some((it) => SATX.collideObject(collider, it))
|
|
||||||
}
|
|
||||||
|
|
||||||
isInLineOfVision(destination) {
|
|
||||||
const bbox = Entity.tunnelBbox(this.position.x, this.position.y, destination.x, destination.y, 0)
|
|
||||||
const terrains = this.game?.terrains ?? []
|
|
||||||
const bboxCheckedObstacles = terrains.filter((it) => SATX.bboxCheck(bbox, it.bbox))
|
|
||||||
if (bboxCheckedObstacles.length < 1) { return true }
|
|
||||||
|
|
||||||
const inWallVisionBypassRadius = Math.max(0, this.radius - 1)
|
|
||||||
const posCollider = Entity.collider(this.position.x, this.position.y, inWallVisionBypassRadius)
|
|
||||||
const posBbox = Entity.bbox(this.position.x, this.position.y, inWallVisionBypassRadius)
|
|
||||||
const unpassableTerrain = bboxCheckedObstacles.filter((it) => !SATX.bboxCheck(posBbox, it.bbox) || !it.colliders().some((c) => SATX.collideObject(posCollider, c)))
|
|
||||||
|
|
||||||
const colliders = unpassableTerrain.map((it) => it.colliders()).flat()
|
|
||||||
const collider = Entity.tunnelCollider(this.position.x, this.position.y, destination.x, destination.y, 0)
|
|
||||||
return !colliders.some((it) => SATX.collideObject(collider, it))
|
|
||||||
}
|
|
||||||
|
|
||||||
obstaclesInStraightPath(destination, position = this.position) {
|
|
||||||
const bbox = Entity.tunnelBbox(position.x, position.y, destination.x, destination.y, this.radius)
|
|
||||||
const entitiesAndTerrains = (this.game?.entities ?? []).filter((it) => it.id != this.id).concat(this.game?.terrains ?? [])
|
|
||||||
const bboxCheckedObstacles = entitiesAndTerrains.filter((it) => !it.dead && it.collision && !((this.ghosting && it.ghostable) || (this.ghostable && it.ghosting)) && SATX.bboxCheck(bbox, it.bbox))
|
|
||||||
if (bboxCheckedObstacles.length < 1) { return [] }
|
|
||||||
|
|
||||||
const collider = Entity.tunnelCollider(position.x, position.y, destination.x, destination.y, this.radius)
|
|
||||||
return bboxCheckedObstacles.filter((obstacle) => obstacle.colliders().some((it) => SATX.collideObject(collider, it)))
|
|
||||||
}
|
|
||||||
|
|
||||||
removeBuff(id) {
|
|
||||||
if (this.dead) { return }
|
|
||||||
|
|
||||||
this.buffs = this.buffs.filter((it) => it.id != id)
|
|
||||||
}
|
|
||||||
|
|
||||||
respawn() {
|
|
||||||
this.setPosition(this.#spawnPosition)
|
|
||||||
this.health = this.maxHealth
|
|
||||||
this.dead = false
|
|
||||||
}
|
|
||||||
|
|
||||||
revive(startingHealth = null) {
|
|
||||||
this.dead = false
|
|
||||||
const health = (startingHealth ?? this.maxHealth)
|
|
||||||
this.health = Math.max(0, Math.min(health, this.maxHealth))
|
|
||||||
|
|
||||||
this.#calculateCollider()
|
|
||||||
this.#calculateVision()
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosition(vector) {
|
|
||||||
this.position.copy(vector)
|
|
||||||
this.#calculateCollider()
|
|
||||||
}
|
|
||||||
|
|
||||||
teleport(cursor) {
|
|
||||||
this.setPosition(this.fixFuturePosition(cursor))
|
|
||||||
}
|
|
||||||
|
|
||||||
unadjustedWaypoints() {
|
|
||||||
const numberOfWaypoints = 8
|
|
||||||
const margin = 1
|
|
||||||
const enclosingRegularPolygonRadius = SATX.enclosingRegularPolygonRadius(numberOfWaypoints)
|
|
||||||
const radius = this.radius * enclosingRegularPolygonRadius + margin
|
|
||||||
const baseWaypoint = new Vector2(radius, 0)
|
|
||||||
const waypoints = []
|
|
||||||
|
|
||||||
const origin = new Vector2
|
|
||||||
const unitOfRotation = (Math.PI * 2 / numberOfWaypoints)
|
|
||||||
for (let i = 0; i < numberOfWaypoints; i++) {
|
|
||||||
waypoints.push(baseWaypoint.clone().rotateAround(origin, unitOfRotation * i))
|
|
||||||
}
|
|
||||||
|
|
||||||
return waypoints.map((w) => [
|
|
||||||
w.clone().add(this.position),
|
|
||||||
w.clone().normalize().multiplyScalar(enclosingRegularPolygonRadius),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
this.#calculateVision()
|
|
||||||
this.#checkHealth()
|
|
||||||
if (!this.dead) {
|
|
||||||
this.#cast()
|
|
||||||
this.#move()
|
|
||||||
this.#tickBuffs()
|
|
||||||
this.fixPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#logic != null) {
|
|
||||||
this.#logic()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleEntities() {
|
|
||||||
return this.game?.visibleEntities(this.team)
|
|
||||||
}
|
|
||||||
|
|
||||||
willCollide(futurePosition) {
|
|
||||||
const collidables = this.futureCollidables(futurePosition)
|
|
||||||
if (collidables.length < 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const colliders = collidables.map((it) => it.colliders()).flat()
|
|
||||||
const collider = Entity.collider(futurePosition.x, futurePosition.y, this.radius)
|
|
||||||
|
|
||||||
return colliders.some((it) => SATX.collideObject(collider, it))
|
|
||||||
}
|
|
||||||
|
|
||||||
#calculateBbox() {
|
|
||||||
this.bbox[0] = this.position.y + this.radius
|
|
||||||
this.bbox[1] = this.position.x + this.radius
|
|
||||||
this.bbox[2] = this.position.y - this.radius
|
|
||||||
this.bbox[3] = this.position.x - this.radius
|
|
||||||
}
|
|
||||||
|
|
||||||
#calculateCollider() {
|
|
||||||
this.#calculateBbox()
|
|
||||||
this.#colliders = [Entity.collider(this.position.x, this.position.y, this.radius)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#calculateVision() {
|
|
||||||
if (this.dead) {
|
|
||||||
this.#entitiesInVision = [this.id]
|
|
||||||
this.#projectilesInVision = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entities = this.game?.entities ?? []
|
|
||||||
const projectiles = this.game?.projectiles ?? []
|
|
||||||
|
|
||||||
const entitiesInVisionRange = entities.filter((it) => it.id != this.id && it.distanceTo(this.position) <= this.visionRange + it.radius)
|
|
||||||
const entitiesInLineOfSight = entitiesInVisionRange.filter((it) => this.isInLineOfVision(it.position))
|
|
||||||
|
|
||||||
const projectilesInVisionRange = projectiles.filter((it) => this.position.distanceTo(it.position) <= this.visionRange + it.radius)
|
|
||||||
const projectilesInLineOfSight = projectilesInVisionRange.filter((it) => it.visibleThroughTerrain || this.isInLineOfVision(it.position))
|
|
||||||
|
|
||||||
this.#entitiesInVision = entitiesInLineOfSight.concat([this]).map((it) => it.id)
|
|
||||||
this.#projectilesInVision = projectilesInLineOfSight.map((it) => it.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#cast() {
|
|
||||||
if (this.casting == null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = this.game?.abilities.find((it) => it.id == this.casting.ability)
|
|
||||||
if (ability == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const castTime = this.game?.secToTick(ability.castTime) ?? 0
|
|
||||||
const castStart = this.casting.timestamp
|
|
||||||
const timestamp = this.game?.currentTick ?? 0
|
|
||||||
if (castStart + castTime > timestamp) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
ability.effect(this, this.casting.cursor)
|
|
||||||
|
|
||||||
if (this.casting.ability == ability.id) {
|
|
||||||
this.casting = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#castingVision()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
#castingVision() {
|
|
||||||
const enemyTeam = this.team == Team.blue ? Team.red : (this.team == Team.red ? Team.blue : null)
|
|
||||||
|
|
||||||
const enemiesNearby = (this.game?.entities ?? []).some((it) => !it.dead && (enemyTeam == null || it.team == enemyTeam) && it.distanceTo(this.position) <= (it.visionRange + this.radius))
|
|
||||||
if (enemiesNearby) {
|
|
||||||
const radius = 300
|
|
||||||
const duration = this.game?.secToTick(2) ?? 0
|
|
||||||
if (duration <= 0) { return }
|
|
||||||
|
|
||||||
const currentTick = this.game?.currentTick ?? 0
|
|
||||||
const despawnAfter = currentTick + duration
|
|
||||||
|
|
||||||
const castingVisionLogic = function castingVisionLogic(projectile) {
|
|
||||||
const currentTick = projectile.game?.currentTick ?? 0
|
|
||||||
if (currentTick > despawnAfter) {
|
|
||||||
projectile.despawn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectile = new Projectile({
|
|
||||||
logic: castingVisionLogic,
|
|
||||||
owner: this.id,
|
|
||||||
position: this.position.clone(),
|
|
||||||
visionRange: radius,
|
|
||||||
team: enemyTeam,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.game?.spawnProjectile(projectile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#checkHealth() {
|
|
||||||
if (!this.dead && this.health <= 0) {
|
|
||||||
this.dead = true
|
|
||||||
this.buffs = []
|
|
||||||
}
|
|
||||||
else if (this.dead && this.health > 0) {
|
|
||||||
this.health = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#move(distanceTraveled = 0) {
|
|
||||||
if (this.casting != null) { return false }
|
|
||||||
const currentTick = this.game?.currentTick ?? 0
|
|
||||||
|
|
||||||
if (this.#attacking) {
|
|
||||||
const cursor = this.#dest ?? this.position
|
|
||||||
const basicAttack = this.ability('a')
|
|
||||||
if (basicAttack != null) {
|
|
||||||
const target = this.closestTargetTo(cursor, 500)
|
|
||||||
if (target != null && this.distanceTo(target.position) < basicAttack.range + this.radius + target.radius) {
|
|
||||||
const cooldown = this.game?.secToTick(basicAttack.cooldown) ?? 0
|
|
||||||
const lastCast = this.cooldowns[basicAttack.id]
|
|
||||||
if (lastCast != null && lastCast + cooldown > currentTick) { return false }
|
|
||||||
|
|
||||||
this.castAction('a', target.id, false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.#moving || this.#dest == null) { return false }
|
|
||||||
|
|
||||||
const fixedDest = this.fixFuturePosition(this.#dest)
|
|
||||||
const pathfinding = this.#noPathfindingUntil <= currentTick
|
|
||||||
const obstacles = new Map()
|
|
||||||
let pathGotObstructed = false
|
|
||||||
|
|
||||||
if (pathfinding && this.#path.length > 0) {
|
|
||||||
const sectionDest = this.#path.at(0)
|
|
||||||
const sectionObstacles = this.obstaclesInStraightPath(sectionDest)
|
|
||||||
if (sectionObstacles.length > 0) {
|
|
||||||
pathGotObstructed = true
|
|
||||||
for (const obstacle of sectionObstacles) {
|
|
||||||
if (!obstacles.has(obstacle.id)) {
|
|
||||||
obstacles.set(obstacle.id, obstacle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#path.length < 1 || !this.#path.at(-1).equals(fixedDest)) {
|
|
||||||
const lineOfSight = this.isInLineOfSight(fixedDest)
|
|
||||||
if (lineOfSight) {
|
|
||||||
this.#path = [fixedDest]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathfinding && (pathGotObstructed || this.#path.length < 1 || (this.#path.at(-1)?.distanceTo(fixedDest) ?? 0) > 0.01)) {
|
|
||||||
const start = SATX.vectorToFloat32Array(this.position)
|
|
||||||
const goal = SATX.vectorToFloat32Array(fixedDest)
|
|
||||||
const obstacleWaypoints = new Map()
|
|
||||||
const obstacleColliders = new Map()
|
|
||||||
const obstacleBboxes = new Map()
|
|
||||||
|
|
||||||
const initialObstaclesMargin = this.radius + 20
|
|
||||||
const initialObstacles = this.customBboxCollidables(new Float32Array([
|
|
||||||
this.position.y + initialObstaclesMargin,
|
|
||||||
this.position.x + initialObstaclesMargin,
|
|
||||||
this.position.y - initialObstaclesMargin,
|
|
||||||
this.position.x - initialObstaclesMargin,
|
|
||||||
]))
|
|
||||||
|
|
||||||
for (const obstacle of initialObstacles) {
|
|
||||||
if (!obstacles.has(obstacle.id)) {
|
|
||||||
obstacles.set(obstacle.id, obstacle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let failsafe = 0; failsafe <= (this.pathfindingObstacleLimit ?? 10); failsafe++) {
|
|
||||||
const obstaclesArray = Array.from(obstacles.values())
|
|
||||||
|
|
||||||
for (const obstacle of obstaclesArray) {
|
|
||||||
if (!obstacleWaypoints.has(obstacle.id)) {
|
|
||||||
const waypoint = obstacle.unadjustedWaypoints().map(([w, d]) => SATX.vectorToFloat32Array(this.adjustWaypoint(w, d)))
|
|
||||||
const bbox = obstacle.bbox
|
|
||||||
const colliders = obstacle.colliders()
|
|
||||||
obstacleWaypoints.set(obstacle.id, waypoint)
|
|
||||||
obstacleColliders.set(obstacle.id, colliders)
|
|
||||||
obstacleBboxes.set(obstacle.id, bbox)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const waypoints = [
|
|
||||||
start,
|
|
||||||
goal,
|
|
||||||
...Array.from(obstacleWaypoints.values()).flat()
|
|
||||||
]
|
|
||||||
|
|
||||||
const bboxesSize = obstacleBboxes.size * 5
|
|
||||||
const bboxes = new Float32Array(bboxesSize)
|
|
||||||
let i = 0
|
|
||||||
for (const obstacle of obstacleBboxes.values()) {
|
|
||||||
bboxes[i] = obstacle[0]
|
|
||||||
bboxes[i + 1] = obstacle[1]
|
|
||||||
bboxes[i + 2] = obstacle[2]
|
|
||||||
bboxes[i + 3] = obstacle[3]
|
|
||||||
bboxes[i + 4] = Math.floor(i / 5)
|
|
||||||
i += 5
|
|
||||||
}
|
|
||||||
|
|
||||||
const colliders = Array.from(obstacleColliders.values())
|
|
||||||
const graph = Pathfind.buildGraph(waypoints, bboxes, colliders, this.radius)
|
|
||||||
const path = Pathfind.shortestPath(graph, start, goal).map((waypoint) => new Vector2(waypoint[0], waypoint[1]))
|
|
||||||
|
|
||||||
if (path.length == 0) {
|
|
||||||
// WARNING: This unsets the destination because if an unreachable spot is clicked,
|
|
||||||
// pathfinding cycles all obstacles forever. A possible alternative could
|
|
||||||
// be setting a pathfinding timeout, but then moveAction must reset that!
|
|
||||||
this.#dest = null
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let obstacleInPath = false
|
|
||||||
let lastSection = this.position
|
|
||||||
for (const section of path) {
|
|
||||||
const sectionObstacles = this.obstaclesInStraightPath(section, lastSection)
|
|
||||||
if (sectionObstacles.length > 0) {
|
|
||||||
obstacleInPath = true
|
|
||||||
for (const obstacle of sectionObstacles) {
|
|
||||||
if (!obstacles.has(obstacle.id)) {
|
|
||||||
obstacles.set(obstacle.id, obstacle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSection = section
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#path = path
|
|
||||||
if (!obstacleInPath) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathfinding && this.pathfindingCooldown > 0) {
|
|
||||||
this.#noPathfindingUntil = currentTick + (this.game?.secToTick(this.pathfindingCooldown) ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.#path.length > 0) {
|
|
||||||
const speed = (this.speed / (this.game?.tickRate ?? 1)) - distanceTraveled
|
|
||||||
const destination = this.#path.at(0)
|
|
||||||
const difference = destination.clone().sub(this.position)
|
|
||||||
const distance = difference.length()
|
|
||||||
const direction = difference.clone().normalize()
|
|
||||||
const stepTaken = this.position.clone().add(direction.multiplyScalar(speed))
|
|
||||||
const position = distance <= speed ? destination : stepTaken
|
|
||||||
const rotation = direction.angle()
|
|
||||||
|
|
||||||
this.rotation = rotation
|
|
||||||
|
|
||||||
if (!this.willCollide(position)) {
|
|
||||||
this.setPosition(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.position.equals(destination)) {
|
|
||||||
this.#path = this.#path.slice(1)
|
|
||||||
if (this.#path.length > 0) {
|
|
||||||
this.#move(distance)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.#dest = null
|
|
||||||
this.#moving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#tickBuff(index) {
|
|
||||||
if (this.buffs[index] == null) { return }
|
|
||||||
const buff = this.getBuff(this.buffs[index].id)
|
|
||||||
const duration = this.game?.secToTick(buff.duration) ?? 0
|
|
||||||
const currentTick = this.game?.currentTick ?? 0
|
|
||||||
|
|
||||||
if (buff.timestamp + duration < currentTick) {
|
|
||||||
this.removeBuff(buff.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#tickBuffs() {
|
|
||||||
this.buffs.forEach((_v, i) => this.#tickBuff(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
|
||||||
import { Vector2 } from 'three'
|
|
||||||
import Ability from './ability.js'
|
|
||||||
import Buff from './buff.js'
|
|
||||||
import Entity from './entity.js'
|
|
||||||
import Projectile from './projectile.js'
|
|
||||||
import Terrain from './terrain.js'
|
|
||||||
|
|
||||||
export default class Game {
|
|
||||||
id = crypto.randomUUID()
|
|
||||||
|
|
||||||
abilities = Object.values({...Ability})
|
|
||||||
buffs = Object.values({...Buff})
|
|
||||||
currentTick = 0
|
|
||||||
entities = []
|
|
||||||
height = 0
|
|
||||||
projectiles = []
|
|
||||||
terrains = []
|
|
||||||
tickRate = 30
|
|
||||||
width = 0
|
|
||||||
|
|
||||||
#gameLoopIntervalId = null
|
|
||||||
#logic = null
|
|
||||||
#nextTickAt = 0
|
|
||||||
#startTimestamp = 0
|
|
||||||
#subscriptions = new Map()
|
|
||||||
#tickBudget = 1000 / this.tickRate
|
|
||||||
|
|
||||||
get logic() { return this.#logic }
|
|
||||||
get tickBudget() { return this.#tickBudget }
|
|
||||||
get subscriptions() { return this.#subscriptions }
|
|
||||||
|
|
||||||
set logic(value) { this.#logic = value }
|
|
||||||
|
|
||||||
action(id, options) {
|
|
||||||
const entity = this.entities.find((it) => it.id == id)
|
|
||||||
if (entity == null) {
|
|
||||||
console.info({ info: 'action_invalid_id', id, options })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.action == 'attack') { entity.attackAction(new Vector2(options.x, options.y)) }
|
|
||||||
if (options.action == 'cast') { entity.castAction(options.slot, new Vector2(options.x, options.y)) }
|
|
||||||
if (options.action == 'halt') { entity.haltAction() }
|
|
||||||
if (options.action == 'stop') { entity.stopAction() }
|
|
||||||
if (options.action == 'move') { entity.moveAction(new Vector2(options.x, options.y)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
addTerrain(terrain) {
|
|
||||||
this.terrains.push(terrain)
|
|
||||||
}
|
|
||||||
|
|
||||||
despawn(object) {
|
|
||||||
if (object instanceof Entity) { this.despawnEntity(object) }
|
|
||||||
else if (object instanceof Terrain) { this.removeTerrain(object) }
|
|
||||||
else if (object instanceof Projectile) { this.despawnProjectile(object) }
|
|
||||||
else { console.error({ error: 'despawn_unknown_object', object }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
despawnEntity(entity) {
|
|
||||||
this.entities = this.entities.filter((e) => e.id != entity.id)
|
|
||||||
entity.game = null
|
|
||||||
}
|
|
||||||
|
|
||||||
despawnProjectile(projectile) {
|
|
||||||
this.projectiles = this.projectiles.filter((p) => p.id != projectile.id)
|
|
||||||
projectile.game = null
|
|
||||||
}
|
|
||||||
|
|
||||||
joinReport() {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
height: this.height,
|
|
||||||
width: this.width,
|
|
||||||
currentTick: this.currentTick,
|
|
||||||
abilities: this.abilities,
|
|
||||||
buffs: this.buffs,
|
|
||||||
terrains: this.terrains,
|
|
||||||
tickRate: this.tickRate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTerrain(terrain) {
|
|
||||||
this.terrains = this.terrains.filter((t) => t.id != terrain.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
secToTick(sec) {
|
|
||||||
return Math.floor(this.tickRate * sec)
|
|
||||||
}
|
|
||||||
|
|
||||||
spawn(object) {
|
|
||||||
if (object instanceof Entity) { this.spawnEntity(object) }
|
|
||||||
else if (object instanceof Terrain) { this.addTerrain(object) }
|
|
||||||
else if (object instanceof Projectile) { this.spawnProjectile(object) }
|
|
||||||
else { console.error({ error: 'spawn_unknown_object', object }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnEntity(entity) {
|
|
||||||
this.entities.push(entity)
|
|
||||||
entity.game = this
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnProjectile(projectile) {
|
|
||||||
this.projectiles.push(projectile)
|
|
||||||
projectile.game = this
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
if (this.#gameLoopIntervalId != null) { return }
|
|
||||||
|
|
||||||
this.#startTimestamp = performance.now() + (this.currentTick * this.tickBudget)
|
|
||||||
console.info({ event: 'game_start', id: this.id, tickRate: this.tickRate, currentTick: this.currentTick })
|
|
||||||
this.#gameLoopIntervalId = setInterval(this.#gameLoopCall.bind(this), this.tickBudget / 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (this.#gameLoopIntervalId == null) { return }
|
|
||||||
|
|
||||||
clearInterval(this.#gameLoopIntervalId)
|
|
||||||
this.#gameLoopIntervalId = null
|
|
||||||
console.info({ event: 'game_stop', id: this.id, currentTick: this.currentTick })
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription(websocket, id) {
|
|
||||||
return function builtSubscription(query = null) {
|
|
||||||
const game = this
|
|
||||||
if (query == 'id') { return id }
|
|
||||||
if (query != null) { return }
|
|
||||||
|
|
||||||
const entity = game.entities.find((it) => it.id == id)
|
|
||||||
if (entity == null) {
|
|
||||||
websocket.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = entity.team
|
|
||||||
const state = game.visionByTeam(team)
|
|
||||||
state.currentTick = game.currentTick
|
|
||||||
|
|
||||||
websocket.send(JSON.stringify(state))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
for (const subscription of this.#subscriptions.values()) {
|
|
||||||
subscription()
|
|
||||||
}
|
|
||||||
|
|
||||||
const callUpdate = function callUpdate(object) { object.update() }
|
|
||||||
this.entities.forEach(callUpdate)
|
|
||||||
this.projectiles.forEach(callUpdate)
|
|
||||||
if (this.#logic != null) {
|
|
||||||
this.#logic()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentTick++
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleEntities(team) {
|
|
||||||
const visionSources = this.visionSources(team)
|
|
||||||
return Array.from(new Set(visionSources.map((it) => it.entitiesInVision).flat()))
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleProjectiles(team) {
|
|
||||||
const visionSources = this.visionSources(team)
|
|
||||||
return Array.from(new Set(visionSources.map((it) => it.projectilesInVision).flat()))
|
|
||||||
}
|
|
||||||
|
|
||||||
visionSources(team) {
|
|
||||||
const entityVisionSources = this.entities.filter((it) => it.team == team)
|
|
||||||
const projectileVisionSources = this.projectiles.filter((it) => it.visionRange > 0 && (it.team == null || it.team == team))
|
|
||||||
return entityVisionSources.concat(projectileVisionSources)
|
|
||||||
}
|
|
||||||
|
|
||||||
visionByTeam(team) {
|
|
||||||
const visionSources = this.visionSources(team)
|
|
||||||
const visibleEntities = new Set(visionSources.map((it) => it.entitiesInVision).flat())
|
|
||||||
const visibleProjectiles = new Set(visionSources.map((it) => it.projectilesInVision).flat())
|
|
||||||
return {
|
|
||||||
entities: this.entities.filter((it) => visibleEntities.has(it.id)),
|
|
||||||
projectiles: this.projectiles.filter((it) => visibleProjectiles.has(it.id)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#gameLoop() {
|
|
||||||
if (this.#nextTickAt != null) {
|
|
||||||
const tickBudget = this.#tickBudget
|
|
||||||
const nextTickAt = this.#nextTickAt
|
|
||||||
this.#nextTickAt = null
|
|
||||||
|
|
||||||
let start = 0
|
|
||||||
while (start < nextTickAt) { start = performance.now() }
|
|
||||||
|
|
||||||
const before = performance.now()
|
|
||||||
this.update()
|
|
||||||
const after = performance.now()
|
|
||||||
const tickTaken = after - before
|
|
||||||
|
|
||||||
const useAbsoluteBehind = true
|
|
||||||
const absoluteBehind = Math.max(0, (start - this.#startTimestamp) - ((this.currentTick) * tickBudget))
|
|
||||||
this.#nextTickAt = start + tickBudget - (useAbsoluteBehind ? absoluteBehind : prevBehind)
|
|
||||||
|
|
||||||
if (tickTaken > tickBudget) {
|
|
||||||
console.warn({ warn: 'overload', tickTaken, tickBudget, absoluteBehind })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#gameLoopCall() {
|
|
||||||
this.#gameLoop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import * as LEVEL from './level.js'
|
|
||||||
import { WebSocketExpress } from 'websocket-express'
|
|
||||||
import express from 'express'
|
|
||||||
import Game from './game.js'
|
|
||||||
import os from 'node:os'
|
|
||||||
|
|
||||||
try {
|
|
||||||
// WARNING: process.nice can undermine dependencies?
|
|
||||||
os.setPriority(process.pid, os.constants.priority.PRIORITY_HIGHEST)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.warn({ warn: 'process_priority_unadjustable' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new WebSocketExpress()
|
|
||||||
const port = 1280
|
|
||||||
const game = new Game()
|
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: true }))
|
|
||||||
|
|
||||||
app.use('/three/', express.static('node_modules/three'))
|
|
||||||
app.use('/@tweenjs/', express.static('node_modules/@tweenjs'))
|
|
||||||
app.use('/stats.js/', express.static('node_modules/stats.js'))
|
|
||||||
|
|
||||||
app.use('/', express.static('public'))
|
|
||||||
app.use('/models', express.static('models'))
|
|
||||||
|
|
||||||
app.use('/tools/', express.static('tools'))
|
|
||||||
|
|
||||||
app.ws('/ws', async (req, res) => {
|
|
||||||
const websocket = await res.accept()
|
|
||||||
|
|
||||||
websocket.on('message', (rawData) => {
|
|
||||||
const message = JSON.parse(rawData)
|
|
||||||
console.info(message)
|
|
||||||
if (message.action == 'entities') {
|
|
||||||
websocket.send(JSON.stringify({ entities: game.entities.map((it) => it.id) }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action == 'join') {
|
|
||||||
const id = message.id
|
|
||||||
const connectionId = crypto.randomUUID()
|
|
||||||
if (!game.entities.some((it) => it.id == id)) {
|
|
||||||
console.info({ error: 'join_invalid_id', id, connectionId })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info({ event: 'connected', id, connectionId })
|
|
||||||
websocket.send(JSON.stringify(game.joinReport()))
|
|
||||||
const subscription = game.subscription(websocket, id).bind(game)
|
|
||||||
game.subscriptions.set(connectionId, subscription)
|
|
||||||
|
|
||||||
websocket.on('close', () => {
|
|
||||||
console.info({ event: 'disconnected', id })
|
|
||||||
game.subscriptions.delete(connectionId)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
game.action(message.id, message)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.info({ event: 'startup', visit: `http://localhost:${port}/menu/` })
|
|
||||||
|
|
||||||
LEVEL.Chase.scenario(game)
|
|
||||||
game.start()
|
|
||||||
})
|
|
||||||
-2293
File diff suppressed because it is too large
Load Diff
-213
@@ -1,213 +0,0 @@
|
|||||||
import Entity from './entity.js'
|
|
||||||
import PriorityQueue from './priority-queue.js'
|
|
||||||
import SATX from './satx.js'
|
|
||||||
|
|
||||||
export default class Pathfind {
|
|
||||||
static precision = 0.01
|
|
||||||
static multiplier = 1000000 // (1 / this.precision) * 10^expected_digit_count / 10
|
|
||||||
|
|
||||||
static key2(a, b) {
|
|
||||||
return `${a},${b}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fowler-Noll-Vo hash prime and offset basis for small keyspaces
|
|
||||||
static floatKey4(a, b, c, d) {
|
|
||||||
const prime = 16777619
|
|
||||||
let result = 2166136261
|
|
||||||
result ^= Math.floor(a * Pathfind.multiplier)
|
|
||||||
result *= prime
|
|
||||||
result ^= Math.floor(b * Pathfind.multiplier)
|
|
||||||
result *= prime
|
|
||||||
result ^= Math.floor(c * Pathfind.multiplier)
|
|
||||||
result *= prime
|
|
||||||
result ^= Math.floor(d * Pathfind.multiplier)
|
|
||||||
result *= prime
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
static uniqueWaypoints(waypoints) {
|
|
||||||
const included = new Set()
|
|
||||||
const uniqueWaypoints = []
|
|
||||||
for (const waypoint of waypoints) {
|
|
||||||
const key = Pathfind.key2(waypoint[0], waypoint[1])
|
|
||||||
if (!included.has(key)) {
|
|
||||||
included.add(key)
|
|
||||||
uniqueWaypoints.push(waypoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueWaypoints
|
|
||||||
}
|
|
||||||
|
|
||||||
static shortestPath(graph, start, goal) {
|
|
||||||
const queue = new PriorityQueue((a, b) => a[1] < b[1])
|
|
||||||
const visited = new Map()
|
|
||||||
|
|
||||||
queue.push([[start], 0])
|
|
||||||
|
|
||||||
while (!queue.isEmpty()) {
|
|
||||||
const [path, cost] = queue.pop()
|
|
||||||
const waypoint = path.at(-1)
|
|
||||||
|
|
||||||
if (Math.abs(waypoint[0] - goal[0]) < Pathfind.precision && Math.abs(waypoint[1] - goal[1]) < Pathfind.precision) {
|
|
||||||
path.shift()
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
const waypointKey = Pathfind.key2(waypoint[0], waypoint[1])
|
|
||||||
if (!visited.has(waypointKey) || visited.get(waypointKey) > cost) {
|
|
||||||
visited.set(waypointKey, cost)
|
|
||||||
|
|
||||||
for (let i = 0; i < graph.length; i += 5) {
|
|
||||||
if (Math.abs(waypoint[0] - graph[i]) > Pathfind.precision || Math.abs(waypoint[1] - graph[i + 1]) > Pathfind.precision) {
|
|
||||||
continue // waypoint and graph.from aren't the same (so graph.to isn't a neighbor)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextKey = Pathfind.key2(graph[i + 2], graph[i + 3])
|
|
||||||
if (!visited.has(nextKey) || visited.get(nextKey) > cost + graph[i + 4]) {
|
|
||||||
const next = new Float32Array(2)
|
|
||||||
next[0] = graph[i + 2]
|
|
||||||
next[1] = graph[i + 3]
|
|
||||||
queue.push([[...path, next], cost + graph[i + 4]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static buildGraph(waypoints, bboxes, obstacles, radius) {
|
|
||||||
const filteredWaypoints = []
|
|
||||||
const checked = new Set()
|
|
||||||
|
|
||||||
if (radius > 0) {
|
|
||||||
for (const waypoint of waypoints) {
|
|
||||||
const bbox = Entity.bbox(waypoint[0], waypoint[1], radius)
|
|
||||||
const bboxCheckedObstacles = []
|
|
||||||
for (let i = 0; i < bboxes.length; i += 5) {
|
|
||||||
if (bbox[0] <= bboxes[i + 2]) { continue }
|
|
||||||
if (bbox[1] <= bboxes[i + 3]) { continue }
|
|
||||||
if (bbox[2] >= bboxes[i]) { continue }
|
|
||||||
if (bbox[3] >= bboxes[i + 1]) { continue }
|
|
||||||
|
|
||||||
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bboxCheckedObstacles.length > 0) {
|
|
||||||
const collider = Entity.collider(waypoint[0], waypoint[1], radius)
|
|
||||||
const colliding = bboxCheckedObstacles.flat().some((it) => SATX.collideObject(collider, it))
|
|
||||||
if (colliding) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredWaypoints.push(waypoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedWaypoints = new Float32Array(filteredWaypoints.length * 2)
|
|
||||||
let mergedWaypointsIndex = 0
|
|
||||||
for (const waypoint of filteredWaypoints) {
|
|
||||||
mergedWaypoints[mergedWaypointsIndex] = waypoint[0]
|
|
||||||
mergedWaypoints[mergedWaypointsIndex + 1] = waypoint[1]
|
|
||||||
mergedWaypointsIndex += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = []
|
|
||||||
for (let i = 0; i < mergedWaypoints.length; i += 2) {
|
|
||||||
for (let j = 0; j < mergedWaypoints.length; j += 2) {
|
|
||||||
if (i == j) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(mergedWaypoints[i] - mergedWaypoints[j]) < Pathfind.precision && Math.abs(mergedWaypoints[i + 1] - mergedWaypoints[j + 1]) < Pathfind.precision) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = Pathfind.floatKey4(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1])
|
|
||||||
if (checked.has(key)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
checked.add(key)
|
|
||||||
checked.add(Pathfind.floatKey4(mergedWaypoints[j], mergedWaypoints[j + 1], mergedWaypoints[i], mergedWaypoints[i + 1]))
|
|
||||||
|
|
||||||
const bbox = Entity.tunnelBbox(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
|
|
||||||
|
|
||||||
const bboxCheckedObstacles = []
|
|
||||||
for (let i = 0; i < bboxes.length; i += 5) {
|
|
||||||
if (bbox[0] <= bboxes[i + 2]) { continue }
|
|
||||||
if (bbox[1] <= bboxes[i + 3]) { continue }
|
|
||||||
if (bbox[2] >= bboxes[i]) { continue }
|
|
||||||
if (bbox[3] >= bboxes[i + 1]) { continue }
|
|
||||||
|
|
||||||
bboxCheckedObstacles.push(obstacles[bboxes[i + 4]])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bboxCheckedObstacles.length > 0) {
|
|
||||||
const tunnel = Entity.tunnelCollider(mergedWaypoints[i], mergedWaypoints[i + 1], mergedWaypoints[j], mergedWaypoints[j + 1], radius)
|
|
||||||
const colliding = bboxCheckedObstacles.some((it) => it.some((c) => SATX.collideObject(tunnel, c)))
|
|
||||||
if (colliding) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = new Float32Array(5)
|
|
||||||
node[0] = mergedWaypoints[i]
|
|
||||||
node[1] = mergedWaypoints[i + 1]
|
|
||||||
node[2] = mergedWaypoints[j]
|
|
||||||
node[3] = mergedWaypoints[j + 1]
|
|
||||||
node[4] = Math.hypot(mergedWaypoints[j] - mergedWaypoints[i], mergedWaypoints[j + 1] - mergedWaypoints[i + 1])
|
|
||||||
nodes.push(node)
|
|
||||||
|
|
||||||
const reverseNode = new Float32Array(5)
|
|
||||||
reverseNode[0] = mergedWaypoints[j]
|
|
||||||
reverseNode[1] = mergedWaypoints[j + 1]
|
|
||||||
reverseNode[2] = mergedWaypoints[i]
|
|
||||||
reverseNode[3] = mergedWaypoints[i + 1]
|
|
||||||
reverseNode[4] = node[4] // distance is the same, copying is less expensive
|
|
||||||
nodes.push(reverseNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const graph = new Float32Array(nodes.length * 5)
|
|
||||||
let graphIndex = 0
|
|
||||||
for (const node of nodes) {
|
|
||||||
graph[graphIndex] = node[0]
|
|
||||||
graph[graphIndex + 1] = node[1]
|
|
||||||
graph[graphIndex + 2] = node[2]
|
|
||||||
graph[graphIndex + 3] = node[3]
|
|
||||||
graph[graphIndex + 4] = node[4]
|
|
||||||
graphIndex += 5
|
|
||||||
}
|
|
||||||
|
|
||||||
return graph
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatFloat32Array(array, columns = 2, text = false) {
|
|
||||||
const formatted = []
|
|
||||||
let columnWidth = 0
|
|
||||||
for (let i = 0; i < array.length; i += columns) {
|
|
||||||
const row = []
|
|
||||||
for (let j = i; j < i + columns; j++) {
|
|
||||||
if (text) {
|
|
||||||
row.push(`${array[j]}`)
|
|
||||||
if (`${array[j]}`.length > columnWidth) {
|
|
||||||
columnWidth = `${array[j]}`.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
row.push(array[j])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
formatted.push(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
return formatted.map((row) => row.map((v) => v.padEnd(columnWidth, ' ')).join(' | ')).join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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
@@ -1,80 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export default class Team {
|
|
||||||
static neutral = 'neutral'
|
|
||||||
static blue = 'blue'
|
|
||||||
static red = 'red'
|
|
||||||
}
|
|
||||||
-147
@@ -1,147 +0,0 @@
|
|||||||
import { Vector2 } from 'three'
|
|
||||||
import Ability from './ability.js'
|
|
||||||
import Team from './team.js'
|
|
||||||
|
|
||||||
export default class Template {
|
|
||||||
static basilisk(overrides) {
|
|
||||||
return {
|
|
||||||
abilities: { a: Ability.rangedAttack.id },
|
|
||||||
logic: this.#basiliskLogic(),
|
|
||||||
maxHealth: 300,
|
|
||||||
model: 'models/generic-bam-placeholder.gltf',
|
|
||||||
radius: 180,
|
|
||||||
speed: 230,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static minion(team, options = {}) {
|
|
||||||
return {
|
|
||||||
abilities: { a: options.ranged ? Ability.rangedAttack.id : Ability.meleeAttack.id },
|
|
||||||
logic: this.#minionLogic(options.route, (team != Team.blue)),
|
|
||||||
maxHealth: options.ranged ? 300 : 450,
|
|
||||||
model: Team.blue == (team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
|
|
||||||
pathfindingCooldown: 0.2,
|
|
||||||
pathfindingObstacleLimit: 0,
|
|
||||||
position: options.route?.at(0) ?? options.position ?? new Vector2(0, 0),
|
|
||||||
radius: options.ranged ? 36 : 38,
|
|
||||||
speed: 325,
|
|
||||||
team,
|
|
||||||
visionRange: 1200,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static player(overrides) {
|
|
||||||
return {
|
|
||||||
abilities: {
|
|
||||||
a: Ability.rangedAttack.id,
|
|
||||||
q: Ability.straightShot.id,
|
|
||||||
w: Ability.expose.id,
|
|
||||||
e: Ability.control.id,
|
|
||||||
r: Ability.shieldThrow.id,
|
|
||||||
d: Ability.circleOfResurrection.id,
|
|
||||||
f: Ability.blink.id,
|
|
||||||
},
|
|
||||||
logic: this.#playerLogic,
|
|
||||||
maxHealth: 600,
|
|
||||||
model: Team.blue == (overrides.team ?? Team.blue) ? 'models/generic-player-placeholder.gltf' : 'models/generic-player-placeholder-red.gltf',
|
|
||||||
pathfindingObstacleLimit: 3,
|
|
||||||
radius: 65,
|
|
||||||
spawnPosition: new Vector2(500, 150),
|
|
||||||
visionRange: 1350,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static #basiliskLogic() {
|
|
||||||
let diedOnTick = null
|
|
||||||
let targetInRangeSince = null
|
|
||||||
|
|
||||||
return function builtBasiliskLogic() {
|
|
||||||
const entity = this
|
|
||||||
if (Array.from(entity.game?.subscriptions.values()).some((it) => it('id') == entity.id)) { return }
|
|
||||||
|
|
||||||
const attackDelaySec = 2
|
|
||||||
const despawnDelaySec = 5
|
|
||||||
|
|
||||||
const despawnDelay = entity.game?.secToTick(despawnDelaySec) ?? 1
|
|
||||||
const timestamp = entity.game?.currentTick ?? 0
|
|
||||||
|
|
||||||
if (entity.dead && diedOnTick == null) { diedOnTick = timestamp }
|
|
||||||
if (entity.dead && diedOnTick != null && diedOnTick + despawnDelay < timestamp) { entity.despawn() }
|
|
||||||
if (!entity.dead) { diedOnTick = null }
|
|
||||||
if (entity.dead) { return }
|
|
||||||
|
|
||||||
const target = entity.closestTargetTo(entity.position, 500)
|
|
||||||
if (target == null) {
|
|
||||||
targetInRangeSince = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetInRangeSince == null) {
|
|
||||||
targetInRangeSince = timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
const attackDelay = entity.game?.secToTick(attackDelaySec) ?? 1
|
|
||||||
if (targetInRangeSince + attackDelay < timestamp) {
|
|
||||||
entity.castAction('a', target.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const directionToTarget = target.position.clone().sub(entity.position).normalize()
|
|
||||||
const entityRotationVector = new Vector2(1, 0).rotateAround(new Vector2(), entity.rotation)
|
|
||||||
entity.rotation = directionToTarget.clone().add(entityRotationVector).add(entityRotationVector).add(entityRotationVector).angle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static #minionLogic(route = [], odd = false) {
|
|
||||||
const checkpointSize = 300
|
|
||||||
const recalculateDestRadius = 50
|
|
||||||
const aggroRadius = 500
|
|
||||||
const memory = {}
|
|
||||||
|
|
||||||
return function builtMinionLogic() {
|
|
||||||
const entity = this
|
|
||||||
if (entity.dead) { entity.despawn() }
|
|
||||||
|
|
||||||
const currentTick = entity.game?.currentTick ?? 0
|
|
||||||
const minionResponseTime = Math.floor(0.1 * (entity.game?.tickRate ?? 1))
|
|
||||||
if (!(currentTick % minionResponseTime == 0 && Math.floor(currentTick / minionResponseTime) % 2 == (odd ? 1 : 0))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = entity.closestTargetTo(entity.position, aggroRadius)
|
|
||||||
if (target != null) {
|
|
||||||
entity.ghosting = false
|
|
||||||
entity.attackAction(target.position)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((route.length > 0 || entity.attacking) && target == null) {
|
|
||||||
const routeIndex = memory.routeCheckpoint ?? 0
|
|
||||||
const goal = route[routeIndex].clone()
|
|
||||||
if (goal instanceof Vector2) {
|
|
||||||
if (entity.distanceTo(goal) < checkpointSize) {
|
|
||||||
if (routeIndex + 1 < route.length) {
|
|
||||||
memory.routeCheckpoint = routeIndex + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((entity.destination?.distanceTo(entity.position) ?? 0) < recalculateDestRadius) {
|
|
||||||
entity.ghosting = true
|
|
||||||
entity.moveAction(goal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.position.equals(route.at(-1))) {
|
|
||||||
entity.despawn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static #playerLogic() {
|
|
||||||
const entity = this
|
|
||||||
// if (entity.dead) {
|
|
||||||
// entity.respawn()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-120
@@ -1,120 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<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