Compare commits
1 Commits
nodejs
..
3ef5254792
| Author | SHA1 | Date | |
|---|---|---|---|
|
3ef5254792
|
@@ -1,4 +0,0 @@
|
|||||||
.git
|
|
||||||
*Dockerfile*
|
|
||||||
*docker-compose*
|
|
||||||
node_modules
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Normalize EOL for all files that Git considers text files.
|
||||||
|
* text=auto eol=lf
|
||||||
@@ -1,136 +1,3 @@
|
|||||||
# Logs
|
# Godot 4+ specific ignores
|
||||||
logs
|
.godot/
|
||||||
*.log
|
/android/
|
||||||
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 +0,0 @@
|
|||||||
# Instructions Clear
|
|
||||||
|
|
||||||
Instructions Clear is a Multiplayer Online Battle Arena (MOBA) where two teams of five players fight for victory.
|
|
||||||
The players have the ability to upgrade their abilities via their gear earned from quests.
|
|
||||||
But make sure to prioritize resurrecting your allies first because once you wipe, it's all over.
|
|
||||||
The game blends some elements of traditional MMORPG dungeons with modern MOBAs.
|
|
||||||
|
|
||||||
## Design Pillars
|
|
||||||
|
|
||||||
| Power | Reaction | Competition |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Players gradually get stronger as they complete quests. | Quests provide upgrade paths that provide opportunity to adapt. | If not by sheer power or a good build, teams have to outperform the opposing team to win by any means. |
|
|
||||||
|
|
||||||
## Audience & Market
|
|
||||||
|
|
||||||
The game is designed to be played online.
|
|
||||||
Elements of the no longer popular MMORPG genre are used to give former players a modern game.
|
|
||||||
It is aimed at young adults.
|
|
||||||
|
|
||||||
## Core Gameplay
|
|
||||||
|
|
||||||
The game controls are single unit isometric Real-Time Strategy (RTS) style (point-and-click).
|
|
||||||
|
|
||||||
The player can decide the pathing destination via right mouse clicks, combine it with auto-attacking the closest target via left mouse clicks, and cast abilities using the Q, W, and E keys.
|
|
||||||
|
|
||||||
Players of the same team collectively deal damage to players of the other team to bring the members' health points to zero. If a team's collective health pool has run out, that team loses.
|
|
||||||
|
|
||||||
Quests provide the income of players. Each quest gives the option of changing an ability or cashing out for gold.
|
|
||||||
|
|
||||||
Abilities provided by quests can either be a "legendary" (main) ability or a "basic" (minor) ability. A player is limited to one legendary ability per game.
|
|
||||||
|
|
||||||
The main ideology behind legendary abilities is the traditional damage, control, support trio.
|
|
||||||
|
|
||||||
Gold can be used to resurrect others (in lieu of a specialized ability), and to upgrade ability slots (to scale power).
|
|
||||||
|
|
||||||
The game is likely to provide a single level only.
|
|
||||||
|
|
||||||
## Mechanics for mechanics
|
|
||||||
|
|
||||||
The game concept is mainly dictated by MOBAs with the tried and tested systems, however, there are differences:
|
|
||||||
|
|
||||||
* Abilities replace items to reduce the possible combinations and to ease the learning curve.
|
|
||||||
* Quests replace last-hitting to make income more diverse to enable traditionally low-income roles like position 5 supports to earn equally.
|
|
||||||
* The win condition is changed to team elimination instead of the destruction of the main structure so that the game is more centered around the already celebrated team fights.
|
|
||||||
* Equal, generic ability kits replace heroes (or champions) to eliminate the possibility of a "lost draft" at the hero selection phase.
|
|
||||||
|
|
||||||
TBD: What are quests?
|
|
||||||
TBD: How (and if) structures work
|
|
||||||
TBD: Unlike in the Defense Of The Ancients (DOTA), the core structure (the Ancient) is not a win condition but it provides a safe haven until destroyed.
|
|
||||||
|
|
||||||
## Gameplay Balance & Pacing
|
|
||||||
|
|
||||||
As all MOBAs, if balancing is not perfect, players will gravitate towards "metas" to win.
|
|
||||||
|
|
||||||
TBD: The pacing is so that it doesn't feel like too much preparation but it isn't dragged out.
|
|
||||||
TBD: The "AFK farming" tactic should be crossed out by giving players incentive to fight.
|
|
||||||
|
|
||||||
### Possible Issues
|
|
||||||
|
|
||||||
**Corpse camping:** After a successful pick, the enemies would not let the picked player revive.
|
|
||||||
|
|
||||||
**Long death timers:** The player can get bored until they get revived.
|
|
||||||
|
|
||||||
## Setting & World
|
|
||||||
|
|
||||||
TBD: Diagonal lanes or a free-for-all layout?
|
|
||||||
|
|
||||||
## Narrative
|
|
||||||
|
|
||||||
TBD: Gameplay is king, narrative is going to be an afterthought.
|
|
||||||
|
|
||||||
## Business Model
|
|
||||||
|
|
||||||
This is a free-to-play game. Since running servers costs money and the server binary is not public, a subscription model can be introduced to spin up more servers on demand instead of waiting in queue for the free servers.
|
|
||||||
|
|
||||||
The cosmetic aspect ("skins" on the player model) is supposed to be responsible for the majority of the income.
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
1. Prototype with working pathing
|
|
||||||
1. Win condition: full team elimination
|
|
||||||
1. Basic ability mechanics
|
|
||||||
1. Ability upgrades (upgrade trees)
|
|
||||||
1. Quest mechanics
|
|
||||||
1. Quest type diversification
|
|
||||||
1. Retrospective to plan the remainder of the timeline
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 994 B |
@@ -0,0 +1,37 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://rf3iei6hroc0"
|
||||||
|
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://icon.svg"
|
||||||
|
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
svg/scale=1.0
|
||||||
|
editor/scale_with_editor_scale=false
|
||||||
|
editor/convert_colors_with_editor_theme=false
|
||||||
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 95 B |
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
; Engine configuration file.
|
||||||
|
; It's best edited using the editor UI and not directly,
|
||||||
|
; since the parameters that go here are not all obvious.
|
||||||
|
;
|
||||||
|
; Format:
|
||||||
|
; [section] ; section goes between []
|
||||||
|
; param=value ; assign values to parameters
|
||||||
|
|
||||||
|
config_version=5
|
||||||
|
|
||||||
|
[application]
|
||||||
|
|
||||||
|
config/name="Instructions Clear"
|
||||||
|
config/features=PackedStringArray("4.3", "Mobile")
|
||||||
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
renderer/rendering_method="mobile"
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 365 B |
@@ -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)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||