I Want To Heal 2 build v1.0.5 code
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-admin
|
||||
.gradle
|
||||
.DS_Store
|
||||
*.local
|
||||
*.apk
|
||||
external
|
||||
tmp-asset-tests
|
||||
tmp-dragon-previews
|
||||
__pycache__
|
||||
scripts/__pycache__
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
- Separate admin model viewer app runs at http://127.0.0.1:4174/admin.html.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
- Keep gameplay systems modular and reusable. Do not fork core combat rules per game mode.
|
||||
- Shared systems own spells, mana/resources, cooldowns, casts, buffs, boss phases, telegraphs, boss mechanics, mobs, and mob mechanics.
|
||||
- Shared rendering systems own 3D combat visuals: character meshes, weapons, attack animations, projectiles, telegraphs, hit/heal/stun effects, and boss/mob models.
|
||||
- Game modes should compose/configure shared systems as adapters, for example Dungeon = one party vs encounter and PVP Roguelike = two party-vs-encounter lanes.
|
||||
- If a new mode needs changed behavior, add configuration or extension points to shared combat/render modules instead of duplicating spell, resource, enemy, mechanic, mesh, weapon, animation, projectile, or effect logic.
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
- Fireballs persist until roughly one second before the next volley.
|
||||
- Birds fly to the left side, dive across the arena, then return to the tank.
|
||||
|
||||
### Cyber Dragon Core
|
||||
|
||||
- Boss fight starts directly against Cyber Dragon.
|
||||
- Cyber Dragon spawns one slow spinning blade every 10 seconds.
|
||||
- Each blade follows a random living party member or player, lasts 15 seconds, deals contact damage while overlapping, and applies non-dispellable Bleed.
|
||||
- Cyber Dragon circles the room, then charges the nearest living party member or player.
|
||||
- Targets hit by the circular charge are stunned for 1 second.
|
||||
|
||||
## Reusable Attack Modules
|
||||
|
||||
Action Mode mobs and bosses use configurable attack modules:
|
||||
@@ -28,6 +36,8 @@ Action Mode mobs and bosses use configurable attack modules:
|
||||
- `groundSlam`
|
||||
- `fireballVolley`
|
||||
- `birdDive`
|
||||
- `spinningBlade`
|
||||
- `circularCharge`
|
||||
- `bodyContact`
|
||||
|
||||
Each module can define:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- `src/actionMode.ts`: Action Mode character save, tier data, coins, rewards, gear stats, and upgrade rules.
|
||||
- `src/actionBoss/actionEncounterConfig.ts`: reusable mob/boss attack module defaults and local admin persistence.
|
||||
- `src/actionBoss/bulldromeSimulation.ts`: Action Mode combat simulation and enemy state machines.
|
||||
- `src/actionBoss/actionCombatSimulation.ts`: Action Mode combat simulation and enemy state machines.
|
||||
- `src/actionBoss/BulldromeScene.ts`: Phaser rendering/input adapter.
|
||||
- `src/components/BulldromeBossSlice.tsx`: browser fight shell, party frames, boss bar, spell bar, and result handling.
|
||||
- `src/components/ActionModeScreen.tsx`: Action Mode menus, dungeons, customization, and mechanics admin UI.
|
||||
@@ -20,6 +20,6 @@
|
||||
|
||||
## Current Follow-Ups
|
||||
|
||||
- Add Rathian dungeon mechanics.
|
||||
- Add Cyber Dragon coin and gear family.
|
||||
- Move Action Mode mechanic config from local storage into SQL tables when the backend persistence pass starts.
|
||||
- Add admin CRUD for new attacks, not just enable/disable existing modules.
|
||||
|
||||
@@ -15,6 +15,14 @@ npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Local Asset Admin
|
||||
|
||||
```bash
|
||||
npm run assets:admin
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4175` to update dungeon icons and boss or mob sprites. Uploads write directly into `public/action-assets`, so the next web or mobile build includes them without manually moving files.
|
||||
|
||||
## TrueNAS Custom App
|
||||
|
||||
Use `docker-compose.truenas.yml` for the new app deployment. Copy this repo into:
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Action Model Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.phenomrom.iwanttoheal2"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10002
|
||||
versionName "1.0.2"
|
||||
versionCode 10005
|
||||
versionName "1.0.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+10
-1
@@ -126,9 +126,18 @@ Then restart the TrueNAS custom app.
|
||||
|
||||
## Admin Changes
|
||||
|
||||
There is no separate admin server yet. Mechanic admin is currently inside the web app under Action Mode settings. This command serves the same app on port `4174` for local admin work:
|
||||
Mechanic admin is inside the web app under Action Mode settings. This command serves the same app on port `4174` for local mechanic admin work:
|
||||
|
||||
```bash
|
||||
cd /Users/warren/Documents/action-mode
|
||||
DATABASE_URL="postgres://iwanttoheal:<password>@127.0.0.1:5432/iwanttoheal" PORT=4174 npm run admin:start
|
||||
```
|
||||
|
||||
Asset admin is a local-only helper for replacing dungeon icons and boss or mob sprites:
|
||||
|
||||
```bash
|
||||
cd /Users/warren/Documents/action-mode
|
||||
npm run assets:admin
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4175`. Uploads write into `public/action-assets`, so the next build includes them automatically.
|
||||
|
||||
+11
-1
@@ -5,7 +5,17 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'android/**/build/**', 'android/app/src/main/assets/**'] },
|
||||
{
|
||||
ignores: [
|
||||
'dist',
|
||||
'dist-admin',
|
||||
'external',
|
||||
'tmp-asset-tests',
|
||||
'tmp-dragon-previews',
|
||||
'android/**/build/**',
|
||||
'android/app/src/main/assets/**',
|
||||
],
|
||||
},
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
Generated
+66
-1
@@ -13,7 +13,8 @@
|
||||
"pg": "^8.22.0",
|
||||
"phaser": "^4.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -336,6 +338,13 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -1068,6 +1077,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -1147,6 +1163,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.184.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
|
||||
@@ -2071,6 +2116,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -2724,6 +2776,13 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -3479,6 +3538,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/through2": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||
|
||||
+5
-1
@@ -4,10 +4,12 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"admin": "vite --config vite.admin.config.ts --host 127.0.0.1 --port 4174",
|
||||
"admin:start": "PORT=4174 node server/index.mjs",
|
||||
"android:build:debug": "npm run android:sync && cd android && ./gradlew assembleDebug",
|
||||
"android:open": "cap open android",
|
||||
"android:sync": "npm run build && cap sync android",
|
||||
"assets:admin": "node server/action-assets-admin.mjs",
|
||||
"db:init": "node server/db-init.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b --noEmit && vite build",
|
||||
@@ -21,7 +23,8 @@
|
||||
"pg": "^8.22.0",
|
||||
"phaser": "^4.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PYTHON=""
|
||||
for candidate in \
|
||||
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3 \
|
||||
/usr/local/bin/python3 \
|
||||
/usr/bin/python3 \
|
||||
python3
|
||||
do
|
||||
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" - <<'PY' >/dev/null 2>&1
|
||||
import tkinter
|
||||
PY
|
||||
then
|
||||
PYTHON="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$PYTHON" ]]; then
|
||||
echo "No Python with Tkinter found. Install python.org Python 3, then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$PYTHON" scripts/deploy_gui.py
|
||||
Executable
+562
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tkinter deployment helper for I Want To Heal 2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def relaunch_with_tk_python() -> None:
|
||||
if os.environ.get("DEPLOY_GUI_TK_RELAUNCH") == "1":
|
||||
return
|
||||
candidates = [
|
||||
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
"/usr/bin/python3",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if Path(candidate) == Path(sys.executable):
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
[candidate, "-c", "import tkinter"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
env = os.environ.copy()
|
||||
env["DEPLOY_GUI_TK_RELAUNCH"] = "1"
|
||||
os.execve(candidate, [candidate, *sys.argv], env)
|
||||
|
||||
|
||||
try:
|
||||
from tkinter import END, StringVar, Text, Tk, messagebox
|
||||
from tkinter import ttk
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "_tkinter":
|
||||
raise
|
||||
relaunch_with_tk_python()
|
||||
raise SystemExit(
|
||||
"Tkinter is not available in this Python. Run scripts/deploy-gui.sh "
|
||||
"or install python.org Python 3 with Tk support."
|
||||
)
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
VERSION_RE = re.compile(r"^[0-9]+[.][0-9]+[.][0-9]+$")
|
||||
|
||||
DEFAULT_GITEA_URL = "https://git.whoagland.com"
|
||||
DEFAULT_GITEA_OWNER = "phenom"
|
||||
DEFAULT_GITEA_REPO = "i-want-to-heal-2"
|
||||
DEFAULT_BRANCH = "main"
|
||||
MAX_STAGED_FILE_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, status: int, message: str, body: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
def __init__(self, command: list[str], return_code: int, output: str) -> None:
|
||||
super().__init__(f"Command failed with exit {return_code}: {shell_join(command)}")
|
||||
self.command = command
|
||||
self.return_code = return_code
|
||||
self.output = output
|
||||
|
||||
|
||||
def shell_join(command: list[str]) -> str:
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
env: dict[str, str],
|
||||
log: callable,
|
||||
*,
|
||||
check: bool = True,
|
||||
) -> int:
|
||||
log(f"$ {shell_join(command)}")
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output_lines = []
|
||||
for line in process.stdout:
|
||||
clean_line = line.rstrip("\n")
|
||||
output_lines.append(clean_line)
|
||||
log(clean_line)
|
||||
return_code = process.wait()
|
||||
if check and return_code != 0:
|
||||
raise CommandError(command, return_code, "\n".join(output_lines))
|
||||
return return_code
|
||||
|
||||
|
||||
def read_command(command: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
token: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
data = None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"token {token}",
|
||||
}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
raise ApiError(error.code, f"Gitea API returned HTTP {error.code}", body) from error
|
||||
|
||||
if not body:
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def upload_release_asset(url: str, token: str, apk_path: Path) -> dict:
|
||||
boundary = f"----codex-deploy-{uuid.uuid4().hex}"
|
||||
content_type = mimetypes.guess_type(apk_path.name)[0] or "application/octet-stream"
|
||||
file_bytes = apk_path.read_bytes()
|
||||
parts = [
|
||||
f"--{boundary}\r\n".encode("utf-8"),
|
||||
(
|
||||
'Content-Disposition: form-data; name="attachment"; '
|
||||
f'filename="{apk_path.name}"\r\n'
|
||||
).encode("utf-8"),
|
||||
f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"),
|
||||
file_bytes,
|
||||
f"\r\n--{boundary}--\r\n".encode("utf-8"),
|
||||
]
|
||||
data = b"".join(parts)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
raise ApiError(error.code, f"Asset upload returned HTTP {error.code}", body) from error
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def api_base(gitea_url: str, owner: str, repo: str) -> str:
|
||||
return (
|
||||
f"{gitea_url.rstrip('/')}/api/v1/repos/"
|
||||
f"{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}"
|
||||
)
|
||||
|
||||
|
||||
def find_oversized_staged_files(env: dict[str, str]) -> list[tuple[str, int]]:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="replace").strip())
|
||||
|
||||
oversized = []
|
||||
for raw_path in result.stdout.split(b"\0"):
|
||||
if not raw_path:
|
||||
continue
|
||||
path = raw_path.decode("utf-8", errors="surrogateescape")
|
||||
file_path = ROOT_DIR / path
|
||||
if file_path.exists() and file_path.stat().st_size > MAX_STAGED_FILE_BYTES:
|
||||
oversized.append((path, file_path.stat().st_size))
|
||||
return oversized
|
||||
|
||||
|
||||
def verify_remote_branch_matches(env: dict[str, str], branch: str) -> bool:
|
||||
local = read_command(["git", "rev-parse", branch], env)
|
||||
remote = read_command(["git", "ls-remote", "origin", f"refs/heads/{branch}"], env)
|
||||
if local.returncode != 0 or remote.returncode != 0:
|
||||
return False
|
||||
local_hash = local.stdout.strip()
|
||||
remote_hash = remote.stdout.split()[0] if remote.stdout.strip() else ""
|
||||
return bool(local_hash and remote_hash and local_hash == remote_hash)
|
||||
|
||||
|
||||
def create_or_get_release(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
token: str,
|
||||
version: str,
|
||||
notes: str,
|
||||
log: callable,
|
||||
) -> dict:
|
||||
base = api_base(gitea_url, owner, repo)
|
||||
tag = f"v{version}"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": branch,
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
log(f"Creating Gitea release {tag}")
|
||||
try:
|
||||
return request_json("POST", f"{base}/releases", token, payload=payload)
|
||||
except ApiError as error:
|
||||
if error.status != 409:
|
||||
raise
|
||||
log(f"Release {tag} already exists; using existing release")
|
||||
encoded_tag = urllib.parse.quote(tag, safe="")
|
||||
return request_json("GET", f"{base}/releases/tags/{encoded_tag}", token)
|
||||
|
||||
|
||||
def upload_apk_asset(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
release_id: int,
|
||||
apk_path: Path,
|
||||
log: callable,
|
||||
) -> dict:
|
||||
base = api_base(gitea_url, owner, repo)
|
||||
asset_name = urllib.parse.quote(apk_path.name)
|
||||
url = f"{base}/releases/{release_id}/assets?name={asset_name}"
|
||||
log(f"Uploading APK asset {apk_path.name}")
|
||||
return upload_release_asset(url, token, apk_path)
|
||||
|
||||
|
||||
class DeployGui:
|
||||
def __init__(self) -> None:
|
||||
self.root = Tk()
|
||||
self.root.title("I Want To Heal 2 Deployment")
|
||||
self.root.geometry("980x720")
|
||||
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
|
||||
self.worker: threading.Thread | None = None
|
||||
|
||||
self.version = StringVar(value=self.detect_version())
|
||||
self.gitea_url = StringVar(value=DEFAULT_GITEA_URL)
|
||||
self.gitea_owner = StringVar(value=DEFAULT_GITEA_OWNER)
|
||||
self.gitea_repo = StringVar(value=DEFAULT_GITEA_REPO)
|
||||
self.branch = StringVar(value=DEFAULT_BRANCH)
|
||||
self.token = StringVar(value=os.environ.get("GITEA_TOKEN", ""))
|
||||
self.status = StringVar(value=f"Repo: {ROOT_DIR}")
|
||||
self.build_apk = StringVar(value="1")
|
||||
self.run_checks = StringVar(value="1")
|
||||
self.commit_push = StringVar(value="1")
|
||||
self.create_release = StringVar(value="1")
|
||||
self.default_notes = self.build_default_notes(self.version.get())
|
||||
|
||||
self.build_ui()
|
||||
self.root.after(100, self.drain_log_queue)
|
||||
|
||||
def detect_version(self) -> str:
|
||||
build_file = ROOT_DIR / "android" / "app" / "build.gradle"
|
||||
if build_file.exists():
|
||||
match = re.search(r'versionName\s+"([^"]+)"', build_file.read_text())
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "1.0.0"
|
||||
|
||||
def build_ui(self) -> None:
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(3, weight=1)
|
||||
|
||||
main = ttk.Frame(self.root, padding=14)
|
||||
main.grid(row=0, column=0, sticky="nsew")
|
||||
main.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(main, text="APK version").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(main, textvariable=self.version, width=20).grid(row=0, column=1, sticky="w")
|
||||
|
||||
ttk.Label(main, text="Gitea token").grid(row=1, column=0, sticky="w", pady=(8, 0))
|
||||
ttk.Entry(main, textvariable=self.token, show="*", width=48).grid(
|
||||
row=1, column=1, sticky="ew", pady=(8, 0)
|
||||
)
|
||||
|
||||
repo_frame = ttk.Frame(main)
|
||||
repo_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
for index in range(8):
|
||||
repo_frame.columnconfigure(index, weight=1 if index in (1, 3, 5, 7) else 0)
|
||||
ttk.Label(repo_frame, text="URL").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_url, width=28).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Owner").grid(row=0, column=2, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_owner, width=14).grid(row=0, column=3, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Repo").grid(row=0, column=4, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_repo, width=20).grid(row=0, column=5, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Branch").grid(row=0, column=6, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.branch, width=10).grid(row=0, column=7, sticky="ew")
|
||||
|
||||
steps = ttk.LabelFrame(main, text="Steps")
|
||||
steps.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Build Thor APK with scripts/build-thor-apk.sh",
|
||||
variable=self.build_apk,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=0, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Run npm lint and npm build",
|
||||
variable=self.run_checks,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=0, column=1, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Commit all repo changes and push",
|
||||
variable=self.commit_push,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=1, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Create Gitea release and upload APK",
|
||||
variable=self.create_release,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=1, column=1, sticky="w", padx=8, pady=4)
|
||||
|
||||
notes_frame = ttk.Frame(self.root, padding=(14, 0, 14, 0))
|
||||
notes_frame.grid(row=1, column=0, sticky="ew")
|
||||
notes_frame.columnconfigure(0, weight=1)
|
||||
ttk.Label(notes_frame, text="Release notes").grid(row=0, column=0, sticky="w")
|
||||
self.notes = Text(notes_frame, height=6, wrap="word")
|
||||
self.notes.grid(row=1, column=0, sticky="ew")
|
||||
self.notes.insert("1.0", self.default_notes)
|
||||
|
||||
buttons = ttk.Frame(self.root, padding=14)
|
||||
buttons.grid(row=2, column=0, sticky="ew")
|
||||
buttons.columnconfigure(1, weight=1)
|
||||
self.run_button = ttk.Button(buttons, text="Run Deployment", command=self.start_deploy)
|
||||
self.run_button.grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(buttons, textvariable=self.status).grid(row=0, column=1, sticky="w", padx=(12, 0))
|
||||
|
||||
log_frame = ttk.Frame(self.root, padding=(14, 0, 14, 14))
|
||||
log_frame.grid(row=3, column=0, sticky="nsew")
|
||||
log_frame.columnconfigure(0, weight=1)
|
||||
log_frame.rowconfigure(0, weight=1)
|
||||
self.log_text = ScrolledText(log_frame, height=22, wrap="word")
|
||||
self.log_text.grid(row=0, column=0, sticky="nsew")
|
||||
self.log_text.configure(state="disabled")
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
self.log_queue.put((level, message))
|
||||
|
||||
def drain_log_queue(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
level, message = self.log_queue.get_nowait()
|
||||
if level == "status":
|
||||
self.status.set(message)
|
||||
continue
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.insert(END, message + "\n")
|
||||
self.log_text.see(END)
|
||||
self.log_text.configure(state="disabled")
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.root.after(100, self.drain_log_queue)
|
||||
|
||||
def start_deploy(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
return
|
||||
|
||||
version = self.version.get().strip()
|
||||
token = self.token.get().strip()
|
||||
if not VERSION_RE.match(version):
|
||||
messagebox.showerror("Invalid version", "Version must look like 1.0.2")
|
||||
return
|
||||
if self.create_release.get() == "1" and not token:
|
||||
messagebox.showerror("Missing token", "Paste a Gitea API token or export GITEA_TOKEN.")
|
||||
return
|
||||
|
||||
notes = self.notes.get("1.0", END).strip()
|
||||
if not notes or notes == self.default_notes:
|
||||
notes = self.build_default_notes(version)
|
||||
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.delete("1.0", END)
|
||||
self.log_text.configure(state="disabled")
|
||||
self.run_button.configure(state="disabled")
|
||||
self.status.set("Deployment running")
|
||||
|
||||
args = {
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"token": token,
|
||||
"gitea_url": self.gitea_url.get().strip(),
|
||||
"owner": self.gitea_owner.get().strip(),
|
||||
"repo": self.gitea_repo.get().strip(),
|
||||
"branch": self.branch.get().strip(),
|
||||
"build_apk": self.build_apk.get() == "1",
|
||||
"run_checks": self.run_checks.get() == "1",
|
||||
"commit_push": self.commit_push.get() == "1",
|
||||
"create_release": self.create_release.get() == "1",
|
||||
}
|
||||
self.worker = threading.Thread(target=self.deploy, kwargs=args, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def deploy(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
notes: str,
|
||||
token: str,
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
build_apk: bool,
|
||||
run_checks: bool,
|
||||
commit_push: bool,
|
||||
create_release: bool,
|
||||
) -> None:
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
if token:
|
||||
env["GITEA_TOKEN"] = token
|
||||
|
||||
self.log(f"Repo: {ROOT_DIR}")
|
||||
self.log(f"Version: {version}")
|
||||
|
||||
if build_apk:
|
||||
run_command(["scripts/build-thor-apk.sh", version], env, self.log)
|
||||
|
||||
if run_checks:
|
||||
run_command(["npm", "run", "lint"], env, self.log)
|
||||
run_command(["npm", "run", "build"], env, self.log)
|
||||
|
||||
if commit_push:
|
||||
status = read_command(["git", "status", "--short"], env)
|
||||
if status.stdout.strip():
|
||||
self.log("Git changes before commit:")
|
||||
self.log(status.stdout.rstrip())
|
||||
run_command(["git", "add", "."], env, self.log)
|
||||
diff = read_command(["git", "diff", "--cached", "--quiet"], env)
|
||||
if diff.returncode == 0:
|
||||
self.log("No staged changes; skipping commit")
|
||||
else:
|
||||
oversized = find_oversized_staged_files(env)
|
||||
if oversized:
|
||||
formatted = "\n".join(
|
||||
f"- {path} ({size / 1024 / 1024:.1f} MiB)"
|
||||
for path, size in oversized
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Refusing to commit oversized files. Add them to .gitignore "
|
||||
f"or upload them as release assets instead:\n{formatted}"
|
||||
)
|
||||
run_command(
|
||||
["git", "commit", "-m", f"I Want To Heal 2 build v{version}"],
|
||||
env,
|
||||
self.log,
|
||||
)
|
||||
try:
|
||||
run_command(["git", "push", "origin", branch], env, self.log)
|
||||
except CommandError:
|
||||
if verify_remote_branch_matches(env, branch):
|
||||
self.log("Push reported an error, but remote branch matches local commit")
|
||||
else:
|
||||
raise
|
||||
|
||||
if create_release:
|
||||
apk_path = ROOT_DIR / f"IWantToHeal2-Thor-v{version}.apk"
|
||||
if not apk_path.exists():
|
||||
raise RuntimeError(f"APK missing: {apk_path}")
|
||||
release = create_or_get_release(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
token,
|
||||
version,
|
||||
notes,
|
||||
self.log,
|
||||
)
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise RuntimeError(f"Release response did not include id: {release}")
|
||||
self.log(f"Release ID: {release_id}")
|
||||
asset = upload_apk_asset(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
int(release_id),
|
||||
apk_path,
|
||||
self.log,
|
||||
)
|
||||
self.log(f"Uploaded asset: {asset.get('name', apk_path.name)}")
|
||||
|
||||
self.log("Deployment complete")
|
||||
self.log_queue.put(("status", "Deployment complete"))
|
||||
except Exception as error: # noqa: BLE001 - display deployment failures in GUI.
|
||||
self.log(f"ERROR: {error}")
|
||||
if isinstance(error, ApiError) and error.body:
|
||||
self.log(error.body)
|
||||
self.log_queue.put(("status", "Deployment failed"))
|
||||
finally:
|
||||
self.root.after(0, lambda: self.run_button.configure(state="normal"))
|
||||
|
||||
def run(self) -> None:
|
||||
self.root.mainloop()
|
||||
|
||||
@staticmethod
|
||||
def build_default_notes(version: str) -> str:
|
||||
return f"I Want To Heal 2 Android and web/server build v{version}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
DeployGui().run()
|
||||
@@ -0,0 +1,692 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { extname, join, normalize } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const rootDir = normalize(join(__dirname, '..'))
|
||||
const publicDir = join(rootDir, 'public')
|
||||
const assetDir = join(publicDir, 'action-assets')
|
||||
const host = process.env.HOST ?? '127.0.0.1'
|
||||
const port = Number(process.env.PORT ?? 4175)
|
||||
|
||||
const assetSlots = [
|
||||
{
|
||||
id: 'dungeon-bulldrome',
|
||||
category: 'Dungeon Icons',
|
||||
label: 'Bulldrome Hunting Grounds',
|
||||
path: 'dungeons/bulldrome.svg',
|
||||
preview: 'dungeon',
|
||||
},
|
||||
{
|
||||
id: 'dungeon-yian-kut-ku',
|
||||
category: 'Dungeon Icons',
|
||||
label: 'Yian Kut-Ku Roost',
|
||||
path: 'dungeons/yian-kut-ku.svg',
|
||||
preview: 'dungeon',
|
||||
},
|
||||
{
|
||||
id: 'dungeon-cyber-dragon',
|
||||
category: 'Dungeon Icons',
|
||||
label: 'Cyber Dragon Core',
|
||||
path: 'dungeons/cyber-dragon.svg',
|
||||
preview: 'dungeon',
|
||||
},
|
||||
{
|
||||
id: 'enemy-cyber-dragon',
|
||||
category: 'Boss and Mob Sprites',
|
||||
label: 'Cyber Dragon',
|
||||
path: 'enemies/cyber-dragon.svg',
|
||||
preview: 'enemy',
|
||||
},
|
||||
{
|
||||
id: 'enemy-bulldrome',
|
||||
category: 'Boss and Mob Sprites',
|
||||
label: 'Bulldrome',
|
||||
path: 'enemies/bulldrome.svg',
|
||||
preview: 'enemy',
|
||||
},
|
||||
{
|
||||
id: 'enemy-bullfango',
|
||||
category: 'Boss and Mob Sprites',
|
||||
label: 'Bullfango',
|
||||
path: 'enemies/bullfango.svg',
|
||||
preview: 'enemy',
|
||||
},
|
||||
{
|
||||
id: 'enemy-yian-kut-ku',
|
||||
category: 'Boss and Mob Sprites',
|
||||
label: 'Yian Kut-Ku',
|
||||
path: 'enemies/yian-kut-ku.svg',
|
||||
preview: 'enemy',
|
||||
},
|
||||
{
|
||||
id: 'enemy-bird',
|
||||
category: 'Boss and Mob Sprites',
|
||||
label: 'Bird',
|
||||
path: 'enemies/bird.svg',
|
||||
preview: 'enemy',
|
||||
},
|
||||
]
|
||||
|
||||
const mimeTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
|
||||
|
||||
if (request.method === 'GET' && url.pathname === '/') {
|
||||
sendHtml(response, adminHtml())
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && url.pathname === '/api/assets') {
|
||||
sendJson(response, 200, { slots: assetSlots.map(slotPayload) })
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && url.pathname.startsWith('/api/assets/')) {
|
||||
const slotId = decodeURIComponent(url.pathname.replace('/api/assets/', ''))
|
||||
const slot = assetSlots.find((item) => item.id === slotId)
|
||||
if (!slot) throw statusError(404, 'Unknown asset slot.')
|
||||
|
||||
const body = await readJson(request, 16 * 1024 * 1024)
|
||||
const svg = normalizeUploadToSvg(body)
|
||||
const targetPath = join(assetDir, slot.path)
|
||||
await mkdir(join(targetPath, '..'), { recursive: true })
|
||||
await writeFile(targetPath, svg)
|
||||
sendJson(response, 200, { slot: slotPayload(slot) })
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && url.pathname.startsWith('/action-assets/')) {
|
||||
await servePublicAsset(response, url.pathname)
|
||||
return
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: 'not_found' })
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 500
|
||||
if (status >= 500) console.error(error)
|
||||
sendJson(response, status, {
|
||||
error: error instanceof Error ? error.message : 'Unable to process request.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Action asset admin listening on http://${host}:${port}`)
|
||||
})
|
||||
|
||||
function slotPayload(slot) {
|
||||
const filePath = join(assetDir, slot.path)
|
||||
const stat = existsSync(filePath) ? statSync(filePath) : null
|
||||
return {
|
||||
...slot,
|
||||
size: stat?.size ?? 0,
|
||||
updatedAt: stat?.mtime.toISOString() ?? null,
|
||||
url: `/action-assets/${slot.path}`,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUploadToSvg(body) {
|
||||
const dataUrl = String(body?.dataUrl ?? '')
|
||||
const width = clampDimension(Number(body?.width), 256)
|
||||
const height = clampDimension(Number(body?.height), 256)
|
||||
const originalName = escapeXml(String(body?.name ?? 'uploaded-image').slice(0, 120))
|
||||
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|svg\+xml));base64,([A-Za-z0-9+/=]+)$/)
|
||||
if (!match) throw statusError(400, 'Upload must be PNG, JPG, WebP, or SVG.')
|
||||
|
||||
const decodedBytes = Buffer.byteLength(match[2], 'base64')
|
||||
if (decodedBytes > 10 * 1024 * 1024) throw statusError(400, 'Upload must be 10 MB or smaller.')
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${originalName}">`,
|
||||
` <image href="${dataUrl}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`,
|
||||
'</svg>',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function clampDimension(value, fallback) {
|
||||
if (!Number.isFinite(value) || value < 1) return fallback
|
||||
return Math.min(4096, Math.round(value))
|
||||
}
|
||||
|
||||
async function readJson(request, maxSize) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length
|
||||
if (size > maxSize) throw statusError(400, 'Request body is too large.')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
async function servePublicAsset(response, pathname) {
|
||||
const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '')
|
||||
const filePath = join(publicDir, safePath)
|
||||
if (!filePath.startsWith(publicDir) || !existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
sendJson(response, 404, { error: 'not_found' })
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': mimeTypes[extname(filePath)] ?? 'application/octet-stream',
|
||||
})
|
||||
createReadStream(filePath).pipe(response)
|
||||
}
|
||||
|
||||
function sendHtml(response, html) {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
response.end(html)
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
response.writeHead(status, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
})
|
||||
response.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function statusError(status, message) {
|
||||
const error = new Error(message)
|
||||
error.status = status
|
||||
return error
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function adminHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Action Asset Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #f4eed8;
|
||||
--muted: #a89f87;
|
||||
--panel: #191b22;
|
||||
--panel-light: #242630;
|
||||
--edge: #565066;
|
||||
--gold: #e5b95f;
|
||||
--red: #dc5162;
|
||||
--green: #76d39a;
|
||||
--blue: #5ec7ff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: #07080b;
|
||||
color: var(--ink);
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(320px, 0.85fr) minmax(520px, 1.15fr);
|
||||
min-height: 100vh;
|
||||
padding: 14px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 2px solid #090a0d;
|
||||
outline: 2px solid var(--edge);
|
||||
min-width: 0;
|
||||
}
|
||||
.asset-panel {
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1, h2, h3, .eyebrow, .asset-card strong, .pixel {
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 { font-size: 24px; line-height: 1.2; }
|
||||
h2 { font-size: 15px; margin: 18px 0 8px; }
|
||||
.eyebrow {
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.copy {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.asset-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.asset-card {
|
||||
align-items: center;
|
||||
background: #111319;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
outline: 2px solid #41404a;
|
||||
padding: 10px;
|
||||
}
|
||||
.asset-card.selected {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
.asset-card img {
|
||||
background: #08090c;
|
||||
border: 2px solid #090a0d;
|
||||
display: block;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
width: 72px;
|
||||
}
|
||||
.asset-card strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.asset-card small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.asset-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.asset-actions input {
|
||||
color: var(--muted);
|
||||
max-width: 100%;
|
||||
}
|
||||
.asset-actions button, .toolbar a {
|
||||
background: var(--gold);
|
||||
border: 2px solid #08090c;
|
||||
color: #19150e;
|
||||
cursor: pointer;
|
||||
outline: 2px solid #816630;
|
||||
padding: 8px 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.asset-actions button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.status {
|
||||
color: var(--green);
|
||||
min-height: 20px;
|
||||
}
|
||||
.status.error {
|
||||
color: #ff8190;
|
||||
}
|
||||
.preview-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
}
|
||||
.dungeon-preview-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.dungeon-card {
|
||||
align-items: center;
|
||||
background: #111319;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
min-height: 80px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 8px;
|
||||
}
|
||||
.dungeon-card img {
|
||||
background: #171922;
|
||||
border: 2px solid #090a0d;
|
||||
display: block;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
width: 56px;
|
||||
}
|
||||
.dungeon-card strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.dungeon-card small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fight-preview-shell {
|
||||
align-items: center;
|
||||
background: #08090c;
|
||||
border: 2px solid #090a0d;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 340px;
|
||||
outline: 2px solid #41404a;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
}
|
||||
.fight-preview {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #11151c;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.arena {
|
||||
background:
|
||||
linear-gradient(rgba(37,43,53,.65) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(37,43,53,.65) 1px, transparent 1px),
|
||||
#18202a;
|
||||
background-size: 48px 48px;
|
||||
border: 3px solid var(--edge);
|
||||
inset: 13% 4% 7%;
|
||||
position: absolute;
|
||||
}
|
||||
.bossbar {
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
transform: translateX(-50%);
|
||||
width: min(440px, calc(100% - 40px));
|
||||
z-index: 3;
|
||||
}
|
||||
.bossbar strong, .bossbar span {
|
||||
display: block;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
.bossbar span {
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bossbar i {
|
||||
background: #090a0d;
|
||||
border: 2px solid #090a0d;
|
||||
display: block;
|
||||
height: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.bossbar b {
|
||||
background: var(--red);
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 82%;
|
||||
}
|
||||
.unit {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
.unit img {
|
||||
display: block;
|
||||
height: 78px;
|
||||
object-fit: contain;
|
||||
width: 78px;
|
||||
}
|
||||
.unit.bulldrome img, .unit.yian-kut-ku img {
|
||||
height: 116px;
|
||||
width: 116px;
|
||||
}
|
||||
.player-dot {
|
||||
background: #30ff7a;
|
||||
border: 3px solid #090a0d;
|
||||
border-radius: 50%;
|
||||
height: 28px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 74%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 28px;
|
||||
z-index: 2;
|
||||
}
|
||||
@media (max-width: 920px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="admin-shell">
|
||||
<section class="panel asset-panel">
|
||||
<p class="eyebrow">Local Only</p>
|
||||
<h1>Action Asset Admin</h1>
|
||||
<p class="copy">Uploads write directly into <span class="pixel">public/action-assets</span>. Next web or mobile build uses them automatically.</p>
|
||||
<div class="toolbar">
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
<a href="http://127.0.0.1:5173/" target="_blank" rel="noreferrer">Open Game</a>
|
||||
</div>
|
||||
<div id="assetRoot"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel preview-panel">
|
||||
<div>
|
||||
<p class="eyebrow">In-Game Preview</p>
|
||||
<h1>Dungeon Cards</h1>
|
||||
</div>
|
||||
<div class="dungeon-preview-grid" id="dungeonPreview"></div>
|
||||
<div>
|
||||
<p class="eyebrow">Combat Preview</p>
|
||||
<div class="fight-preview-shell">
|
||||
<div class="fight-preview">
|
||||
<div class="arena"></div>
|
||||
<div class="bossbar">
|
||||
<strong>Boss Pack</strong>
|
||||
<span>364 / 375</span>
|
||||
<i><b></b></i>
|
||||
</div>
|
||||
<div id="enemyPreview"></div>
|
||||
<div class="player-dot" title="Player"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
slots: [],
|
||||
selectedId: '',
|
||||
pending: new Map(),
|
||||
}
|
||||
|
||||
const positions = {
|
||||
'enemy-bulldrome': ['50%', '31%', 'bulldrome'],
|
||||
'enemy-bullfango': ['50%', '33%', 'bullfango'],
|
||||
'enemy-yian-kut-ku': ['50%', '31%', 'yian-kut-ku'],
|
||||
'enemy-bird': ['64%', '45%', 'bird'],
|
||||
'enemy-cyber-dragon': ['50%', '31%', 'cyber-dragon'],
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
const response = await fetch('/api/assets')
|
||||
const body = await response.json()
|
||||
state.slots = body.slots
|
||||
state.selectedId = state.selectedId || state.slots[0]?.id || ''
|
||||
render()
|
||||
}
|
||||
|
||||
function assetUrl(slot) {
|
||||
const pending = state.pending.get(slot.id)
|
||||
if (pending) return pending.dataUrl
|
||||
return slot.url + '?v=' + encodeURIComponent(slot.updatedAt || Date.now())
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAssetList()
|
||||
renderDungeonPreview()
|
||||
renderEnemyPreview()
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
const root = document.getElementById('assetRoot')
|
||||
const groups = state.slots.reduce((map, slot) => {
|
||||
if (!map.has(slot.category)) map.set(slot.category, [])
|
||||
map.get(slot.category).push(slot)
|
||||
return map
|
||||
}, new Map())
|
||||
root.innerHTML = Array.from(groups, ([category, slots]) => \`
|
||||
<h2>\${category}</h2>
|
||||
<div class="asset-list">
|
||||
\${slots.map(slot => assetCard(slot)).join('')}
|
||||
</div>
|
||||
\`).join('')
|
||||
|
||||
for (const slot of state.slots) {
|
||||
document.getElementById('file-' + slot.id).addEventListener('change', event => selectFile(slot, event))
|
||||
document.getElementById('save-' + slot.id).addEventListener('click', () => saveSlot(slot))
|
||||
}
|
||||
}
|
||||
|
||||
function assetCard(slot) {
|
||||
const pending = state.pending.get(slot.id)
|
||||
return \`
|
||||
<article class="asset-card \${slot.id === state.selectedId ? 'selected' : ''}" data-slot="\${slot.id}">
|
||||
<img src="\${assetUrl(slot)}" alt="">
|
||||
<div>
|
||||
<strong>\${slot.label}</strong>
|
||||
<small>\${slot.path}</small>
|
||||
<small>\${slot.updatedAt ? 'Updated ' + new Date(slot.updatedAt).toLocaleString() : 'Missing file'}\${pending ? ' - pending' : ''}</small>
|
||||
<div class="asset-actions">
|
||||
<input id="file-\${slot.id}" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml">
|
||||
<button id="save-\${slot.id}" type="button" \${pending ? '' : 'disabled'}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
\`
|
||||
}
|
||||
|
||||
function renderDungeonPreview() {
|
||||
const root = document.getElementById('dungeonPreview')
|
||||
const dungeons = state.slots.filter(slot => slot.preview === 'dungeon')
|
||||
root.innerHTML = dungeons.map(slot => \`
|
||||
<article class="dungeon-card">
|
||||
<img src="\${assetUrl(slot)}" alt="">
|
||||
<div>
|
||||
<strong>\${slot.label}</strong>
|
||||
<small>Action dungeon icon preview.</small>
|
||||
</div>
|
||||
</article>
|
||||
\`).join('')
|
||||
}
|
||||
|
||||
function renderEnemyPreview() {
|
||||
const root = document.getElementById('enemyPreview')
|
||||
const enemies = state.slots.filter(slot => slot.preview === 'enemy')
|
||||
root.innerHTML = enemies.map(slot => {
|
||||
const [left, top, className] = positions[slot.id] || ['50%', '50%', '']
|
||||
return \`
|
||||
<div class="unit \${className}" style="left: \${left}; top: \${top}">
|
||||
<img src="\${assetUrl(slot)}" alt="\${slot.label}">
|
||||
</div>
|
||||
\`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function selectFile(slot, event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!/^image\\/(png|jpeg|webp|svg\\+xml)$/.test(file.type)) {
|
||||
setStatus('Use PNG, JPG, WebP, or SVG.', true)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = String(reader.result)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
state.pending.set(slot.id, {
|
||||
dataUrl,
|
||||
height: image.naturalHeight || 256,
|
||||
name: file.name,
|
||||
width: image.naturalWidth || 256,
|
||||
})
|
||||
state.selectedId = slot.id
|
||||
setStatus('Preview updated. Save to write file.', false)
|
||||
render()
|
||||
}
|
||||
image.onerror = () => setStatus('Could not read image.', true)
|
||||
image.src = dataUrl
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
async function saveSlot(slot) {
|
||||
const pending = state.pending.get(slot.id)
|
||||
if (!pending) return
|
||||
setStatus('Saving...', false)
|
||||
const response = await fetch('/api/assets/' + encodeURIComponent(slot.id), {
|
||||
body: JSON.stringify(pending),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
const body = await response.json()
|
||||
if (!response.ok) {
|
||||
setStatus(body.error || 'Save failed.', true)
|
||||
return
|
||||
}
|
||||
state.pending.delete(slot.id)
|
||||
const index = state.slots.findIndex(item => item.id === slot.id)
|
||||
if (index >= 0) state.slots[index] = body.slot
|
||||
setStatus('Saved. Next build will include this asset.', false)
|
||||
render()
|
||||
}
|
||||
|
||||
function setStatus(message, error) {
|
||||
const element = document.getElementById('status')
|
||||
element.textContent = message
|
||||
element.classList.toggle('error', Boolean(error))
|
||||
}
|
||||
|
||||
loadSlots().catch(error => setStatus(error.message, true))
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const port = Number(process.env.PORT ?? 4173)
|
||||
const pool = createPool()
|
||||
const sessionCookieName = 'iwanttoheal2_session'
|
||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||
const arenaQueue = new Map()
|
||||
const arenaMatches = new Map()
|
||||
const arenaQueueTtlMs = 15 * 1000
|
||||
const arenaMatchTtlMs = 60 * 60 * 1000
|
||||
|
||||
const corsOrigins = new Set(
|
||||
(process.env.CORS_ORIGINS ?? '')
|
||||
@@ -144,6 +148,10 @@ async function handleApi(request, response, url) {
|
||||
if (!user) return
|
||||
const body = await readJson(request)
|
||||
const mode = String(body?.mode ?? 'action-healer')
|
||||
if (mode === 'arenas') {
|
||||
sendJson(response, 200, joinArenaQueue(user))
|
||||
return
|
||||
}
|
||||
const rating = Number(body?.rating ?? 1000)
|
||||
const queued = await pool.query(
|
||||
`
|
||||
@@ -159,6 +167,14 @@ async function handleApi(request, response, url) {
|
||||
return
|
||||
}
|
||||
|
||||
const arenaQueueTicket = url.pathname.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
||||
if (request.method === 'GET' && arenaQueueTicket) {
|
||||
const user = await requireUser(request, response)
|
||||
if (!user) return
|
||||
sendJson(response, 200, checkArenaQueue(user, arenaQueueTicket[1]))
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE' && url.pathname === '/api/pvp/queue') {
|
||||
const user = await requireUser(request, response)
|
||||
if (!user) return
|
||||
@@ -168,9 +184,127 @@ async function handleApi(request, response, url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE' && arenaQueueTicket) {
|
||||
const user = await requireUser(request, response)
|
||||
if (!user) return
|
||||
cancelArenaQueue(user, arenaQueueTicket[1])
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: 'not_found' })
|
||||
}
|
||||
|
||||
function cleanupArenaMemory(now = Date.now()) {
|
||||
for (const [ticketId, ticket] of arenaQueue.entries()) {
|
||||
if (now - ticket.updatedAt > arenaQueueTtlMs) arenaQueue.delete(ticketId)
|
||||
}
|
||||
for (const [matchId, match] of arenaMatches.entries()) {
|
||||
if (now - match.updatedAt > arenaMatchTtlMs) arenaMatches.delete(matchId)
|
||||
}
|
||||
}
|
||||
|
||||
function arenaPlayerInfo(user) {
|
||||
return {
|
||||
accountId: user.id,
|
||||
displayName: user.display_name ?? user.username ?? 'Arena Player',
|
||||
username: user.username ?? user.auth_subject,
|
||||
}
|
||||
}
|
||||
|
||||
function arenaSnapshot(match) {
|
||||
return {
|
||||
id: match.id,
|
||||
mode: 'arenas',
|
||||
createdAt: match.createdAt,
|
||||
players: match.players,
|
||||
updatedAt: match.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function createArenaMatch(players, now = Date.now()) {
|
||||
const match = {
|
||||
id: randomBytes(12).toString('base64url'),
|
||||
players,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
arenaMatches.set(match.id, match)
|
||||
return match
|
||||
}
|
||||
|
||||
function joinArenaQueue(user) {
|
||||
const now = Date.now()
|
||||
cleanupArenaMemory(now)
|
||||
const existingTicket = [...arenaQueue.values()].find((ticket) => ticket.accountId === user.id)
|
||||
if (existingTicket?.matchId) {
|
||||
const match = arenaMatches.get(existingTicket.matchId)
|
||||
if (match) {
|
||||
const side = match.players.a.accountId === user.id ? 'a' : 'b'
|
||||
return { ticketId: existingTicket.id, status: 'matched', side, match: arenaSnapshot(match) }
|
||||
}
|
||||
}
|
||||
|
||||
const opponent = [...arenaQueue.values()]
|
||||
.filter((ticket) => !ticket.matchId && ticket.accountId !== user.id)
|
||||
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||
const player = arenaPlayerInfo(user)
|
||||
if (opponent) {
|
||||
const match = createArenaMatch({
|
||||
a: { side: 'a', ...opponent.player },
|
||||
b: { side: 'b', ...player },
|
||||
}, now)
|
||||
opponent.matchId = match.id
|
||||
opponent.updatedAt = now
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
arenaQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: user.id,
|
||||
player,
|
||||
matchId: match.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'matched', side: 'b', match: arenaSnapshot(match) }
|
||||
}
|
||||
|
||||
if (existingTicket) {
|
||||
existingTicket.updatedAt = now
|
||||
return { ticketId: existingTicket.id, status: 'waiting' }
|
||||
}
|
||||
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
arenaQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: user.id,
|
||||
player,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'waiting' }
|
||||
}
|
||||
|
||||
function checkArenaQueue(user, ticketId) {
|
||||
cleanupArenaMemory()
|
||||
const ticket = arenaQueue.get(ticketId)
|
||||
if (!ticket || ticket.accountId !== user.id) {
|
||||
const error = new Error('Arena queue ticket not found.')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
ticket.updatedAt = Date.now()
|
||||
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
||||
const match = arenaMatches.get(ticket.matchId)
|
||||
if (!match) return { ticketId, status: 'waiting' }
|
||||
const side = match.players.a.accountId === user.id ? 'a' : 'b'
|
||||
return { ticketId, status: 'matched', side, match: arenaSnapshot(match) }
|
||||
}
|
||||
|
||||
function cancelArenaQueue(user, ticketId) {
|
||||
const ticket = arenaQueue.get(ticketId)
|
||||
if (ticket && ticket.accountId === user.id && !ticket.matchId) arenaQueue.delete(ticketId)
|
||||
}
|
||||
|
||||
async function handleAuthApi(request, response, url) {
|
||||
if (!url.pathname.startsWith('/api/auth/')) return false
|
||||
|
||||
|
||||
+22
-11
@@ -8,7 +8,6 @@ import {
|
||||
type AuthSession,
|
||||
} from './actionApi'
|
||||
import type { ActionMechanicConfig } from './actionBoss/actionEncounterConfig'
|
||||
import type { ActionCharacter } from './actionMode'
|
||||
import { ActionModeScreen } from './components/ActionModeScreen'
|
||||
import { AuthScreen } from './components/AuthScreen'
|
||||
|
||||
@@ -54,15 +53,21 @@ export function App() {
|
||||
setSession(nextSession)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logoutAccount()
|
||||
setSession({ account: null })
|
||||
setMessage('Signed out.')
|
||||
function handlePlayOffline() {
|
||||
setSession({ account: null, offline: true })
|
||||
setMessage('')
|
||||
setScreenKey((current) => current + 1)
|
||||
}
|
||||
|
||||
function syncCharacter(character: ActionCharacter) {
|
||||
async function handleLogout() {
|
||||
if (!session?.offline) await logoutAccount()
|
||||
setSession({ account: null })
|
||||
setMessage(session?.offline ? 'Offline mode ended.' : 'Signed out.')
|
||||
}
|
||||
|
||||
function syncCharacter() {
|
||||
if (!session?.account) return
|
||||
pushCloudSave({ ...localCloudSave(), character }).catch(() => null)
|
||||
pushCloudSave(localCloudSave()).catch(() => null)
|
||||
}
|
||||
|
||||
function syncMechanics(mechanics: ActionMechanicConfig) {
|
||||
@@ -84,18 +89,24 @@ export function App() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!session?.account) {
|
||||
return <AuthScreen onAuthenticated={handleAuthenticated} serverMessage={message} />
|
||||
if (!session?.account && !session?.offline) {
|
||||
return (
|
||||
<AuthScreen
|
||||
onAuthenticated={handleAuthenticated}
|
||||
onPlayOffline={handlePlayOffline}
|
||||
serverMessage={message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionModeScreen
|
||||
key={screenKey}
|
||||
account={session.account}
|
||||
account={session.account ?? undefined}
|
||||
authActionLabel={session.offline ? 'Sign In' : 'Logout'}
|
||||
onCharacterSaved={syncCharacter}
|
||||
onLogout={handleLogout}
|
||||
onMechanicsSaved={syncMechanics}
|
||||
serverMessage={message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
import type { PartyRole } from './actionCombatCore'
|
||||
import { getArenaAbility, getArenaClassKit, type ArenaAbilityId, type ArenaClassId } from './actionClassKits'
|
||||
|
||||
export type CombatRenderUnit = {
|
||||
classId?: ArenaClassId
|
||||
id: string
|
||||
role: PartyRole | 'player' | 'boss'
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
type CombatModelRuntime = {
|
||||
actions: Map<string, THREE.AnimationAction>
|
||||
clips: Map<string, THREE.AnimationClip>
|
||||
currentAction: string
|
||||
mixer: THREE.AnimationMixer
|
||||
}
|
||||
|
||||
type CombatAnimationRequest = string | string[]
|
||||
|
||||
const combatModelLoader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder)
|
||||
const combatModelCache = new Map<string, Promise<GLTF>>()
|
||||
const combatWeaponCache = new Map<string, Promise<GLTF>>()
|
||||
let kayKitMediumAnimationRequest: Promise<THREE.AnimationClip[]> | null = null
|
||||
const KAYKIT_COMBAT_ANIMATION_URLS = [
|
||||
'/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_General.glb',
|
||||
'/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_MovementBasic.glb',
|
||||
'/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_CombatMelee.glb',
|
||||
'/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_CombatRanged.glb',
|
||||
]
|
||||
const IDLE_ANIMATIONS = ['Idle_A', 'Idle_B', 'Skeletons_Idle', 'Idle', 'Idle_Combat']
|
||||
const RUN_ANIMATIONS = ['Running_A', 'Running_B', 'Walking_A', 'Running_Strafe_Left', 'Walking_Backwards']
|
||||
const SPELL_CAST_ANIMATIONS = ['Ranged_Magic_Raise', 'Spellcast_Raise', 'Spellcasting', 'Ranged_Magic_Spellcasting']
|
||||
const SPELL_SHOOT_ANIMATIONS = ['Ranged_Magic_Shoot', 'Spellcast_Shoot', 'Ranged_Magic_Spellcasting', 'Ranged_Magic_Raise', 'Spellcasting']
|
||||
const RANGED_ATTACK_ANIMATIONS = ['Ranged_Bow_Release', 'Ranged_2H_Shoot', 'Ranged_1H_Shoot', 'Ranged_Magic_Shoot', '2H_Ranged_Shoot']
|
||||
const MELEE_ATTACK_ANIMATIONS = ['Melee_1H_Attack_Chop', 'Melee_1H_Attack_Slice_Diagonal', 'Melee_1H_Attack_Stab', '1H_Melee_Attack_Chop']
|
||||
const MELEE_HEAVY_ATTACK_ANIMATIONS = ['Melee_2H_Attack_Chop', 'Melee_2H_Attack_Slice', 'Melee_2H_Attack_Stab', '2H_Melee_Attack_Chop']
|
||||
const OGA_DRAGON_MODEL_URL = '/action-assets/models/opengameart/dragon-oga/dragon-oga-animated.glb'
|
||||
const BULLDROME_IDLE_ANIMATIONS = ['Idle1', 'Idle_AnimalArmature', 'Idle_MonsterArmature', 'Idle']
|
||||
const BULLDROME_WALK_ANIMATIONS = ['Gallop_AnimalArmature', 'Walk_AnimalArmature', 'Walk_MonsterArmature']
|
||||
const BULLDROME_ATTACK_ANIMATIONS = ['Attack2 (tusks)', 'Attack_Headbutt_AnimalArmature', 'Bite_Front_MonsterArmature']
|
||||
const BULLDROME_DEATH_ANIMATIONS = ['Death_AnimalArmature', 'Death_MonsterArmature', 'Dying']
|
||||
const COMBAT_ANIMATION_NAMES = new Set([
|
||||
...IDLE_ANIMATIONS,
|
||||
...RUN_ANIMATIONS,
|
||||
...SPELL_CAST_ANIMATIONS,
|
||||
...SPELL_SHOOT_ANIMATIONS,
|
||||
...RANGED_ATTACK_ANIMATIONS,
|
||||
...MELEE_ATTACK_ANIMATIONS,
|
||||
...MELEE_HEAVY_ATTACK_ANIMATIONS,
|
||||
...BULLDROME_IDLE_ANIMATIONS,
|
||||
...BULLDROME_WALK_ANIMATIONS,
|
||||
...BULLDROME_ATTACK_ANIMATIONS,
|
||||
...BULLDROME_DEATH_ANIMATIONS,
|
||||
'Block',
|
||||
'Dragon_Attack',
|
||||
'Dragon_Death',
|
||||
'Dragon_Idle',
|
||||
'Dragon_Walk',
|
||||
'Dualwield_Melee_Attack_Chop',
|
||||
'Flying_Idle',
|
||||
'Idle1',
|
||||
'Melee_Block',
|
||||
'Melee_Blocking',
|
||||
'Melee_Dualwield_Attack_Chop',
|
||||
'Melee_Dualwield_Attack_Slice',
|
||||
'Spellcast_Raise',
|
||||
'Spellcast_Shoot',
|
||||
])
|
||||
const OGA_DRAGON_FOOT_BONES = [
|
||||
'Armature_leg_front_foot_L',
|
||||
'Armature_leg_front_foot_R',
|
||||
'Armature_leg_back_foot_L',
|
||||
'Armature_leg_back_foot_R',
|
||||
]
|
||||
|
||||
export function getEnemyModelYawOffset(kind: string) {
|
||||
if (kind === 'bullfango' || kind === 'yian-kut-ku' || kind === 'bird') return Math.PI
|
||||
return 0
|
||||
}
|
||||
|
||||
export function addCombatUnitMesh(group: THREE.Group, unit: CombatRenderUnit) {
|
||||
group.userData.role = unit.role
|
||||
const fallback = new THREE.Group()
|
||||
fallback.name = 'proceduralFallback'
|
||||
group.add(fallback)
|
||||
|
||||
if (unit.role === 'player') {
|
||||
addHunterMesh(fallback)
|
||||
addClaudeCraftCombatModel(group, { ...unit, classId: unit.classId ?? 'priest' })
|
||||
return
|
||||
}
|
||||
if (unit.role === 'boss') return
|
||||
if (unit.role === 'healer') {
|
||||
addHunterMesh(fallback)
|
||||
addClaudeCraftCombatModel(group, { ...unit, classId: unit.classId ?? 'priest' })
|
||||
return
|
||||
}
|
||||
addPartyMesh(fallback, unit.role, unit.id)
|
||||
addClaudeCraftCombatModel(group, {
|
||||
...unit,
|
||||
classId: unit.classId ?? (unit.role === 'tank' ? 'knight' : unit.role === 'melee' ? 'rogue' : unit.id.includes('ranged-2') ? 'ranger' : 'mage'),
|
||||
})
|
||||
}
|
||||
|
||||
function addClaudeCraftCombatModel(group: THREE.Group, unit: Exclude<CombatRenderUnit, { role: 'boss' }> & { classId: ArenaClassId }) {
|
||||
if (unit.role === 'boss') return
|
||||
|
||||
const kit = getArenaClassKit(unit.classId)
|
||||
const loadToken = Symbol(kit.model)
|
||||
group.userData.combatModelToken = loadToken
|
||||
group.userData.classId = unit.classId
|
||||
|
||||
Promise.all([loadCombatModel(kit.model), loadKayKitMediumAnimationClips()])
|
||||
.then(([gltf, kayKitClips]) => {
|
||||
if (group.userData.combatModelToken !== loadToken) return
|
||||
|
||||
const model = cloneSkeleton(gltf.scene)
|
||||
model.name = 'claudeCraftCombatModel'
|
||||
model.scale.setScalar(unit.classId === 'priest' ? 0.5 : 0.56)
|
||||
model.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
})
|
||||
|
||||
const mixer = new THREE.AnimationMixer(model)
|
||||
const clips = createCombatClipMap([...kayKitClips, ...gltf.animations])
|
||||
|
||||
const runtime: CombatModelRuntime = {
|
||||
actions: new Map<string, THREE.AnimationAction>(),
|
||||
clips,
|
||||
currentAction: '',
|
||||
mixer,
|
||||
}
|
||||
group.userData.combatModelRuntime = runtime
|
||||
group.add(model)
|
||||
addClaudeCraftWeapons(model, unit.classId)
|
||||
setCombatModelAction(runtime, IDLE_ANIMATIONS, true)
|
||||
|
||||
const fallback = group.getObjectByName('proceduralFallback')
|
||||
if (fallback) fallback.visible = false
|
||||
})
|
||||
.catch(() => {
|
||||
const fallback = group.getObjectByName('proceduralFallback')
|
||||
if (fallback) fallback.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
function loadCombatModel(modelUrl: string) {
|
||||
let request = combatModelCache.get(modelUrl)
|
||||
if (!request) {
|
||||
request = combatModelLoader.loadAsync(modelUrl)
|
||||
combatModelCache.set(modelUrl, request)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function loadKayKitMediumAnimationClips() {
|
||||
if (!kayKitMediumAnimationRequest) {
|
||||
kayKitMediumAnimationRequest = Promise.all(KAYKIT_COMBAT_ANIMATION_URLS.map((url) => combatModelLoader.loadAsync(url)))
|
||||
.then((gltfs) => {
|
||||
const clips: THREE.AnimationClip[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const gltf of gltfs) {
|
||||
for (const clip of gltf.animations) {
|
||||
if (!COMBAT_ANIMATION_NAMES.has(clip.name) || seen.has(clip.name)) continue
|
||||
seen.add(clip.name)
|
||||
clips.push(clip.clone())
|
||||
}
|
||||
}
|
||||
return clips
|
||||
})
|
||||
}
|
||||
return kayKitMediumAnimationRequest
|
||||
}
|
||||
|
||||
function createCombatClipMap(clips: THREE.AnimationClip[]) {
|
||||
const map = new Map<string, THREE.AnimationClip>()
|
||||
for (const clip of clips) {
|
||||
if (!COMBAT_ANIMATION_NAMES.has(clip.name) || map.has(clip.name)) continue
|
||||
map.set(clip.name, clip)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function loadCombatWeapon(weaponUrl: string) {
|
||||
let request = combatWeaponCache.get(weaponUrl)
|
||||
if (!request) {
|
||||
request = combatModelLoader.loadAsync(weaponUrl)
|
||||
combatWeaponCache.set(weaponUrl, request)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function addClaudeCraftWeapons(model: THREE.Object3D, classId: ArenaClassId) {
|
||||
for (const weapon of getArenaClassKit(classId).weapons) {
|
||||
attachClaudeCraftWeapon(model, weapon.hand, weapon.model)
|
||||
}
|
||||
}
|
||||
|
||||
function attachClaudeCraftWeapon(model: THREE.Object3D, handSlotName: string, weaponUrl: string) {
|
||||
const handSlot = findAttachmentSlot(model, handSlotName)
|
||||
if (!handSlot) return
|
||||
|
||||
loadCombatWeapon(weaponUrl)
|
||||
.then((gltf) => {
|
||||
const weaponModel = cloneSkeleton(gltf.scene)
|
||||
weaponModel.name = 'claudeCraftWeapon'
|
||||
weaponModel.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
})
|
||||
handSlot.add(weaponModel)
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
function findAttachmentSlot(model: THREE.Object3D, handSlotName: string) {
|
||||
const exact = model.getObjectByName(handSlotName)
|
||||
if (exact) return exact
|
||||
|
||||
const normalizedName = normalizeSlotName(handSlotName)
|
||||
let match: THREE.Object3D | null = null
|
||||
model.traverse((child) => {
|
||||
if (!match && normalizeSlotName(child.name) === normalizedName) match = child
|
||||
})
|
||||
return match
|
||||
}
|
||||
|
||||
function normalizeSlotName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
function setCombatModelAction(runtime: CombatModelRuntime, requestedAction: CombatAnimationRequest, instant = false) {
|
||||
const requestedActions = Array.isArray(requestedAction) ? requestedAction : [requestedAction]
|
||||
const nextAction = requestedActions.map((name) => getCombatAction(runtime, name)).find(Boolean)
|
||||
?? IDLE_ANIMATIONS.map((name) => getCombatAction(runtime, name)).find(Boolean)
|
||||
?? getCombatAction(runtime, runtime.clips.keys().next().value)
|
||||
if (!nextAction || runtime.currentAction === nextAction.getClip().name) return
|
||||
|
||||
const previousAction = runtime.actions.get(runtime.currentAction)
|
||||
const isDeathAction = nextAction.getClip().name === 'Dragon_Death'
|
||||
|| BULLDROME_DEATH_ANIMATIONS.includes(nextAction.getClip().name)
|
||||
nextAction.enabled = true
|
||||
nextAction.clampWhenFinished = isDeathAction
|
||||
nextAction.setLoop(isDeathAction ? THREE.LoopOnce : THREE.LoopRepeat, Infinity)
|
||||
nextAction.reset()
|
||||
nextAction.play()
|
||||
if (previousAction && !instant) {
|
||||
previousAction.crossFadeTo(nextAction, 0.16, false)
|
||||
} else {
|
||||
nextAction.weight = 1
|
||||
}
|
||||
runtime.currentAction = nextAction.getClip().name
|
||||
}
|
||||
|
||||
function getCombatAction(runtime: CombatModelRuntime, name: string | undefined) {
|
||||
if (!name) return null
|
||||
const existing = runtime.actions.get(name)
|
||||
if (existing) return existing
|
||||
const clip = runtime.clips.get(name)
|
||||
if (!clip) return null
|
||||
const action = runtime.mixer.clipAction(clip)
|
||||
runtime.actions.set(name, action)
|
||||
return action
|
||||
}
|
||||
|
||||
export function updateCombatUnitModel(
|
||||
mesh: THREE.Object3D,
|
||||
deltaSeconds: number,
|
||||
options?: { abilityId?: ArenaAbilityId | null, action?: 'idle' | 'attack' | 'run' | 'spell', role?: PartyRole | 'boss' | 'player' },
|
||||
) {
|
||||
const runtime = mesh.userData.combatModelRuntime as CombatModelRuntime | undefined
|
||||
if (!runtime) return
|
||||
|
||||
const role = options?.role ?? (mesh.userData.role as PartyRole | 'boss' | 'player' | undefined)
|
||||
if (role === 'boss' && mesh.userData.kind === 'bulldrome') {
|
||||
const action = options?.action === 'attack' || options?.action === 'spell'
|
||||
? BULLDROME_ATTACK_ANIMATIONS
|
||||
: options?.action === 'run'
|
||||
? BULLDROME_WALK_ANIMATIONS
|
||||
: BULLDROME_IDLE_ANIMATIONS
|
||||
setCombatModelAction(runtime, action)
|
||||
runtime.mixer.update(deltaSeconds)
|
||||
return
|
||||
}
|
||||
|
||||
const action = options?.abilityId
|
||||
? getAnimationForAbility(options.abilityId)
|
||||
: options?.action === 'spell'
|
||||
? SPELL_CAST_ANIMATIONS
|
||||
: options?.action === 'attack'
|
||||
? role === 'ranged' ? RANGED_ATTACK_ANIMATIONS : role === 'healer' ? SPELL_SHOOT_ANIMATIONS : MELEE_ATTACK_ANIMATIONS
|
||||
: options?.action === 'run'
|
||||
? RUN_ANIMATIONS
|
||||
: IDLE_ANIMATIONS
|
||||
setCombatModelAction(runtime, action)
|
||||
runtime.mixer.update(deltaSeconds)
|
||||
}
|
||||
|
||||
export function updateBossModelAnimation(
|
||||
mesh: THREE.Object3D,
|
||||
deltaSeconds: number,
|
||||
action: 'idle' | 'walk' | 'attack' | 'death' = 'idle',
|
||||
) {
|
||||
const runtime = mesh.userData.combatModelRuntime as CombatModelRuntime | undefined
|
||||
if (!runtime) return
|
||||
const animation: CombatAnimationRequest = mesh.userData.kind === 'bulldrome'
|
||||
? action === 'attack'
|
||||
? BULLDROME_ATTACK_ANIMATIONS
|
||||
: action === 'walk'
|
||||
? BULLDROME_WALK_ANIMATIONS
|
||||
: action === 'death'
|
||||
? BULLDROME_DEATH_ANIMATIONS
|
||||
: BULLDROME_IDLE_ANIMATIONS
|
||||
: action === 'attack'
|
||||
? 'Dragon_Attack'
|
||||
: action === 'walk'
|
||||
? 'Dragon_Walk'
|
||||
: action === 'death'
|
||||
? 'Dragon_Death'
|
||||
: 'Dragon_Idle'
|
||||
setCombatModelAction(runtime, animation)
|
||||
runtime.mixer.update(deltaSeconds)
|
||||
if (mesh.userData.kind === 'cyber-dragon') lockDragonFeetToGround(mesh)
|
||||
}
|
||||
|
||||
function getAnimationForAbility(abilityId: ArenaAbilityId) {
|
||||
const ability = getArenaAbility(abilityId)
|
||||
if (ability.animation === 'block') return ['Melee_Block', 'Melee_Blocking', 'Block', ...MELEE_ATTACK_ANIMATIONS]
|
||||
if (ability.animation === 'dual') return ['Melee_Dualwield_Attack_Chop', 'Melee_Dualwield_Attack_Slice', 'Dualwield_Melee_Attack_Chop', ...MELEE_ATTACK_ANIMATIONS]
|
||||
if (ability.animation === 'melee') return abilityId === 'thunder_clap' ? MELEE_HEAVY_ATTACK_ANIMATIONS : [...MELEE_ATTACK_ANIMATIONS, ...MELEE_HEAVY_ATTACK_ANIMATIONS]
|
||||
if (ability.animation === 'ranged') return RANGED_ATTACK_ANIMATIONS
|
||||
return ability.castTime > 0 ? SPELL_CAST_ANIMATIONS : SPELL_SHOOT_ANIMATIONS
|
||||
}
|
||||
|
||||
export function addBoarMesh(group: THREE.Group, options?: { color?: number, kind?: 'bulldrome' | 'bullfango' }) {
|
||||
const isBulldrome = options?.kind !== 'bullfango'
|
||||
const hide = new THREE.MeshStandardMaterial({ color: options?.color ?? (isBulldrome ? 0x5f4b3e : 0x8a7049), roughness: 0.9 })
|
||||
const dark = new THREE.MeshStandardMaterial({ color: 0x2f211b, roughness: 0.85 })
|
||||
const hornMaterial = new THREE.MeshStandardMaterial({ color: 0xf4eed8, roughness: 0.5 })
|
||||
|
||||
const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.78 : 0.52, 18, 12), hide)
|
||||
bodyMesh.scale.set(isBulldrome ? 1.35 : 1.12, isBulldrome ? 0.68 : 0.58, isBulldrome ? 1.65 : 1.28)
|
||||
bodyMesh.position.y = isBulldrome ? 0.62 : 0.48
|
||||
bodyMesh.castShadow = true
|
||||
group.add(bodyMesh)
|
||||
|
||||
const chest = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.52 : 0.34, 14, 10), dark)
|
||||
chest.scale.set(1.05, 0.9, 0.9)
|
||||
chest.position.set(0, isBulldrome ? 0.76 : 0.55, isBulldrome ? -0.95 : -0.66)
|
||||
chest.castShadow = true
|
||||
group.add(chest)
|
||||
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.42 : 0.28, 14, 10), hide)
|
||||
head.scale.set(1.05, 0.82, 0.86)
|
||||
head.position.set(0, isBulldrome ? 0.86 : 0.62, isBulldrome ? -1.35 : -0.9)
|
||||
head.castShadow = true
|
||||
group.add(head)
|
||||
|
||||
const snout = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.22 : 0.16, 10, 8), dark)
|
||||
snout.scale.set(1.2, 0.62, 0.85)
|
||||
snout.position.set(0, isBulldrome ? 0.78 : 0.55, isBulldrome ? -1.68 : -1.12)
|
||||
snout.castShadow = true
|
||||
group.add(snout)
|
||||
|
||||
for (const x of [-0.28, 0.28]) {
|
||||
const horn = new THREE.Mesh(new THREE.ConeGeometry(isBulldrome ? 0.1 : 0.065, isBulldrome ? 0.58 : 0.34, 8), hornMaterial)
|
||||
horn.position.set(x * (isBulldrome ? 1.5 : 1), isBulldrome ? 1.02 : 0.73, isBulldrome ? -1.5 : -1.0)
|
||||
horn.rotation.set(-Math.PI / 2, 0, x < 0 ? -0.45 : 0.45)
|
||||
horn.castShadow = true
|
||||
group.add(horn)
|
||||
}
|
||||
|
||||
for (const x of [-0.42, 0.42]) {
|
||||
for (const z of [isBulldrome ? -0.72 : -0.48, isBulldrome ? 0.64 : 0.42]) {
|
||||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(isBulldrome ? 0.1 : 0.075, isBulldrome ? 0.12 : 0.09, isBulldrome ? 0.58 : 0.42, 8), dark)
|
||||
leg.position.set(x, isBulldrome ? 0.24 : 0.18, z)
|
||||
leg.castShadow = true
|
||||
group.add(leg)
|
||||
}
|
||||
}
|
||||
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(isBulldrome ? 0.06 : 0.04, isBulldrome ? 0.5 : 0.3, 8), dark)
|
||||
tail.position.set(0, isBulldrome ? 0.74 : 0.52, isBulldrome ? 1.35 : 0.9)
|
||||
tail.rotation.x = Math.PI / 2
|
||||
tail.castShadow = true
|
||||
group.add(tail)
|
||||
}
|
||||
|
||||
export type ClaudeCraftBossModelKind =
|
||||
| 'deacon-varric'
|
||||
| 'vael-the-mistcaller'
|
||||
| 'ysolei'
|
||||
| 'korgath-the-bound'
|
||||
| 'grand-necromancer-velkhar'
|
||||
| 'korzul-the-gravewyrm'
|
||||
| 'bulldrome'
|
||||
| 'bullfango'
|
||||
| 'cyber-dragon'
|
||||
|
||||
export function addClaudeCraftBossModel(group: THREE.Group, kind: ClaudeCraftBossModelKind) {
|
||||
const modelUrl = getClaudeCraftBossModelUrl(kind)
|
||||
const loadToken = Symbol(modelUrl)
|
||||
group.userData.bossModelToken = loadToken
|
||||
loadCombatModel(modelUrl)
|
||||
.then((gltf) => {
|
||||
if (group.userData.bossModelToken !== loadToken) return
|
||||
const model = cloneSkeleton(gltf.scene)
|
||||
model.name = 'claudeCraftBossModel'
|
||||
model.scale.setScalar(getClaudeCraftBossModelScale(kind))
|
||||
if (kind === 'cyber-dragon') normalizeCyberDragonModel(model)
|
||||
if (kind === 'bulldrome') normalizeBulldromeModel(model)
|
||||
model.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.frustumCulled = false
|
||||
child.receiveShadow = true
|
||||
})
|
||||
const mixer = new THREE.AnimationMixer(model)
|
||||
const runtime: CombatModelRuntime = {
|
||||
actions: new Map<string, THREE.AnimationAction>(),
|
||||
clips: createCombatClipMap(gltf.animations),
|
||||
currentAction: '',
|
||||
mixer,
|
||||
}
|
||||
group.userData.combatModelRuntime = runtime
|
||||
group.add(model)
|
||||
setCombatModelAction(runtime, getClaudeCraftBossIdleAction(kind), true)
|
||||
const fallback = group.getObjectByName('proceduralFallback')
|
||||
if (fallback) fallback.visible = false
|
||||
})
|
||||
.catch(() => {
|
||||
const fallback = group.getObjectByName('proceduralFallback')
|
||||
if (fallback) fallback.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelCombatRenderLoads(root: THREE.Object3D) {
|
||||
root.traverse((object) => {
|
||||
object.userData.combatModelToken = null
|
||||
object.userData.bossModelToken = null
|
||||
})
|
||||
}
|
||||
|
||||
export function clearCombatRenderCaches() {
|
||||
const pending = [...combatModelCache.values(), ...combatWeaponCache.values()]
|
||||
combatModelCache.clear()
|
||||
combatWeaponCache.clear()
|
||||
kayKitMediumAnimationRequest = null
|
||||
for (const request of pending) request.then(disposeCachedGltf).catch(() => null)
|
||||
}
|
||||
|
||||
function disposeCachedGltf(gltf: GLTF) {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.geometry.dispose()
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
for (const material of materials) disposeMaterial(material)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value instanceof THREE.Texture) value.dispose()
|
||||
}
|
||||
material.dispose()
|
||||
}
|
||||
|
||||
function getClaudeCraftBossModelUrl(kind: ClaudeCraftBossModelKind) {
|
||||
if (kind === 'cyber-dragon') return OGA_DRAGON_MODEL_URL
|
||||
if (kind === 'ysolei' || kind === 'korzul-the-gravewyrm') {
|
||||
return '/action-assets/models/claudecraft/creatures/dragonevolved.glb'
|
||||
}
|
||||
if (kind === 'bullfango') return '/action-assets/models/claudecraft/creatures/bull.glb'
|
||||
if (kind === 'bulldrome') return '/action-assets/models/claudecraft/creatures/wild_boar.glb'
|
||||
if (kind === 'korgath-the-bound') return '/action-assets/models/claudecraft/creatures/giant.glb'
|
||||
if (kind === 'vael-the-mistcaller') return '/action-assets/models/claudecraft/chars/players/mage_classic.glb'
|
||||
return '/action-assets/models/claudecraft/chars/enemies/necromancer.glb'
|
||||
}
|
||||
|
||||
function getClaudeCraftBossModelScale(kind: ClaudeCraftBossModelKind) {
|
||||
if (kind === 'cyber-dragon') return 4.5
|
||||
if (kind === 'bulldrome') return 0.35
|
||||
if (kind === 'korzul-the-gravewyrm') return 1.08
|
||||
if (kind === 'ysolei') return 0.92
|
||||
if (kind === 'korgath-the-bound') return 1.1
|
||||
if (kind === 'deacon-varric' || kind === 'grand-necromancer-velkhar') return 1.05
|
||||
return 0.72
|
||||
}
|
||||
|
||||
function getClaudeCraftBossIdleAction(kind: ClaudeCraftBossModelKind) {
|
||||
if (kind === 'cyber-dragon') return 'Dragon_Idle'
|
||||
if (kind === 'ysolei' || kind === 'korzul-the-gravewyrm') return 'Flying_Idle'
|
||||
if (kind === 'bulldrome') return 'Idle1'
|
||||
return 'Idle'
|
||||
}
|
||||
|
||||
function normalizeCyberDragonModel(model: THREE.Object3D) {
|
||||
liftModelBottomToGround(model)
|
||||
model.position.y -= 0.03
|
||||
}
|
||||
|
||||
function normalizeBulldromeModel(model: THREE.Object3D) {
|
||||
liftModelBottomToGround(model)
|
||||
}
|
||||
|
||||
function liftModelBottomToGround(model: THREE.Object3D) {
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
if (!Number.isFinite(box.min.y)) return
|
||||
model.position.y -= box.min.y
|
||||
}
|
||||
|
||||
function lockDragonFeetToGround(mesh: THREE.Object3D) {
|
||||
const model = mesh.getObjectByName('claudeCraftBossModel')
|
||||
if (!model) return
|
||||
|
||||
const footPosition = new THREE.Vector3()
|
||||
let lowestFootY = Infinity
|
||||
for (const boneName of OGA_DRAGON_FOOT_BONES) {
|
||||
const bone = model.getObjectByName(boneName)
|
||||
if (!bone) continue
|
||||
bone.getWorldPosition(footPosition)
|
||||
lowestFootY = Math.min(lowestFootY, footPosition.y)
|
||||
}
|
||||
|
||||
if (Number.isFinite(lowestFootY)) {
|
||||
model.position.y += 0.03 - lowestFootY
|
||||
return
|
||||
}
|
||||
|
||||
liftModelBottomToGround(model)
|
||||
}
|
||||
|
||||
export function addBirdMesh(group: THREE.Group, kind: 'bird' | 'yian-kut-ku' = 'bird') {
|
||||
const isYian = kind === 'yian-kut-ku'
|
||||
const feather = new THREE.MeshStandardMaterial({ color: isYian ? 0xd94d2f : 0x7b6ac0, roughness: 0.72 })
|
||||
const gold = new THREE.MeshStandardMaterial({ color: 0xffc857, roughness: 0.55, emissive: isYian ? 0x7a2200 : 0x000000, emissiveIntensity: isYian ? 0.25 : 0 })
|
||||
const dark = new THREE.MeshStandardMaterial({ color: 0x301015, roughness: 0.82 })
|
||||
|
||||
const torso = new THREE.Mesh(new THREE.SphereGeometry(isYian ? 0.48 : 0.28, 16, 10), feather)
|
||||
torso.scale.set(0.9, isYian ? 1.25 : 1.05, isYian ? 1.05 : 1.2)
|
||||
torso.position.y = isYian ? 0.82 : 0.55
|
||||
torso.castShadow = true
|
||||
group.add(torso)
|
||||
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(isYian ? 0.24 : 0.16, 12, 8), feather)
|
||||
head.position.set(0, isYian ? 1.35 : 0.88, isYian ? -0.34 : -0.22)
|
||||
head.castShadow = true
|
||||
group.add(head)
|
||||
|
||||
const beak = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.09 : 0.06, isYian ? 0.32 : 0.2, 8), gold)
|
||||
beak.position.set(0, isYian ? 1.34 : 0.87, isYian ? -0.58 : -0.38)
|
||||
beak.rotation.x = -Math.PI / 2
|
||||
beak.castShadow = true
|
||||
group.add(beak)
|
||||
|
||||
for (const x of [-1, 1]) {
|
||||
const wing = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.34 : 0.2, isYian ? 1.35 : 0.78, 4), feather)
|
||||
wing.position.set(x * (isYian ? 0.68 : 0.38), isYian ? 0.95 : 0.66, 0)
|
||||
wing.rotation.set(0.4, 0, x < 0 ? 1.05 : -1.05)
|
||||
wing.scale.set(1, 0.38, 1.45)
|
||||
wing.castShadow = true
|
||||
group.add(wing)
|
||||
}
|
||||
|
||||
for (let i = -1; i <= 1; i += 1) {
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.12 : 0.07, isYian ? 0.9 : 0.48, 4), i === 0 ? gold : feather)
|
||||
tail.position.set(i * (isYian ? 0.18 : 0.1), isYian ? 0.62 : 0.42, isYian ? 0.72 : 0.42)
|
||||
tail.rotation.set(Math.PI / 2.8, 0, i * 0.28)
|
||||
tail.castShadow = true
|
||||
group.add(tail)
|
||||
}
|
||||
|
||||
for (const x of [-0.13, 0.13]) {
|
||||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(isYian ? 0.035 : 0.025, isYian ? 0.045 : 0.03, isYian ? 0.32 : 0.22, 6), dark)
|
||||
leg.position.set(x, isYian ? 0.26 : 0.18, -0.04)
|
||||
leg.castShadow = true
|
||||
group.add(leg)
|
||||
}
|
||||
}
|
||||
|
||||
export function addCyberDragonMesh(group: THREE.Group) {
|
||||
const shell = new THREE.MeshStandardMaterial({ color: 0x31566f, roughness: 0.42, metalness: 0.55 })
|
||||
const glow = new THREE.MeshStandardMaterial({ color: 0x6feeff, roughness: 0.25, metalness: 0.3, emissive: 0x1b7f99, emissiveIntensity: 0.55 })
|
||||
const darkMetal = new THREE.MeshStandardMaterial({ color: 0x152432, roughness: 0.48, metalness: 0.72 })
|
||||
|
||||
const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.62, 16, 10), shell)
|
||||
bodyMesh.scale.set(1.35, 0.62, 1.55)
|
||||
bodyMesh.position.y = 0.72
|
||||
bodyMesh.castShadow = true
|
||||
group.add(bodyMesh)
|
||||
|
||||
const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.72, 8), darkMetal)
|
||||
neck.position.set(0, 0.88, -0.74)
|
||||
neck.rotation.x = -0.75
|
||||
neck.castShadow = true
|
||||
group.add(neck)
|
||||
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.34, 12, 8), shell)
|
||||
head.scale.set(1.05, 0.78, 1.2)
|
||||
head.position.set(0, 1.12, -1.18)
|
||||
head.castShadow = true
|
||||
group.add(head)
|
||||
|
||||
const snout = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.42, 4), glow)
|
||||
snout.position.set(0, 1.1, -1.53)
|
||||
snout.rotation.x = -Math.PI / 2
|
||||
snout.castShadow = true
|
||||
group.add(snout)
|
||||
|
||||
for (const x of [-0.16, 0.16]) {
|
||||
const horn = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.32, 6), glow)
|
||||
horn.position.set(x, 1.36, -1.2)
|
||||
horn.rotation.x = -0.55
|
||||
horn.castShadow = true
|
||||
group.add(horn)
|
||||
}
|
||||
|
||||
for (const x of [-1, 1]) {
|
||||
const wing = new THREE.Mesh(new THREE.ConeGeometry(0.36, 1.35, 4), glow)
|
||||
wing.position.set(x * 0.78, 1.0, 0.02)
|
||||
wing.rotation.set(0.25, 0, x < 0 ? 1.08 : -1.08)
|
||||
wing.scale.set(1, 0.32, 1.5)
|
||||
wing.castShadow = true
|
||||
group.add(wing)
|
||||
}
|
||||
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.16, 1.2, 8), darkMetal)
|
||||
tail.position.set(0, 0.68, 1.26)
|
||||
tail.rotation.x = Math.PI / 2
|
||||
tail.castShadow = true
|
||||
group.add(tail)
|
||||
|
||||
for (const x of [-0.42, 0.42]) {
|
||||
for (const z of [-0.38, 0.52]) {
|
||||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.46, 8), darkMetal)
|
||||
leg.position.set(x, 0.28, z)
|
||||
leg.castShadow = true
|
||||
group.add(leg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function animateCombatWeapon(
|
||||
mesh: THREE.Object3D,
|
||||
role: PartyRole,
|
||||
options: { elapsed: number, inMelee: boolean },
|
||||
) {
|
||||
if (role !== 'tank' && role !== 'melee') return
|
||||
const swing = options.inMelee ? 0.5 - Math.cos(options.elapsed * (role === 'tank' ? 12 : 10.5)) * 0.5 : 0
|
||||
|
||||
if (role === 'tank') {
|
||||
const sword = mesh.getObjectByName('tankSword')
|
||||
if (sword) {
|
||||
sword.rotation.x = 0.18 - swing * 0.35
|
||||
sword.rotation.y = 0.12 + swing * 0.18
|
||||
sword.rotation.z = -0.55 + swing * 0.72
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sword = mesh.getObjectByName('twoHandSword')
|
||||
if (sword) {
|
||||
sword.rotation.x = 0.26 - swing * 0.42
|
||||
sword.rotation.y = 0.08 + swing * 0.28
|
||||
sword.rotation.z = -0.82 + swing * 1.35
|
||||
}
|
||||
}
|
||||
|
||||
export function syncRangedCombatProjectiles(
|
||||
scene: THREE.Scene,
|
||||
effects: Map<string, THREE.Group>,
|
||||
options: {
|
||||
elapsed: number
|
||||
teamKey: string
|
||||
toWorld: (x: number, y: number) => { x: number, z: number }
|
||||
target: { x: number, y: number } | null
|
||||
staffCaster?: { x: number, y: number } | null
|
||||
archer?: { x: number, y: number } | null
|
||||
},
|
||||
) {
|
||||
const active = new Set<string>()
|
||||
if (!options.target) {
|
||||
pruneCombatEffects(effects, active)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.staffCaster) {
|
||||
const id = `${options.teamKey}-staff-spell`
|
||||
active.add(id)
|
||||
const effect = getOrCreateStaffSpell(scene, effects, id)
|
||||
const t = (options.elapsed * 1.45) % 1
|
||||
const start = options.toWorld(options.staffCaster.x, options.staffCaster.y)
|
||||
const end = options.toWorld(options.target.x, options.target.y)
|
||||
effect.position.set(
|
||||
THREE.MathUtils.lerp(start.x, end.x, t),
|
||||
0.82 + Math.sin(t * Math.PI) * 0.58,
|
||||
THREE.MathUtils.lerp(start.z, end.z, t),
|
||||
)
|
||||
effect.scale.setScalar(0.78 + Math.sin(options.elapsed * 11) * 0.18)
|
||||
effect.visible = true
|
||||
}
|
||||
|
||||
if (options.archer) {
|
||||
const id = `${options.teamKey}-arrow-shot`
|
||||
active.add(id)
|
||||
const effect = getOrCreateArrowShot(scene, effects, id)
|
||||
const t = (options.elapsed * 2.2) % 1
|
||||
const start = options.toWorld(options.archer.x, options.archer.y)
|
||||
const end = options.toWorld(options.target.x, options.target.y)
|
||||
effect.position.set(
|
||||
THREE.MathUtils.lerp(start.x, end.x, t),
|
||||
0.62 + Math.sin(t * Math.PI) * 0.22,
|
||||
THREE.MathUtils.lerp(start.z, end.z, t),
|
||||
)
|
||||
effect.rotation.y = Math.atan2(end.x - start.x, end.z - start.z)
|
||||
effect.visible = true
|
||||
}
|
||||
|
||||
pruneCombatEffects(effects, active)
|
||||
}
|
||||
|
||||
function addHunterMesh(group: THREE.Group) {
|
||||
const armor = new THREE.MeshStandardMaterial({ color: 0x2f8fd1, roughness: 0.58, metalness: 0.16 })
|
||||
const robe = new THREE.MeshStandardMaterial({ color: 0xf4eed8, roughness: 0.78 })
|
||||
const skin = new THREE.MeshStandardMaterial({ color: 0xf4c9a0, roughness: 0.72 })
|
||||
const metal = new THREE.MeshStandardMaterial({ color: 0xd7d9de, roughness: 0.32, metalness: 0.8 })
|
||||
const glow = new THREE.MeshStandardMaterial({ color: 0x7dff9d, roughness: 0.25, emissive: 0x1e8f45, emissiveIntensity: 0.55 })
|
||||
group.add(body(armor, 0.34, 0.82))
|
||||
const skirt = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.46, 10), robe)
|
||||
skirt.position.y = 0.25
|
||||
skirt.castShadow = true
|
||||
group.add(skirt)
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 8), skin)
|
||||
head.position.y = 1.18
|
||||
head.castShadow = true
|
||||
group.add(head)
|
||||
const staff = new THREE.Group()
|
||||
staff.name = 'weapon'
|
||||
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.48, 8), metal)
|
||||
shaft.rotation.x = 0.22
|
||||
shaft.castShadow = true
|
||||
staff.add(shaft)
|
||||
const headRing = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.018, 8, 20), metal)
|
||||
headRing.position.y = 0.84
|
||||
headRing.rotation.x = Math.PI / 2
|
||||
headRing.castShadow = true
|
||||
staff.add(headRing)
|
||||
const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.14), glow)
|
||||
crystal.position.y = 0.84
|
||||
crystal.castShadow = true
|
||||
staff.add(crystal)
|
||||
const butt = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), metal)
|
||||
butt.position.y = -0.76
|
||||
butt.castShadow = true
|
||||
staff.add(butt)
|
||||
staff.position.set(0.42, 0.76, -0.22)
|
||||
staff.rotation.z = -0.22
|
||||
group.add(staff)
|
||||
}
|
||||
|
||||
function addPartyMesh(group: THREE.Group, role: PartyRole, id: string) {
|
||||
const color = role === 'tank' ? 0xe5b95f : role === 'melee' ? 0xdc5162 : 0x8e68c4
|
||||
const armor = new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: role === 'tank' ? 0.36 : 0.18 })
|
||||
const skin = new THREE.MeshStandardMaterial({ color: 0xf4c9a0, roughness: 0.72 })
|
||||
const leather = new THREE.MeshStandardMaterial({ color: 0x65412a, roughness: 0.78 })
|
||||
const metal = new THREE.MeshStandardMaterial({ color: 0xd2d5db, roughness: 0.36, metalness: 0.72 })
|
||||
const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.82 })
|
||||
const magic = new THREE.MeshStandardMaterial({ color: 0x89f7ff, roughness: 0.26, emissive: 0x147d92, emissiveIntensity: 0.5 })
|
||||
|
||||
group.add(body(armor, role === 'tank' ? 0.34 : 0.29, 0.78))
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 8), skin)
|
||||
head.position.y = 1.05
|
||||
head.castShadow = true
|
||||
group.add(head)
|
||||
|
||||
const leftArm = limb(armor, 0.08, 0.48)
|
||||
leftArm.position.set(-0.28, 0.72, -0.03)
|
||||
leftArm.rotation.z = -0.24
|
||||
group.add(leftArm)
|
||||
const rightArm = limb(armor, 0.08, 0.48)
|
||||
rightArm.position.set(0.28, 0.72, -0.03)
|
||||
rightArm.rotation.z = 0.24
|
||||
group.add(rightArm)
|
||||
|
||||
const leftLeg = limb(leather, 0.08, 0.48)
|
||||
leftLeg.position.set(-0.13, 0.22, 0)
|
||||
group.add(leftLeg)
|
||||
const rightLeg = limb(leather, 0.08, 0.48)
|
||||
rightLeg.position.set(0.13, 0.22, 0)
|
||||
group.add(rightLeg)
|
||||
|
||||
if (role === 'tank') {
|
||||
addTankWeapons(group, metal, wood)
|
||||
return
|
||||
}
|
||||
if (role === 'melee') {
|
||||
addMeleeWeapon(group, metal, wood)
|
||||
return
|
||||
}
|
||||
if (id.includes('ranged-1')) {
|
||||
addCasterStaff(group, metal, wood, magic)
|
||||
return
|
||||
}
|
||||
addBow(group, metal, wood)
|
||||
}
|
||||
|
||||
function addTankWeapons(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) {
|
||||
const shield = new THREE.Group()
|
||||
const shieldFace = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.24, 0.08, 6), metal)
|
||||
shieldFace.rotation.set(Math.PI / 2, 0, 0)
|
||||
shieldFace.castShadow = true
|
||||
shield.add(shieldFace)
|
||||
const boss = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), wood)
|
||||
boss.position.z = -0.05
|
||||
boss.castShadow = true
|
||||
shield.add(boss)
|
||||
const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.46, 0.025), wood)
|
||||
stripe.position.z = -0.08
|
||||
shield.add(stripe)
|
||||
shield.position.set(-0.43, 0.72, -0.26)
|
||||
shield.rotation.z = 0.25
|
||||
group.add(shield)
|
||||
|
||||
const sword = new THREE.Group()
|
||||
sword.name = 'tankSword'
|
||||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.82, 0.055), metal)
|
||||
blade.position.y = 0.24
|
||||
blade.castShadow = true
|
||||
sword.add(blade)
|
||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.075, 0.2, 4), metal)
|
||||
tip.position.y = 0.75
|
||||
tip.castShadow = true
|
||||
sword.add(tip)
|
||||
const guard = new THREE.Mesh(new THREE.BoxGeometry(0.36, 0.07, 0.06), metal)
|
||||
guard.position.y = -0.2
|
||||
sword.add(guard)
|
||||
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.34, 8), wood)
|
||||
grip.position.y = -0.42
|
||||
sword.add(grip)
|
||||
const pommel = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), metal)
|
||||
pommel.position.y = -0.63
|
||||
sword.add(pommel)
|
||||
sword.position.set(0.42, 0.62, -0.38)
|
||||
sword.rotation.set(0.18, 0.12, -0.55)
|
||||
group.add(sword)
|
||||
}
|
||||
|
||||
function addMeleeWeapon(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) {
|
||||
const sword = new THREE.Group()
|
||||
sword.name = 'twoHandSword'
|
||||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.24, 0.075), metal)
|
||||
blade.position.y = 0.28
|
||||
blade.castShadow = true
|
||||
sword.add(blade)
|
||||
const fuller = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.88, 0.082), wood)
|
||||
fuller.position.y = 0.24
|
||||
sword.add(fuller)
|
||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.28, 4), metal)
|
||||
tip.position.y = 1.03
|
||||
sword.add(tip)
|
||||
const guard = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.1, 0.1), metal)
|
||||
guard.position.y = -0.4
|
||||
sword.add(guard)
|
||||
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.56, 8), wood)
|
||||
grip.position.y = -0.74
|
||||
sword.add(grip)
|
||||
const pommel = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), metal)
|
||||
pommel.position.y = -1.07
|
||||
sword.add(pommel)
|
||||
sword.position.set(0.42, 0.66, -0.24)
|
||||
sword.rotation.set(0.26, 0.08, -0.82)
|
||||
group.add(sword)
|
||||
}
|
||||
|
||||
function addCasterStaff(group: THREE.Group, metal: THREE.Material, wood: THREE.Material, magic: THREE.Material) {
|
||||
const staff = new THREE.Group()
|
||||
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.35, 8), wood)
|
||||
shaft.castShadow = true
|
||||
staff.add(shaft)
|
||||
const crescent = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.018, 8, 24, Math.PI * 1.3), magic)
|
||||
crescent.position.y = 0.76
|
||||
crescent.rotation.x = Math.PI / 2
|
||||
staff.add(crescent)
|
||||
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 8), magic)
|
||||
orb.position.y = 0.76
|
||||
staff.add(orb)
|
||||
const cap = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), metal)
|
||||
cap.position.y = -0.7
|
||||
staff.add(cap)
|
||||
staff.position.set(0.38, 0.74, -0.18)
|
||||
staff.rotation.set(0.18, 0, -0.24)
|
||||
group.add(staff)
|
||||
}
|
||||
|
||||
function addBow(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) {
|
||||
const bow = new THREE.Group()
|
||||
for (const y of [-0.2, 0.2]) {
|
||||
const limbMesh = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.42, 0.045), wood)
|
||||
limbMesh.position.y = y
|
||||
limbMesh.position.x = y < 0 ? -0.08 : 0.08
|
||||
limbMesh.rotation.z = y < 0 ? -0.35 : 0.35
|
||||
bow.add(limbMesh)
|
||||
}
|
||||
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.24, 8), wood)
|
||||
grip.rotation.z = Math.PI / 2
|
||||
bow.add(grip)
|
||||
const string = new THREE.Mesh(new THREE.BoxGeometry(0.025, 0.78, 0.025), metal)
|
||||
string.position.x = 0.24
|
||||
bow.add(string)
|
||||
const arrow = new THREE.Group()
|
||||
const arrowShaft = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.035, 0.72), metal)
|
||||
arrow.add(arrowShaft)
|
||||
const arrowTip = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.12, 6), metal)
|
||||
arrowTip.position.z = -0.42
|
||||
arrowTip.rotation.x = -Math.PI / 2
|
||||
arrow.add(arrowTip)
|
||||
arrow.position.set(0.08, 0, -0.05)
|
||||
bow.add(arrow)
|
||||
bow.position.set(0.4, 0.75, -0.32)
|
||||
bow.rotation.y = 0.18
|
||||
group.add(bow)
|
||||
}
|
||||
|
||||
function body(material: THREE.Material, radius: number, height: number) {
|
||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 0.86, height, 12), material)
|
||||
mesh.position.y = height / 2
|
||||
mesh.castShadow = true
|
||||
return mesh
|
||||
}
|
||||
|
||||
function limb(material: THREE.Material, radius: number, height: number) {
|
||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 0.82, height, 8), material)
|
||||
mesh.castShadow = true
|
||||
return mesh
|
||||
}
|
||||
|
||||
function getOrCreateStaffSpell(scene: THREE.Scene, effects: Map<string, THREE.Group>, id: string) {
|
||||
let group = effects.get(id)
|
||||
if (!group) {
|
||||
group = new THREE.Group()
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x89f7ff, transparent: true, opacity: 0.86 })
|
||||
group.add(new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 8), material))
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.19, 0.013, 6, 20), material)
|
||||
ring.rotation.x = Math.PI / 2
|
||||
group.add(ring)
|
||||
scene.add(group)
|
||||
effects.set(id, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
function getOrCreateArrowShot(scene: THREE.Scene, effects: Map<string, THREE.Group>, id: string) {
|
||||
let group = effects.get(id)
|
||||
if (!group) {
|
||||
group = new THREE.Group()
|
||||
const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.82 })
|
||||
const metal = new THREE.MeshStandardMaterial({ color: 0xd2d5db, roughness: 0.36, metalness: 0.72 })
|
||||
const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.04, 0.58), wood)
|
||||
group.add(shaft)
|
||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.055, 0.14, 6), metal)
|
||||
tip.position.z = -0.36
|
||||
tip.rotation.x = -Math.PI / 2
|
||||
group.add(tip)
|
||||
scene.add(group)
|
||||
effects.set(id, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
function pruneCombatEffects(map: Map<string, THREE.Group>, active: Set<string>) {
|
||||
for (const [id, object] of map) {
|
||||
if (active.has(id)) continue
|
||||
object.removeFromParent()
|
||||
map.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import * as THREE from 'three'
|
||||
import type { BossInput } from './actionCombatTypes'
|
||||
import type { SpellSlot } from './actionCombatCore'
|
||||
|
||||
export const ACTION_ARENA_SCALE = 48
|
||||
export const ACTION_PLAYER_KEYS = new Set(['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'])
|
||||
export const ACTION_RENDER_QUALITY = {
|
||||
antialias: false,
|
||||
maxPixelRatio: 1.1,
|
||||
shadows: false,
|
||||
}
|
||||
export const CLAUDECRAFT_CAMERA_FOV = 60
|
||||
const CLAUDECRAFT_CAMERA_PITCH = 0.32
|
||||
const CLAUDECRAFT_CAMERA_DISTANCE = 12
|
||||
const CLAUDECRAFT_CAMERA_EYE_HEIGHT = 2
|
||||
const CLAUDECRAFT_CAMERA_DRAG_SENSITIVITY = 0.006
|
||||
|
||||
export type ActionWorldPoint = { x: number, z: number }
|
||||
export type ActionCameraTarget = { x: number, y: number }
|
||||
export type ActionArenaProjection = { height: number, width: number }
|
||||
export type ActionWorldProjector = (x: number, y: number) => ActionWorldPoint
|
||||
export type ActionCombatCameraController = {
|
||||
bindDrag: (element: HTMLElement, options?: {
|
||||
isPaused?: () => boolean,
|
||||
onPointerDown?: (event: PointerEvent) => void,
|
||||
}) => () => void
|
||||
reset: (yaw?: number) => void
|
||||
yaw: number
|
||||
}
|
||||
|
||||
export type CombatActionQueue = {
|
||||
reset: boolean
|
||||
}
|
||||
|
||||
export function readCombatInput(
|
||||
keys: Set<string>,
|
||||
cameraYaw: number,
|
||||
targetId: string | null,
|
||||
castSpell: SpellSlot | null,
|
||||
actions: CombatActionQueue,
|
||||
options?: { fallbackAim?: { x: number, y: number } },
|
||||
): BossInput {
|
||||
const strafe = Number(keys.has('d') || keys.has('arrowright')) - Number(keys.has('a') || keys.has('arrowleft'))
|
||||
const forwardInput = Number(keys.has('w') || keys.has('arrowup')) - Number(keys.has('s') || keys.has('arrowdown'))
|
||||
const forwardX = Math.sin(cameraYaw)
|
||||
const forwardY = Math.cos(cameraYaw)
|
||||
const rightX = -Math.cos(cameraYaw)
|
||||
const rightY = Math.sin(cameraYaw)
|
||||
const xAxis = rightX * strafe + forwardX * forwardInput
|
||||
const yAxis = rightY * strafe + forwardY * forwardInput
|
||||
const length = Math.hypot(xAxis, yAxis)
|
||||
const fallbackAim = options?.fallbackAim ?? { x: 0, y: -1 }
|
||||
|
||||
return {
|
||||
xAxis,
|
||||
yAxis,
|
||||
reset: actions.reset,
|
||||
targetDelta: 0,
|
||||
targetId,
|
||||
castSpell,
|
||||
aimX: length > 0.01 ? xAxis / length : fallbackAim.x,
|
||||
aimY: length > 0.01 ? yAxis / length : fallbackAim.y,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCombatActions(actions: CombatActionQueue) {
|
||||
actions.reset = false
|
||||
}
|
||||
|
||||
export function setupActionSceneEnvironment(scene: THREE.Scene, options?: { divider?: boolean, worldHeight?: number, worldWidth?: number }) {
|
||||
scene.background = new THREE.Color(0x10151a)
|
||||
scene.fog = new THREE.Fog(0x10151a, 14, 30)
|
||||
const worldWidth = options?.worldWidth ?? 20
|
||||
const worldHeight = options?.worldHeight ?? 11.25
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0xf8f0d8, 0x1c2530, 1.7)
|
||||
scene.add(ambient)
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff1c2, 2.8)
|
||||
sun.position.set(-6, 11, 8)
|
||||
sun.castShadow = true
|
||||
sun.shadow.camera.left = -13
|
||||
sun.shadow.camera.right = 13
|
||||
sun.shadow.camera.top = 9
|
||||
sun.shadow.camera.bottom = -9
|
||||
scene.add(sun)
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(worldWidth, worldHeight, 18, 10),
|
||||
new THREE.MeshStandardMaterial({ color: 0x243126, roughness: 0.88 }),
|
||||
)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
|
||||
const grid = new THREE.GridHelper(worldWidth, Math.max(20, Math.round(worldWidth)), 0x566044, 0x3b4436)
|
||||
grid.position.y = 0.012
|
||||
scene.add(grid)
|
||||
|
||||
addActionArenaBounds(scene, { divider: options?.divider, worldHeight, worldWidth })
|
||||
}
|
||||
|
||||
export function addActionArenaBounds(scene: THREE.Scene, options?: { divider?: boolean, worldHeight?: number, worldWidth?: number }) {
|
||||
const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x32323a, roughness: 0.7 })
|
||||
const worldWidth = options?.worldWidth ?? 20
|
||||
const worldHeight = options?.worldHeight ?? 11.25
|
||||
const halfW = worldWidth / 2
|
||||
const halfH = worldHeight / 2
|
||||
for (const wall of [
|
||||
{ x: 0, z: -halfH - 0.15, sx: worldWidth + 0.4, sz: 0.28 },
|
||||
{ x: 0, z: halfH + 0.15, sx: worldWidth + 0.4, sz: 0.28 },
|
||||
{ x: -halfW - 0.15, z: 0, sx: 0.28, sz: worldHeight + 0.3 },
|
||||
{ x: halfW + 0.15, z: 0, sx: 0.28, sz: worldHeight + 0.3 },
|
||||
]) {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.sx, 0.62, wall.sz), wallMaterial)
|
||||
mesh.position.set(wall.x, 0.31, wall.z)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
scene.add(mesh)
|
||||
}
|
||||
|
||||
if (!options?.divider) return
|
||||
const dividerHeight = 3.45
|
||||
const divider = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.22, dividerHeight, worldHeight - 0.85),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5d6470, roughness: 0.72, transparent: true, opacity: 0.48 }),
|
||||
)
|
||||
divider.position.set(0, dividerHeight / 2, 0)
|
||||
divider.castShadow = true
|
||||
divider.receiveShadow = true
|
||||
scene.add(divider)
|
||||
}
|
||||
|
||||
export function createActionCombatCamera(options?: { aspect?: number, far?: number, near?: number }) {
|
||||
return new THREE.PerspectiveCamera(
|
||||
CLAUDECRAFT_CAMERA_FOV,
|
||||
options?.aspect ?? 16 / 9,
|
||||
options?.near ?? 0.1,
|
||||
options?.far ?? 120,
|
||||
)
|
||||
}
|
||||
|
||||
export function createActionCombatCameraController(initialYaw = Math.PI): ActionCombatCameraController {
|
||||
let dragX: number | null = null
|
||||
const controller: ActionCombatCameraController = {
|
||||
yaw: initialYaw,
|
||||
reset: (yaw = initialYaw) => {
|
||||
controller.yaw = yaw
|
||||
dragX = null
|
||||
},
|
||||
bindDrag: (element, options) => {
|
||||
const isPaused = options?.isPaused ?? (() => false)
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (isPaused()) return
|
||||
element.setPointerCapture(event.pointerId)
|
||||
dragX = event.clientX
|
||||
options?.onPointerDown?.(event)
|
||||
}
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isPaused() || dragX === null) return
|
||||
controller.yaw -= (event.clientX - dragX) * CLAUDECRAFT_CAMERA_DRAG_SENSITIVITY
|
||||
dragX = event.clientX
|
||||
}
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (isPaused()) return
|
||||
if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId)
|
||||
dragX = null
|
||||
}
|
||||
|
||||
element.addEventListener('pointerdown', handlePointerDown)
|
||||
element.addEventListener('pointermove', handlePointerMove)
|
||||
element.addEventListener('pointerup', handlePointerUp)
|
||||
element.addEventListener('pointercancel', handlePointerUp)
|
||||
return () => {
|
||||
element.removeEventListener('pointerdown', handlePointerDown)
|
||||
element.removeEventListener('pointermove', handlePointerMove)
|
||||
element.removeEventListener('pointerup', handlePointerUp)
|
||||
element.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
},
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
export function resizeActionCombatCamera(camera: THREE.PerspectiveCamera, width: number, height: number) {
|
||||
camera.aspect = Math.max(1, width) / Math.max(1, height)
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
export function updateActionCombatCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
yaw: number,
|
||||
target: ActionCameraTarget,
|
||||
project: ActionWorldProjector = toActionWorld,
|
||||
) {
|
||||
const point = project(target.x, target.y)
|
||||
applyClaudeCraftCamera(camera, yaw, point)
|
||||
}
|
||||
|
||||
export function updateFollowCamera(camera: THREE.PerspectiveCamera, yaw: number, target: ActionCameraTarget) {
|
||||
updateActionCombatCamera(camera, yaw, target)
|
||||
}
|
||||
|
||||
export function applyClaudeCraftCamera(camera: THREE.PerspectiveCamera, yaw: number, point: ActionWorldPoint) {
|
||||
const eyeY = CLAUDECRAFT_CAMERA_EYE_HEIGHT
|
||||
camera.position.x = point.x - Math.sin(yaw) * Math.cos(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
||||
camera.position.y = eyeY + Math.sin(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
||||
camera.position.z = point.z - Math.cos(yaw) * Math.cos(CLAUDECRAFT_CAMERA_PITCH) * CLAUDECRAFT_CAMERA_DISTANCE
|
||||
if (camera.fov !== CLAUDECRAFT_CAMERA_FOV) {
|
||||
camera.fov = CLAUDECRAFT_CAMERA_FOV
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
camera.lookAt(point.x, eyeY, point.z)
|
||||
}
|
||||
|
||||
export function toActionWorld(x: number, y: number, arena: ActionArenaProjection = { height: 540, width: 960 }) {
|
||||
return {
|
||||
x: (x - arena.width / 2) / ACTION_ARENA_SCALE,
|
||||
z: (y - arena.height / 2) / ACTION_ARENA_SCALE,
|
||||
}
|
||||
}
|
||||
|
||||
export function getLineTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
||||
let mesh = map.get(id)
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff5d43, transparent: true, opacity: 0.28, side: THREE.DoubleSide }),
|
||||
)
|
||||
scene.add(mesh)
|
||||
map.set(id, mesh)
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
export function syncLineTelegraphGeometry(
|
||||
mesh: THREE.Mesh,
|
||||
startPoint: { x: number; y: number },
|
||||
endPoint: { x: number; y: number },
|
||||
width: number,
|
||||
project: (x: number, y: number) => { x: number, z: number } = toActionWorld,
|
||||
) {
|
||||
const start = project(startPoint.x, startPoint.y)
|
||||
const end = project(endPoint.x, endPoint.y)
|
||||
const dx = end.x - start.x
|
||||
const dz = end.z - start.z
|
||||
const length = Math.max(0.001, Math.hypot(dx, dz))
|
||||
const normalX = -dz / length
|
||||
const normalZ = dx / length
|
||||
const halfWidth = width / ACTION_ARENA_SCALE / 2
|
||||
const y = 0.035
|
||||
const positions = new Float32Array([
|
||||
start.x + normalX * halfWidth, y, start.z + normalZ * halfWidth,
|
||||
start.x - normalX * halfWidth, y, start.z - normalZ * halfWidth,
|
||||
end.x + normalX * halfWidth, y, end.z + normalZ * halfWidth,
|
||||
end.x - normalX * halfWidth, y, end.z - normalZ * halfWidth,
|
||||
])
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geometry.setIndex([0, 2, 1, 2, 3, 1])
|
||||
geometry.computeVertexNormals()
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = geometry
|
||||
}
|
||||
|
||||
export function getCircleTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
||||
let mesh = map.get(id)
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(1, 42),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffd36d, transparent: true, opacity: 0.24, side: THREE.DoubleSide }),
|
||||
)
|
||||
mesh.rotation.x = -Math.PI / 2
|
||||
scene.add(mesh)
|
||||
map.set(id, mesh)
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
export function getArcTelegraph(scene: THREE.Scene, map: Map<string, THREE.Mesh>, id: string) {
|
||||
let mesh = map.get(id)
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff8a43, transparent: true, opacity: 0.3, side: THREE.DoubleSide }),
|
||||
)
|
||||
scene.add(mesh)
|
||||
map.set(id, mesh)
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
export function syncArcTelegraphGeometry(
|
||||
mesh: THREE.Mesh,
|
||||
centerPoint: { x: number; y: number },
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
width: number,
|
||||
project: (x: number, y: number) => { x: number, z: number } = toActionWorld,
|
||||
) {
|
||||
const center = project(centerPoint.x, centerPoint.y)
|
||||
const innerRadius = Math.max(0, radius - width / 2) / ACTION_ARENA_SCALE
|
||||
const outerRadius = Math.max(width, radius + width / 2) / ACTION_ARENA_SCALE
|
||||
const segments = 32
|
||||
const positions: number[] = []
|
||||
const indices: number[] = []
|
||||
const y = 0.045
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const t = i / segments
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
positions.push(
|
||||
center.x + Math.cos(angle) * innerRadius, y, center.z + Math.sin(angle) * innerRadius,
|
||||
center.x + Math.cos(angle) * outerRadius, y, center.z + Math.sin(angle) * outerRadius,
|
||||
)
|
||||
if (i < segments) {
|
||||
const base = i * 2
|
||||
indices.push(base, base + 1, base + 2, base + 1, base + 3, base + 2)
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3))
|
||||
geometry.setIndex(indices)
|
||||
geometry.computeVertexNormals()
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = geometry
|
||||
}
|
||||
|
||||
export function createHealBurstEffect(options?: { simple?: boolean, color?: number }) {
|
||||
const color = options?.color ?? 0x7dff9d
|
||||
const group = new THREE.Group()
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(options?.simple ? 0.34 : 0.38, options?.simple ? 0.026 : 0.028, 8, options?.simple ? 32 : 36),
|
||||
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }),
|
||||
)
|
||||
ring.rotation.x = Math.PI / 2
|
||||
group.add(ring)
|
||||
|
||||
if (options?.simple) return group
|
||||
|
||||
const pillar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.34, 0.9, 18, 1, true),
|
||||
new THREE.MeshBasicMaterial({ color: 0xb8ffd0, transparent: true, opacity: 0.34, side: THREE.DoubleSide }),
|
||||
)
|
||||
pillar.position.y = 0.45
|
||||
group.add(pillar)
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const mote = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.045, 8, 6),
|
||||
new THREE.MeshBasicMaterial({ color: 0xf4fff2, transparent: true, opacity: 0.9 }),
|
||||
)
|
||||
const angle = (Math.PI * 2 * i) / 5
|
||||
mote.position.set(Math.cos(angle) * 0.34, 0.25 + i * 0.12, Math.sin(angle) * 0.34)
|
||||
group.add(mote)
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
export function getOrCreateStunEffect(scene: THREE.Scene, effects: Map<string, THREE.Group>, id: string) {
|
||||
let group = effects.get(id)
|
||||
if (!group) {
|
||||
group = new THREE.Group()
|
||||
const starMaterial = new THREE.MeshBasicMaterial({ color: 0xffd36d, transparent: true, opacity: 0.95 })
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.09), starMaterial.clone())
|
||||
const angle = (Math.PI * 2 * i) / 3
|
||||
star.position.set(Math.cos(angle) * 0.28, Math.sin(i * 1.7) * 0.05, Math.sin(angle) * 0.28)
|
||||
star.rotation.set(i * 0.7, i * 0.4, i * 0.9)
|
||||
group.add(star)
|
||||
}
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(0.28, 0.012, 6, 24),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfff0a8, transparent: true, opacity: 0.62 }),
|
||||
)
|
||||
ring.rotation.x = Math.PI / 2
|
||||
group.add(ring)
|
||||
scene.add(group)
|
||||
effects.set(id, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
export function setObjectOpacity(group: THREE.Object3D, opacity: number) {
|
||||
group.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
for (const material of materials) {
|
||||
material.transparent = true
|
||||
material.opacity = opacity
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function pruneObjectMap<T extends THREE.Object3D>(map: Map<string, T>, active: Set<string>) {
|
||||
for (const [id, object] of map) {
|
||||
if (active.has(id)) continue
|
||||
disposeObject3d(object)
|
||||
map.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeObject3d(object: THREE.Object3D) {
|
||||
object.removeFromParent()
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.geometry.dispose()
|
||||
const material = child.material
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose())
|
||||
else material.dispose()
|
||||
})
|
||||
}
|
||||
+53
-2
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
ACTION_SAVE_KEY,
|
||||
ACTION_ROSTER_SAVE_KEY,
|
||||
loadActionCharacter,
|
||||
loadActionRoster,
|
||||
saveActionCharacter,
|
||||
saveActionRoster,
|
||||
type ActionCharacter,
|
||||
type ActionCharacterRoster,
|
||||
} from './actionMode'
|
||||
import {
|
||||
ACTION_MECHANIC_CONFIG_KEY,
|
||||
@@ -21,16 +25,43 @@ export type AuthAccount = {
|
||||
|
||||
export type AuthSession = {
|
||||
account: AuthAccount | null
|
||||
offline?: boolean
|
||||
profile?: null
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type ActionCloudSave = {
|
||||
character?: ActionCharacter
|
||||
roster?: ActionCharacterRoster
|
||||
mechanics?: ActionMechanicConfig
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ArenaQueuePlayer = {
|
||||
accountId: string
|
||||
displayName: string
|
||||
side: 'a' | 'b'
|
||||
username: string
|
||||
}
|
||||
|
||||
export type ArenaQueueMatch = {
|
||||
id: string
|
||||
mode: 'arenas'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
players: {
|
||||
a: ArenaQueuePlayer
|
||||
b: ArenaQueuePlayer
|
||||
}
|
||||
}
|
||||
|
||||
export type ArenaQueueResponse = {
|
||||
ticketId: string
|
||||
status: 'waiting' | 'matched'
|
||||
side?: 'a' | 'b'
|
||||
match?: ArenaQueueMatch
|
||||
}
|
||||
|
||||
type SavedSlotResponse = {
|
||||
save: null | {
|
||||
save_json?: ActionCloudSave
|
||||
@@ -111,13 +142,15 @@ export async function logoutAccount() {
|
||||
export function localCloudSave(): ActionCloudSave {
|
||||
return {
|
||||
character: loadActionCharacter(),
|
||||
roster: loadActionRoster(),
|
||||
mechanics: loadActionMechanicConfig(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCloudSave(save: ActionCloudSave) {
|
||||
if (save.character) saveActionCharacter(save.character)
|
||||
if (save.roster) saveActionRoster(save.roster)
|
||||
else if (save.character) saveActionCharacter(save.character)
|
||||
if (save.mechanics) saveActionMechanicConfig(save.mechanics)
|
||||
}
|
||||
|
||||
@@ -138,13 +171,31 @@ export async function pushCloudSave(save: ActionCloudSave = localCloudSave()) {
|
||||
})
|
||||
}
|
||||
|
||||
export function joinArenaQueue() {
|
||||
return requestActionApiJson<ArenaQueueResponse>('/api/pvp/queue', {
|
||||
body: JSON.stringify({ mode: 'arenas' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function checkArenaQueue(ticketId: string) {
|
||||
return requestActionApiJson<ArenaQueueResponse>(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||
}
|
||||
|
||||
export function cancelArenaQueue(ticketId: string) {
|
||||
return requestActionApiJson<{ ok: true }>(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function hydrateLocalSaveFromServer() {
|
||||
const remote = await loadCloudSave()
|
||||
if (remote) {
|
||||
applyCloudSave(remote)
|
||||
return 'loaded'
|
||||
}
|
||||
if (window.localStorage.getItem(ACTION_SAVE_KEY) || window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)) {
|
||||
if (window.localStorage.getItem(ACTION_ROSTER_SAVE_KEY) || window.localStorage.getItem(ACTION_SAVE_KEY) || window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)) {
|
||||
await pushCloudSave()
|
||||
return 'uploaded'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
ARENA_PRIEST_SPELL_BAR,
|
||||
SPELLS,
|
||||
type SpellSlot,
|
||||
} from './actionBoss/actionCombatSimulation'
|
||||
import { SPELL_MANA_COSTS } from './actionCombatCore'
|
||||
|
||||
export type ArenaTeamId = 'player' | 'opponent'
|
||||
export type ArenaPhase = 'setup' | 'fighting' | 'upgrade' | 'complete'
|
||||
export type ArenaUpgradeId =
|
||||
| 'all-target'
|
||||
| 'all-cost'
|
||||
| 'all-cooldown'
|
||||
| `slot-${SpellSlot}-cost`
|
||||
| `slot-${SpellSlot}-cooldown`
|
||||
| 'slot-1-renew'
|
||||
| 'slot-1-shield'
|
||||
| 'slot-2-shield'
|
||||
| 'slot-2-duration'
|
||||
| 'slot-3-half-shield'
|
||||
| 'slot-3-renew'
|
||||
| 'slot-4-renew'
|
||||
| 'slot-5-renew'
|
||||
| 'slot-5-shield'
|
||||
| 'stored-momentum'
|
||||
| 'wide-radiance'
|
||||
| 'dense-shields'
|
||||
| 'atonement'
|
||||
|
||||
export type ArenaUpgrade = {
|
||||
id: ArenaUpgradeId
|
||||
label: string
|
||||
scope: string
|
||||
description: string
|
||||
cost: number
|
||||
}
|
||||
|
||||
export type ArenaMatchState = {
|
||||
phase: ArenaPhase
|
||||
round: number
|
||||
playerName: string
|
||||
opponentName: string
|
||||
playerWins: number
|
||||
opponentWins: number
|
||||
playerCredits: number
|
||||
opponentCredits: number
|
||||
playerUpgrades: ArenaUpgradeId[]
|
||||
opponentUpgrades: ArenaUpgradeId[]
|
||||
history: string[]
|
||||
}
|
||||
|
||||
const FALLBACK_SPELL_NAMES: Partial<Record<SpellSlot, string>> = {
|
||||
1: 'Smite',
|
||||
2: 'Power Word: Fortitude',
|
||||
3: 'Shadow Word: Pain',
|
||||
4: 'Power Word: Shield',
|
||||
5: 'Renew',
|
||||
6: 'Mind Blast',
|
||||
7: 'Heal',
|
||||
8: 'Mind Flay',
|
||||
9: 'Flash Heal',
|
||||
}
|
||||
|
||||
function getSlotCostUpgradeId(slot: SpellSlot): ArenaUpgradeId {
|
||||
return `slot-${slot}-cost`
|
||||
}
|
||||
|
||||
function getSlotCooldownUpgradeId(slot: SpellSlot): ArenaUpgradeId {
|
||||
return `slot-${slot}-cooldown`
|
||||
}
|
||||
|
||||
function createSlotEconomyUpgrades(slot: SpellSlot): ArenaUpgrade[] {
|
||||
const name = getArenaSpellName(slot)
|
||||
return [
|
||||
{
|
||||
id: getSlotCostUpgradeId(slot),
|
||||
label: `${name}: -25% Mana Cost`,
|
||||
scope: `Slot ${slot}`,
|
||||
description: `${name} costs 25% less mana.`,
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: getSlotCooldownUpgradeId(slot),
|
||||
label: `${name}: -25% Cooldown`,
|
||||
scope: `Slot ${slot}`,
|
||||
description: `${name} cooldown recovers 25% faster.`,
|
||||
cost: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const ARENA_UPGRADES: ArenaUpgrade[] = [
|
||||
{
|
||||
id: 'all-target',
|
||||
label: 'Every Slot: +1 Target',
|
||||
scope: 'Every Slot',
|
||||
description: 'Friendly ClaudeCraft priest spells reach one extra ally.',
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
id: 'all-cost',
|
||||
label: 'Every Slot: -25% Cost',
|
||||
scope: 'Every Slot',
|
||||
description: 'All spell energy costs are reduced by 25%.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'all-cooldown',
|
||||
label: 'Every Slot: -25% Cooldown',
|
||||
scope: 'Every Slot',
|
||||
description: 'All spell cooldowns recover faster.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'slot-1-renew',
|
||||
label: `${getArenaSpellName(1)} Applies Renew`,
|
||||
scope: 'Slot 1',
|
||||
description: 'Slot 1 also applies Renew to its targets.',
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
id: 'slot-1-shield',
|
||||
label: `${getArenaSpellName(1)} Applies Shield`,
|
||||
scope: 'Slot 1',
|
||||
description: 'Slot 1 also grants a shield.',
|
||||
cost: 2,
|
||||
},
|
||||
...createSlotEconomyUpgrades(1),
|
||||
{
|
||||
id: 'slot-2-shield',
|
||||
label: `${getArenaSpellName(2)} Applies Shield`,
|
||||
scope: 'Slot 2',
|
||||
description: 'Slot 2 also grants a shield.',
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
id: 'slot-2-duration',
|
||||
label: `${getArenaSpellName(2)} Double Duration`,
|
||||
scope: 'Slot 2',
|
||||
description: 'Slot 2 Renew lasts twice as long.',
|
||||
cost: 2,
|
||||
},
|
||||
...createSlotEconomyUpgrades(2),
|
||||
{
|
||||
id: 'slot-3-half-shield',
|
||||
label: `${getArenaSpellName(3)} Applies 50% Shield`,
|
||||
scope: 'Slot 3',
|
||||
description: 'Slot 3 also grants a half-strength shield.',
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
id: 'slot-3-renew',
|
||||
label: `${getArenaSpellName(3)} Applies Renew`,
|
||||
scope: 'Slot 3',
|
||||
description: 'Slot 3 also applies Renew to healed targets.',
|
||||
cost: 2,
|
||||
},
|
||||
...createSlotEconomyUpgrades(3),
|
||||
{
|
||||
id: 'slot-4-renew',
|
||||
label: `${getArenaSpellName(4)} Applies Renew`,
|
||||
scope: 'Slot 4',
|
||||
description: 'Slot 4 also applies Renew.',
|
||||
cost: 2,
|
||||
},
|
||||
...createSlotEconomyUpgrades(4),
|
||||
{
|
||||
id: 'slot-5-renew',
|
||||
label: `${getArenaSpellName(5)} Applies Renew`,
|
||||
scope: 'Slot 5',
|
||||
description: 'Slot 5 also applies Renew.',
|
||||
cost: 2,
|
||||
},
|
||||
{
|
||||
id: 'slot-5-shield',
|
||||
label: `${getArenaSpellName(5)} Applies Shield`,
|
||||
scope: 'Slot 5',
|
||||
description: 'Slot 5 also grants a shield.',
|
||||
cost: 2,
|
||||
},
|
||||
...createSlotEconomyUpgrades(5),
|
||||
...createSlotEconomyUpgrades(6),
|
||||
...createSlotEconomyUpgrades(7),
|
||||
...createSlotEconomyUpgrades(8),
|
||||
...createSlotEconomyUpgrades(9),
|
||||
...createSlotEconomyUpgrades(10),
|
||||
{
|
||||
id: 'stored-momentum',
|
||||
label: 'Stored Momentum',
|
||||
scope: 'Misc',
|
||||
description: 'After 5 spell casts, the next cast is free.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'atonement',
|
||||
label: 'Atonement',
|
||||
scope: 'Priest',
|
||||
description: 'Smite and Shadow Word: Pain heal the lowest-health ally for 100% of damage dealt.',
|
||||
cost: 3,
|
||||
},
|
||||
{
|
||||
id: 'wide-radiance',
|
||||
label: 'Stronger Heal',
|
||||
scope: 'Misc',
|
||||
description: 'Heal is 25% stronger.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'dense-shields',
|
||||
label: 'Dense Shields',
|
||||
scope: 'Misc',
|
||||
description: 'Shields absorb 25% more damage.',
|
||||
cost: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export function getArenaUpgrades() {
|
||||
return ARENA_UPGRADES
|
||||
}
|
||||
|
||||
export function getArenaSpellName(slot: SpellSlot) {
|
||||
return SPELLS[slot]?.name || FALLBACK_SPELL_NAMES[slot] || `Slot ${slot}`
|
||||
}
|
||||
|
||||
export function getArenaSpellCostMultiplier(upgrades: ArenaUpgradeId[], spell: SpellSlot) {
|
||||
let multiplier = upgrades.includes('all-cost') ? 0.75 : 1
|
||||
if (upgrades.includes(getSlotCostUpgradeId(spell))) multiplier *= 0.75
|
||||
return multiplier
|
||||
}
|
||||
|
||||
export function getArenaSpellCooldownMultiplier(upgrades: ArenaUpgradeId[], spell: SpellSlot) {
|
||||
let multiplier = upgrades.includes('all-cooldown') ? 0.75 : 1
|
||||
if (upgrades.includes(getSlotCooldownUpgradeId(spell))) multiplier *= 0.75
|
||||
return multiplier
|
||||
}
|
||||
|
||||
export function getArenaSpellLoadout() {
|
||||
return ARENA_PRIEST_SPELL_BAR.map((slot) => ({
|
||||
slot,
|
||||
name: getArenaSpellName(slot),
|
||||
cost: SPELL_MANA_COSTS[slot],
|
||||
cooldown: SPELLS[slot]?.cooldown ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createArenaMatchState(playerName = 'Action Healer'): ArenaMatchState {
|
||||
return {
|
||||
phase: 'setup',
|
||||
round: 1,
|
||||
playerName,
|
||||
opponentName: 'Rival Five',
|
||||
playerWins: 0,
|
||||
opponentWins: 0,
|
||||
playerCredits: 0,
|
||||
opponentCredits: 0,
|
||||
playerUpgrades: [],
|
||||
opponentUpgrades: [],
|
||||
history: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveArenaRoundWinner(
|
||||
match: ArenaMatchState,
|
||||
winner: 'player' | 'opponent',
|
||||
elapsedSeconds: number,
|
||||
): ArenaMatchState {
|
||||
const playerWon = winner === 'player'
|
||||
const playerWins = match.playerWins + (playerWon ? 1 : 0)
|
||||
const opponentWins = match.opponentWins + (playerWon ? 0 : 1)
|
||||
const complete = playerWins >= 3 || opponentWins >= 3
|
||||
const winnerName = playerWon ? match.playerName : match.opponentName
|
||||
const playerPointAward = playerWon ? 3 : 4
|
||||
const opponentPointAward = playerWon ? 4 : 3
|
||||
const opponentDraft = complete
|
||||
? { upgrades: match.opponentUpgrades, credits: match.opponentCredits + opponentPointAward, picked: null }
|
||||
: pickOpponentUpgrade(match.opponentUpgrades, match.opponentCredits + opponentPointAward)
|
||||
|
||||
return {
|
||||
...match,
|
||||
phase: complete ? 'complete' : 'upgrade',
|
||||
round: complete ? match.round : match.round + 1,
|
||||
playerWins,
|
||||
opponentWins,
|
||||
playerCredits: match.playerCredits + playerPointAward,
|
||||
opponentCredits: opponentDraft.credits,
|
||||
opponentUpgrades: opponentDraft.upgrades,
|
||||
history: [
|
||||
`${winnerName} won round ${match.round} in ${elapsedSeconds.toFixed(1)}s.`,
|
||||
...(opponentDraft.picked ? [`${match.opponentName} drafted ${getUpgradeLabel(opponentDraft.picked)}.`] : []),
|
||||
...match.history,
|
||||
].slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
export function buyArenaUpgrade(match: ArenaMatchState, upgradeId: ArenaUpgradeId): ArenaMatchState {
|
||||
const upgrade = ARENA_UPGRADES.find((item) => item.id === upgradeId)
|
||||
if (!upgrade) return match
|
||||
|
||||
if (match.playerUpgrades.includes(upgradeId)) {
|
||||
return {
|
||||
...match,
|
||||
playerCredits: match.playerCredits + upgrade.cost,
|
||||
playerUpgrades: match.playerUpgrades.filter((id) => id !== upgradeId),
|
||||
history: [`${match.playerName} removed ${upgrade.label}.`, ...match.history].slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
if (match.playerCredits < upgrade.cost) return match
|
||||
|
||||
return {
|
||||
...match,
|
||||
playerCredits: match.playerCredits - upgrade.cost,
|
||||
playerUpgrades: [...match.playerUpgrades, upgradeId],
|
||||
history: [`${match.playerName} drafted ${upgrade.label}.`, ...match.history].slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
export function startNextArenaRound(match: ArenaMatchState): ArenaMatchState {
|
||||
return {
|
||||
...match,
|
||||
phase: 'fighting',
|
||||
}
|
||||
}
|
||||
|
||||
export function getArenaAvailableUpgrades(match: ArenaMatchState) {
|
||||
return ARENA_UPGRADES.map((upgrade) => ({
|
||||
...upgrade,
|
||||
owned: match.playerUpgrades.includes(upgrade.id),
|
||||
affordable: match.playerCredits >= upgrade.cost,
|
||||
}))
|
||||
}
|
||||
|
||||
function pickOpponentUpgrade(upgrades: ArenaUpgradeId[], credits: number) {
|
||||
const priority: ArenaUpgradeId[] = [
|
||||
'all-cooldown',
|
||||
'wide-radiance',
|
||||
'all-cost',
|
||||
'slot-1-cost',
|
||||
'slot-1-cooldown',
|
||||
'slot-3-cost',
|
||||
'slot-3-cooldown',
|
||||
'slot-4-renew',
|
||||
'dense-shields',
|
||||
'all-target',
|
||||
'slot-3-half-shield',
|
||||
'stored-momentum',
|
||||
]
|
||||
const picked = priority.find((id) => {
|
||||
const upgrade = ARENA_UPGRADES.find((item) => item.id === id)
|
||||
return upgrade && !upgrades.includes(id) && credits >= upgrade.cost
|
||||
}) ?? null
|
||||
if (!picked) return { upgrades, credits, picked }
|
||||
|
||||
const cost = ARENA_UPGRADES.find((upgrade) => upgrade.id === picked)?.cost ?? 0
|
||||
return {
|
||||
upgrades: [...upgrades, picked],
|
||||
credits: credits - cost,
|
||||
picked,
|
||||
}
|
||||
}
|
||||
|
||||
function getUpgradeLabel(id: ArenaUpgradeId) {
|
||||
return ARENA_UPGRADES.find((upgrade) => upgrade.id === id)?.label ?? id
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import type { ActionDungeonId } from './actionMode'
|
||||
import type { EnemyKind } from './actionBoss/actionCombatSimulation'
|
||||
|
||||
type EnemySpriteAsset = {
|
||||
src: string
|
||||
displayScale: number
|
||||
}
|
||||
|
||||
export const ACTION_ENEMY_SPRITES: Record<EnemyKind, EnemySpriteAsset> = {
|
||||
bulldrome: {
|
||||
src: '/action-assets/enemies/bulldrome.svg',
|
||||
displayScale: 3.2,
|
||||
},
|
||||
bullfango: {
|
||||
src: '/action-assets/enemies/bullfango.svg',
|
||||
displayScale: 3,
|
||||
},
|
||||
'yian-kut-ku': {
|
||||
src: '/action-assets/enemies/yian-kut-ku.svg',
|
||||
displayScale: 3.35,
|
||||
},
|
||||
bird: {
|
||||
src: '/action-assets/enemies/bird.svg',
|
||||
displayScale: 2.8,
|
||||
},
|
||||
'cyber-dragon': {
|
||||
src: '/action-assets/enemies/cyber-dragon.svg',
|
||||
displayScale: 3.4,
|
||||
},
|
||||
'claudecraft-mob': {
|
||||
src: '/action-assets/enemies/claudecraft-mob.svg',
|
||||
displayScale: 2.9,
|
||||
},
|
||||
'morthen-the-gravecaller': {
|
||||
src: '/action-assets/enemies/morthen-the-gravecaller.svg',
|
||||
displayScale: 3.05,
|
||||
},
|
||||
'vael-the-mistcaller': {
|
||||
src: '/action-assets/enemies/vael-the-mistcaller.svg',
|
||||
displayScale: 3,
|
||||
},
|
||||
'korzul-the-gravewyrm': {
|
||||
src: '/action-assets/enemies/korzul-the-gravewyrm.svg',
|
||||
displayScale: 3.65,
|
||||
},
|
||||
}
|
||||
|
||||
export const ACTION_DUNGEON_ICONS: Record<ActionDungeonId, string> = {
|
||||
bulldrome: '/action-assets/dungeons/bulldrome.svg',
|
||||
'yian-kut-ku': '/action-assets/dungeons/yian-kut-ku.svg',
|
||||
'cyber-dragon': '/action-assets/dungeons/cyber-dragon.svg',
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Phaser from 'phaser'
|
||||
import type { ActionDifficulty } from '../actionMode'
|
||||
import type { PlayerClass } from '../claudeCraftTypes'
|
||||
import type { TalentModifiers } from '../claudeCraftTalents'
|
||||
import { ACTION_ENEMY_SPRITES } from '../actionAssets'
|
||||
import {
|
||||
type ActionDungeonId,
|
||||
type ActionRunMode,
|
||||
@@ -7,17 +10,20 @@ import {
|
||||
getAllEnemies,
|
||||
getTargetableUnits,
|
||||
updateBulldromeState,
|
||||
type BossInput,
|
||||
type BulldromeState,
|
||||
type EnemyState,
|
||||
type HealTextEvent,
|
||||
type NoticeTextEvent,
|
||||
type SpellSlot,
|
||||
type TargetableState,
|
||||
} from './bulldromeSimulation'
|
||||
} from './actionCombatSimulation'
|
||||
import type { BossInput } from '../actionCombatTypes'
|
||||
|
||||
type SceneCallbacks = {
|
||||
difficulty: ActionDifficulty
|
||||
dungeonId: ActionDungeonId
|
||||
playerClassId?: PlayerClass
|
||||
playerTalentModifiers?: TalentModifiers
|
||||
runMode: ActionRunMode
|
||||
onStateChange: (state: BulldromeState) => void
|
||||
}
|
||||
@@ -33,6 +39,11 @@ type KeyMap = {
|
||||
THREE: Phaser.Input.Keyboard.Key
|
||||
FOUR: Phaser.Input.Keyboard.Key
|
||||
FIVE: Phaser.Input.Keyboard.Key
|
||||
SIX: Phaser.Input.Keyboard.Key
|
||||
SEVEN: Phaser.Input.Keyboard.Key
|
||||
EIGHT: Phaser.Input.Keyboard.Key
|
||||
NINE: Phaser.Input.Keyboard.Key
|
||||
ZERO: Phaser.Input.Keyboard.Key
|
||||
}
|
||||
|
||||
export class BulldromeScene extends Phaser.Scene {
|
||||
@@ -47,14 +58,20 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
private playerGraphics?: Phaser.GameObjects.Graphics
|
||||
private fxGraphics?: Phaser.GameObjects.Graphics
|
||||
private statusText?: Phaser.GameObjects.Text
|
||||
private enemySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||
private lastResetDown = false
|
||||
private lastOneDown = false
|
||||
private lastTwoDown = false
|
||||
private lastThreeDown = false
|
||||
private lastFourDown = false
|
||||
private lastFiveDown = false
|
||||
private lastSixDown = false
|
||||
private lastSevenDown = false
|
||||
private lastEightDown = false
|
||||
private lastNineDown = false
|
||||
private lastZeroDown = false
|
||||
private queuedTargetId: string | null = null
|
||||
private queuedSpell: 1 | 2 | 3 | 4 | 5 | null = null
|
||||
private queuedSpell: SpellSlot | null = null
|
||||
private hudPublishTimer = 0
|
||||
private seenHealEventIds = new Set<string>()
|
||||
private seenNoticeEventIds = new Set<string>()
|
||||
@@ -62,11 +79,17 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
constructor(callbacks: SceneCallbacks) {
|
||||
super('BulldromeScene')
|
||||
this.callbacks = callbacks
|
||||
this.state = createBulldromeState(callbacks.difficulty, callbacks.dungeonId, callbacks.runMode)
|
||||
this.state = createBulldromeState(callbacks.difficulty, callbacks.dungeonId, callbacks.runMode, callbacks.playerClassId, callbacks.playerTalentModifiers)
|
||||
}
|
||||
|
||||
preload() {
|
||||
for (const [kind, asset] of Object.entries(ACTION_ENEMY_SPRITES)) {
|
||||
this.load.image(`enemy-${kind}`, asset.src)
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
this.keys = this.input.keyboard?.addKeys('W,A,S,D,R,ONE,TWO,THREE,FOUR,FIVE') as KeyMap
|
||||
this.keys = this.input.keyboard?.addKeys('W,A,S,D,R,ONE,TWO,THREE,FOUR,FIVE,SIX,SEVEN,EIGHT,NINE,ZERO') as KeyMap
|
||||
this.arenaGraphics = this.add.graphics()
|
||||
this.telegraphGraphics = this.add.graphics()
|
||||
this.hazardGraphics = this.add.graphics()
|
||||
@@ -90,7 +113,7 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
this.queuedTargetId = targetId
|
||||
}
|
||||
|
||||
castSpell(slot: 1 | 2 | 3 | 4 | 5) {
|
||||
castSpell(slot: SpellSlot) {
|
||||
this.queuedSpell = slot
|
||||
}
|
||||
|
||||
@@ -131,12 +154,22 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
const threeDown = keys.THREE.isDown
|
||||
const fourDown = keys.FOUR.isDown
|
||||
const fiveDown = keys.FIVE.isDown
|
||||
const sixDown = keys.SIX.isDown
|
||||
const sevenDown = keys.SEVEN.isDown
|
||||
const eightDown = keys.EIGHT.isDown
|
||||
const nineDown = keys.NINE.isDown
|
||||
const zeroDown = keys.ZERO.isDown
|
||||
const pressedSpell = this.consumeQueuedSpell()
|
||||
?? (oneDown && !this.lastOneDown ? 1
|
||||
: twoDown && !this.lastTwoDown ? 2
|
||||
: threeDown && !this.lastThreeDown ? 3
|
||||
: fourDown && !this.lastFourDown ? 4
|
||||
: fiveDown && !this.lastFiveDown ? 5
|
||||
: sixDown && !this.lastSixDown ? 6
|
||||
: sevenDown && !this.lastSevenDown ? 7
|
||||
: eightDown && !this.lastEightDown ? 8
|
||||
: nineDown && !this.lastNineDown ? 9
|
||||
: zeroDown && !this.lastZeroDown ? 10
|
||||
: null)
|
||||
const input = {
|
||||
xAxis: Number(keys.D.isDown) - Number(keys.A.isDown),
|
||||
@@ -152,6 +185,11 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
this.lastThreeDown = threeDown
|
||||
this.lastFourDown = fourDown
|
||||
this.lastFiveDown = fiveDown
|
||||
this.lastSixDown = sixDown
|
||||
this.lastSevenDown = sevenDown
|
||||
this.lastEightDown = eightDown
|
||||
this.lastNineDown = nineDown
|
||||
this.lastZeroDown = zeroDown
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -178,6 +216,24 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId)
|
||||
if (!target) return
|
||||
|
||||
const ring = this.add.graphics()
|
||||
ring.setPosition(target.x, target.y)
|
||||
ring.lineStyle(4, 0x7dff9d, 0.95)
|
||||
ring.strokeCircle(0, 0, target.radius + 10)
|
||||
ring.lineStyle(2, 0xf4fff2, 0.8)
|
||||
ring.strokeCircle(0, 0, target.radius + 18)
|
||||
this.tweens.add({
|
||||
targets: ring,
|
||||
alpha: 0,
|
||||
scaleX: 1.45,
|
||||
scaleY: 1.45,
|
||||
duration: 520,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => ring.destroy(),
|
||||
})
|
||||
|
||||
if (event.amount <= 0) return
|
||||
|
||||
const text = this.add.text(target.x, target.y - target.radius - 18, `+${Math.ceil(event.amount)}`, {
|
||||
color: '#7dff9d',
|
||||
fontFamily: 'monospace',
|
||||
@@ -239,7 +295,7 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private selectTargetAt(x: number, y: number) {
|
||||
const units = [this.state.player, ...this.state.party].filter((unit) => unit.hp > 0)
|
||||
const units = [this.state.player, ...this.state.party, ...getAllEnemies(this.state)].filter((unit) => unit.hp > 0)
|
||||
const target = units
|
||||
.map((unit) => ({ unit, distance: Phaser.Math.Distance.Between(x, y, unit.x, unit.y) }))
|
||||
.filter(({ unit, distance }) => distance <= unit.radius + 14)
|
||||
@@ -318,6 +374,21 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.lineStyle(2, 0x090a0d, 1)
|
||||
graphics.strokeCircle(fireball.x, fireball.y, fireball.radius + 4)
|
||||
}
|
||||
|
||||
for (const blade of this.state.spinningBlades) {
|
||||
graphics.fillStyle(0xd9f7ff, 0.95)
|
||||
graphics.fillCircle(blade.x, blade.y, blade.radius)
|
||||
graphics.lineStyle(5, 0x7ff5ff, 1)
|
||||
graphics.beginPath()
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const angle = blade.rotation + (Math.PI / 2) * i
|
||||
graphics.moveTo(blade.x, blade.y)
|
||||
graphics.lineTo(blade.x + Math.cos(angle) * (blade.radius + 13), blade.y + Math.sin(angle) * (blade.radius + 13))
|
||||
}
|
||||
graphics.strokePath()
|
||||
graphics.lineStyle(2, 0x090a0d, 1)
|
||||
graphics.strokeCircle(blade.x, blade.y, blade.radius)
|
||||
}
|
||||
}
|
||||
|
||||
private drawBoss() {
|
||||
@@ -325,13 +396,17 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
if (!graphics) return
|
||||
|
||||
graphics.clear()
|
||||
for (const sprite of this.enemySprites.values()) sprite.setVisible(false)
|
||||
for (const enemy of getAllEnemies(this.state)) {
|
||||
if (enemy.hp <= 0) continue
|
||||
this.drawEnemyTargetRing(graphics, enemy)
|
||||
this.drawEnemy(graphics, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
private drawEnemy(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
|
||||
if (this.drawEnemySprite(enemy)) return
|
||||
|
||||
if (enemy.kind === 'yian-kut-ku') {
|
||||
this.drawYianKutKu(graphics, enemy)
|
||||
return
|
||||
@@ -342,6 +417,11 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
return
|
||||
}
|
||||
|
||||
if (enemy.kind === 'cyber-dragon') {
|
||||
this.drawCyberDragon(graphics, enemy)
|
||||
return
|
||||
}
|
||||
|
||||
const isCharging = enemy.phase === 'charging'
|
||||
const isRecovering = enemy.phase === 'recovering'
|
||||
const isSlam = enemy.phase === 'slamWindup'
|
||||
@@ -376,6 +456,30 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.strokeEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight)
|
||||
}
|
||||
|
||||
private drawEnemySprite(enemy: EnemyState) {
|
||||
const asset = ACTION_ENEMY_SPRITES[enemy.kind]
|
||||
const textureKey = `enemy-${enemy.kind}`
|
||||
if (!asset || !this.textures.exists(textureKey)) return false
|
||||
|
||||
let sprite = this.enemySprites.get(enemy.id)
|
||||
if (!sprite) {
|
||||
sprite = this.add.image(enemy.x, enemy.y, textureKey)
|
||||
sprite.setDepth(8)
|
||||
this.enemySprites.set(enemy.id, sprite)
|
||||
}
|
||||
|
||||
const size = enemy.radius * asset.displayScale
|
||||
sprite
|
||||
.setVisible(true)
|
||||
.setTexture(textureKey)
|
||||
.setPosition(enemy.x, enemy.y)
|
||||
.setDisplaySize(size, size)
|
||||
.setAlpha(enemy.phase === 'recovering' ? 0.85 : 1)
|
||||
.setAngle(enemy.kind === 'bird' && enemy.phase !== 'tracking' ? -8 : 0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private drawYianKutKu(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
|
||||
const isCasting = enemy.phase === 'windup'
|
||||
graphics.fillStyle(isCasting ? 0xff9d3d : 0xd8772f, 1)
|
||||
@@ -406,6 +510,23 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25)
|
||||
}
|
||||
|
||||
private drawCyberDragon(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
|
||||
const isCharging = enemy.phase === 'charging' || enemy.phase === 'circling'
|
||||
graphics.fillStyle(isCharging ? 0x89f7ff : 0x31566f, 1)
|
||||
graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 2.3, enemy.radius * 1.55)
|
||||
graphics.fillStyle(0x4aa3c7, 1)
|
||||
graphics.fillTriangle(enemy.x + 18, enemy.y - 4, enemy.x + 54, enemy.y - 18, enemy.x + 45, enemy.y + 15)
|
||||
graphics.fillTriangle(enemy.x - 16, enemy.y - 16, enemy.x - 42, enemy.y - 48, enemy.x - 4, enemy.y - 28)
|
||||
graphics.fillTriangle(enemy.x - 18, enemy.y + 15, enemy.x - 46, enemy.y + 48, enemy.x - 1, enemy.y + 30)
|
||||
graphics.fillStyle(0xff4f7d, 1)
|
||||
graphics.fillCircle(enemy.x + 18, enemy.y - 7, 5)
|
||||
graphics.lineStyle(3, 0xd9f7ff, 0.9)
|
||||
graphics.lineBetween(enemy.x - 22, enemy.y - 8, enemy.x + 8, enemy.y - 8)
|
||||
graphics.lineBetween(enemy.x - 24, enemy.y + 8, enemy.x + 10, enemy.y + 8)
|
||||
graphics.lineStyle(2, 0x090a0d, 1)
|
||||
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 2.3, enemy.radius * 1.55)
|
||||
}
|
||||
|
||||
private drawPlayer() {
|
||||
const graphics = this.playerGraphics
|
||||
if (!graphics) return
|
||||
@@ -426,6 +547,11 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.fillCircle(player.x + 2, player.y - 29, 3)
|
||||
graphics.fillCircle(player.x + 13, player.y - 23, 3)
|
||||
}
|
||||
|
||||
if (player.bleedTimer > 0) {
|
||||
graphics.lineStyle(3, 0xff6b84, 0.9)
|
||||
graphics.strokeCircle(player.x, player.y, player.radius + 13)
|
||||
}
|
||||
}
|
||||
|
||||
private drawParty() {
|
||||
@@ -452,6 +578,11 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.fillCircle(member.x - 8, member.y - 22, 3)
|
||||
graphics.fillCircle(member.x + 6, member.y - 24, 3)
|
||||
}
|
||||
|
||||
if (member.bleedTimer > 0) {
|
||||
graphics.lineStyle(3, 0xff6b84, 0.9)
|
||||
graphics.strokeCircle(member.x, member.y, member.radius + 13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +592,12 @@ export class BulldromeScene extends Phaser.Scene {
|
||||
graphics.strokeCircle(unit.x, unit.y, unit.radius + 9)
|
||||
}
|
||||
|
||||
private drawEnemyTargetRing(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
|
||||
if (this.state.targetId !== enemy.id) return
|
||||
graphics.lineStyle(4, 0xe55353, 1)
|
||||
graphics.strokeCircle(enemy.x, enemy.y, enemy.radius + 12)
|
||||
}
|
||||
|
||||
private drawUnitIcon(
|
||||
graphics: Phaser.GameObjects.Graphics,
|
||||
unit: TargetableState,
|
||||
|
||||
+801
-384
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import type { EnemyKind } from './bulldromeSimulation'
|
||||
import type { EnemyKind } from './actionCombatSimulation'
|
||||
|
||||
export type ActionAttackKind =
|
||||
| 'tankMelee'
|
||||
@@ -6,8 +6,24 @@ export type ActionAttackKind =
|
||||
| 'groundSlam'
|
||||
| 'fireballVolley'
|
||||
| 'birdDive'
|
||||
| 'spinningBlade'
|
||||
| 'circularCharge'
|
||||
| 'bodyContact'
|
||||
|
||||
export type ActionAiAvoidanceSource =
|
||||
| 'projectile'
|
||||
| 'persistentArea'
|
||||
| 'circleTelegraph'
|
||||
| 'lineTelegraph'
|
||||
|
||||
export type ActionAiAvoidanceConfig = {
|
||||
enabled: boolean
|
||||
buffer: number
|
||||
moveSpeed: number
|
||||
avoidDistance: number
|
||||
lineWidthMultiplier?: number
|
||||
}
|
||||
|
||||
export type ActionAttackConfig = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -19,7 +35,9 @@ export type ActionAttackConfig = {
|
||||
recoverSeconds?: number
|
||||
speed?: number
|
||||
radius?: number
|
||||
durationSeconds?: number
|
||||
everyNthCharge?: number
|
||||
aiAvoidance?: Partial<Record<ActionAiAvoidanceSource, ActionAiAvoidanceConfig>>
|
||||
}
|
||||
|
||||
export type ActionEnemyMechanicConfig = {
|
||||
@@ -31,7 +49,7 @@ export type ActionEnemyMechanicConfig = {
|
||||
|
||||
export type ActionMechanicConfig = Record<EnemyKind, ActionEnemyMechanicConfig>
|
||||
|
||||
export const ACTION_MECHANIC_CONFIG_KEY = 'i-want-to-heal:action-mode-mechanics:v1'
|
||||
export const ACTION_MECHANIC_CONFIG_KEY = 'i-want-to-heal:action-mode-mechanics:v2'
|
||||
|
||||
export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
|
||||
bulldrome: {
|
||||
@@ -39,18 +57,24 @@ export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
|
||||
label: 'Bulldrome',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('bulldrome-tank-melee', 'Tank Melee', 'tankMelee', 0.9, 13),
|
||||
createAttack('bulldrome-charge', 'Charge', 'charge', 2.8, 24, {
|
||||
createAttack('bulldrome-tank-melee', 'Tank Melee', 'tankMelee', 0.9, 42),
|
||||
createAttack('bulldrome-charge', 'Charge', 'charge', 2.8, 92, {
|
||||
windupSeconds: 0.82,
|
||||
recoverSeconds: 0.86,
|
||||
speed: 650,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.42 }),
|
||||
},
|
||||
}),
|
||||
createAttack('bulldrome-ground-slam', 'Ground Slam', 'groundSlam', 3, 28, {
|
||||
createAttack('bulldrome-ground-slam', 'Ground Slam', 'groundSlam', 3, 110, {
|
||||
windupSeconds: 1.25,
|
||||
radius: 142,
|
||||
everyNthCharge: 3,
|
||||
aiAvoidance: {
|
||||
circleTelegraph: createAiAvoidance({ buffer: 12 }),
|
||||
},
|
||||
}),
|
||||
createAttack('bulldrome-body-contact', 'Body Contact', 'bodyContact', 0, 10),
|
||||
createAttack('bulldrome-body-contact', 'Body Contact', 'bodyContact', 0, 38),
|
||||
],
|
||||
},
|
||||
bullfango: {
|
||||
@@ -58,13 +82,16 @@ export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
|
||||
label: 'Bullfango',
|
||||
role: 'mob',
|
||||
attacks: [
|
||||
createAttack('bullfango-tank-melee', 'Tank Melee', 'tankMelee', 1.35, 6),
|
||||
createAttack('bullfango-charge', 'Charge', 'charge', 1.6, 13, {
|
||||
createAttack('bullfango-tank-melee', 'Tank Melee', 'tankMelee', 1.35, 22),
|
||||
createAttack('bullfango-charge', 'Charge', 'charge', 1.6, 46, {
|
||||
windupSeconds: 0.68,
|
||||
recoverSeconds: 1.05,
|
||||
speed: 510,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.42 }),
|
||||
},
|
||||
}),
|
||||
createAttack('bullfango-body-contact', 'Body Contact', 'bodyContact', 0, 4),
|
||||
createAttack('bullfango-body-contact', 'Body Contact', 'bodyContact', 0, 18),
|
||||
],
|
||||
},
|
||||
'yian-kut-ku': {
|
||||
@@ -72,13 +99,17 @@ export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
|
||||
label: 'Yian Kut-Ku',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('yian-tank-melee', 'Tank Peck', 'tankMelee', 1, 11),
|
||||
createAttack('yian-fireballs', 'Fireball Volley', 'fireballVolley', 2.4, 16, {
|
||||
createAttack('yian-tank-melee', 'Tank Peck', 'tankMelee', 1, 38),
|
||||
createAttack('yian-fireballs', 'Fireball Volley', 'fireballVolley', 2.4, 70, {
|
||||
windupSeconds: 1,
|
||||
recoverSeconds: 1.1,
|
||||
speed: 275,
|
||||
speed: 190,
|
||||
aiAvoidance: {
|
||||
projectile: createAiAvoidance({ buffer: 34 }),
|
||||
persistentArea: createAiAvoidance({ buffer: 18 }),
|
||||
},
|
||||
}),
|
||||
createAttack('yian-body-contact', 'Body Contact', 'bodyContact', 0, 8),
|
||||
createAttack('yian-body-contact', 'Body Contact', 'bodyContact', 0, 32),
|
||||
],
|
||||
},
|
||||
bird: {
|
||||
@@ -86,11 +117,132 @@ export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
|
||||
label: 'Bird',
|
||||
role: 'mob',
|
||||
attacks: [
|
||||
createAttack('bird-tank-melee', 'Tank Claw', 'tankMelee', 1.15, 5),
|
||||
createAttack('bird-dive', 'Dive Flight', 'birdDive', 3.2, 12, {
|
||||
createAttack('bird-tank-melee', 'Tank Claw', 'tankMelee', 1.15, 18),
|
||||
createAttack('bird-dive', 'Dive Flight', 'birdDive', 3.2, 42, {
|
||||
speed: 340,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.42 }),
|
||||
},
|
||||
}),
|
||||
createAttack('bird-body-contact', 'Body Contact', 'bodyContact', 0, 4),
|
||||
createAttack('bird-body-contact', 'Body Contact', 'bodyContact', 0, 16),
|
||||
],
|
||||
},
|
||||
'cyber-dragon': {
|
||||
kind: 'cyber-dragon',
|
||||
label: 'Cyber Dragon',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('cyber-tank-melee', 'Tank Slash', 'tankMelee', 1, 44),
|
||||
createAttack('cyber-spinning-blade', 'Spinning Blade', 'spinningBlade', 10, 22, {
|
||||
recoverSeconds: 15,
|
||||
speed: 96,
|
||||
radius: 24,
|
||||
aiAvoidance: {
|
||||
persistentArea: createAiAvoidance({ avoidDistance: 150, buffer: 42, moveSpeed: 184 }),
|
||||
},
|
||||
}),
|
||||
createAttack('cyber-circular-charge', 'Circular Charge', 'circularCharge', 6.8, 84, {
|
||||
windupSeconds: 1.35,
|
||||
recoverSeconds: 0.8,
|
||||
speed: 520,
|
||||
radius: 62,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.48 }),
|
||||
},
|
||||
}),
|
||||
createAttack('cyber-body-contact', 'Body Contact', 'bodyContact', 0, 34),
|
||||
],
|
||||
},
|
||||
'claudecraft-mob': {
|
||||
kind: 'claudecraft-mob',
|
||||
label: 'ClaudeCraft Dungeon Mob',
|
||||
role: 'mob',
|
||||
attacks: [
|
||||
createAttack('cc-mob-tank-melee', 'Tank Melee', 'tankMelee', 2.1, 9),
|
||||
createAttack('cc-mob-lunge', 'Lunge', 'charge', 2.6, 12, {
|
||||
windupSeconds: 0.62,
|
||||
recoverSeconds: 0.8,
|
||||
speed: 430,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.38 }),
|
||||
},
|
||||
}),
|
||||
createAttack('cc-mob-body-contact', 'Body Contact', 'bodyContact', 0, 5),
|
||||
],
|
||||
},
|
||||
'morthen-the-gravecaller': {
|
||||
kind: 'morthen-the-gravecaller',
|
||||
label: 'Morthen the Gravecaller',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('morthen-tank-melee', 'Grave Strike', 'tankMelee', 2.6, 18),
|
||||
createAttack('morthen-charge', 'Grave Lunge', 'charge', 3.1, 22, {
|
||||
windupSeconds: 0.78,
|
||||
recoverSeconds: 0.9,
|
||||
speed: 460,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.42 }),
|
||||
},
|
||||
}),
|
||||
createAttack('morthen-shadow-pulse', 'Shadow Pulse', 'groundSlam', 2, 22, {
|
||||
windupSeconds: 1.1,
|
||||
radius: 132,
|
||||
everyNthCharge: 2,
|
||||
aiAvoidance: {
|
||||
circleTelegraph: createAiAvoidance({ buffer: 12 }),
|
||||
},
|
||||
}),
|
||||
createAttack('morthen-body-contact', 'Body Contact', 'bodyContact', 0, 8),
|
||||
],
|
||||
},
|
||||
'vael-the-mistcaller': {
|
||||
kind: 'vael-the-mistcaller',
|
||||
label: 'Vael the Mistcaller',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('vael-tank-melee', 'Mist Slash', 'tankMelee', 2.4, 20),
|
||||
createAttack('vael-charge', 'Tide Rush', 'charge', 3, 24, {
|
||||
windupSeconds: 0.82,
|
||||
recoverSeconds: 0.95,
|
||||
speed: 470,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.42 }),
|
||||
},
|
||||
}),
|
||||
createAttack('vael-mist-surge', 'Mist Surge', 'groundSlam', 2, 26, {
|
||||
windupSeconds: 1.05,
|
||||
radius: 148,
|
||||
everyNthCharge: 2,
|
||||
aiAvoidance: {
|
||||
circleTelegraph: createAiAvoidance({ buffer: 14 }),
|
||||
},
|
||||
}),
|
||||
createAttack('vael-body-contact', 'Body Contact', 'bodyContact', 0, 8),
|
||||
],
|
||||
},
|
||||
'korzul-the-gravewyrm': {
|
||||
kind: 'korzul-the-gravewyrm',
|
||||
label: 'Korzul the Gravewyrm',
|
||||
role: 'boss',
|
||||
attacks: [
|
||||
createAttack('korzul-tank-melee', 'Wyrm Bite', 'tankMelee', 2.6, 26),
|
||||
createAttack('korzul-charge', 'Gravewyrm Charge', 'charge', 3.2, 30, {
|
||||
windupSeconds: 0.95,
|
||||
recoverSeconds: 1,
|
||||
speed: 500,
|
||||
aiAvoidance: {
|
||||
lineTelegraph: createAiAvoidance({ buffer: 0, lineWidthMultiplier: 0.5 }),
|
||||
},
|
||||
}),
|
||||
createAttack('korzul-necrotic-shockwave', 'Necrotic Shockwave', 'groundSlam', 2, 36, {
|
||||
windupSeconds: 1.2,
|
||||
radius: 168,
|
||||
everyNthCharge: 2,
|
||||
aiAvoidance: {
|
||||
circleTelegraph: createAiAvoidance({ buffer: 16 }),
|
||||
},
|
||||
}),
|
||||
createAttack('korzul-body-contact', 'Body Contact', 'bodyContact', 0, 10),
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -132,6 +284,16 @@ export function getEnabledActionAttack(kind: EnemyKind, attackKind: ActionAttack
|
||||
return attack?.enabled ? attack : null
|
||||
}
|
||||
|
||||
export function getActionAiAvoidance(
|
||||
kind: EnemyKind,
|
||||
attackKind: ActionAttackKind,
|
||||
source: ActionAiAvoidanceSource,
|
||||
) {
|
||||
const attack = getEnabledActionAttack(kind, attackKind)
|
||||
const avoidance = attack?.aiAvoidance?.[source]
|
||||
return avoidance?.enabled ? avoidance : null
|
||||
}
|
||||
|
||||
function createAttack(
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -151,6 +313,16 @@ function createAttack(
|
||||
}
|
||||
}
|
||||
|
||||
function createAiAvoidance(options: Partial<ActionAiAvoidanceConfig> = {}): ActionAiAvoidanceConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
avoidDistance: 132,
|
||||
buffer: 0,
|
||||
moveSpeed: 190,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMechanicConfig(saved: Partial<ActionMechanicConfig>) {
|
||||
const merged = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
|
||||
for (const kind of Object.keys(merged) as EnemyKind[]) {
|
||||
@@ -161,11 +333,34 @@ function mergeMechanicConfig(saved: Partial<ActionMechanicConfig>) {
|
||||
...merged[kind],
|
||||
...savedEnemy,
|
||||
kind,
|
||||
attacks: merged[kind].attacks.map((attack) => ({
|
||||
attacks: merged[kind].attacks.map((attack) => {
|
||||
const savedAttack = savedAttacks.find((candidate) => candidate.id === attack.id)
|
||||
return {
|
||||
...attack,
|
||||
...savedAttacks.find((candidate) => candidate.id === attack.id),
|
||||
})),
|
||||
...savedAttack,
|
||||
aiAvoidance: mergeAiAvoidance(attack.aiAvoidance, savedAttack?.aiAvoidance),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeAiAvoidance(
|
||||
defaults: ActionAttackConfig['aiAvoidance'],
|
||||
saved: ActionAttackConfig['aiAvoidance'],
|
||||
) {
|
||||
if (!defaults && !saved) return undefined
|
||||
const sources = new Set([
|
||||
...Object.keys(defaults ?? {}),
|
||||
...Object.keys(saved ?? {}),
|
||||
] as ActionAiAvoidanceSource[])
|
||||
const merged: ActionAttackConfig['aiAvoidance'] = {}
|
||||
for (const source of sources) {
|
||||
merged[source] = {
|
||||
...defaults?.[source],
|
||||
...saved?.[source],
|
||||
} as ActionAiAvoidanceConfig
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
getActionAttack,
|
||||
type ActionAttackKind,
|
||||
} from './actionEncounterConfig'
|
||||
import type { EnemyKind } from './actionCombatSimulation'
|
||||
|
||||
const BOSS_TRACK_SECONDS = 1.15
|
||||
const BOSS_WINDUP_SECONDS = 0.82
|
||||
const BOSS_CHARGE_SECONDS = 0.74
|
||||
const BOSS_RECOVER_SECONDS = 0.86
|
||||
const BOSS_TANK_FOCUS_SECONDS = 2.8
|
||||
const BOSS_TANK_SWING_SECONDS = 0.9
|
||||
const BOSS_TANK_SWING_DAMAGE = 13
|
||||
const BOSS_CHARGE_SPEED = 650
|
||||
const BOSS_BODY_DAMAGE = 10
|
||||
const BOSS_CHARGE_DAMAGE = 24
|
||||
const BULLFANGO_TRACK_SECONDS = 2.35
|
||||
const BULLFANGO_WINDUP_SECONDS = 0.68
|
||||
const BULLFANGO_CHARGE_SECONDS = 0.58
|
||||
const BULLFANGO_RECOVER_SECONDS = 1.05
|
||||
const BULLFANGO_TANK_FOCUS_SECONDS = 1.6
|
||||
const BULLFANGO_TANK_SWING_SECONDS = 1.35
|
||||
const BULLFANGO_TANK_SWING_DAMAGE = 6
|
||||
const BULLFANGO_CHARGE_SPEED = 510
|
||||
const BULLFANGO_BODY_DAMAGE = 4
|
||||
const BULLFANGO_CHARGE_DAMAGE = 13
|
||||
const YIAN_TRACK_SECONDS = 2.1
|
||||
const YIAN_RECOVER_SECONDS = 1.1
|
||||
const YIAN_TANK_FOCUS_SECONDS = 2.4
|
||||
const YIAN_TANK_SWING_SECONDS = 1
|
||||
const YIAN_TANK_SWING_DAMAGE = 11
|
||||
const YIAN_BODY_DAMAGE = 8
|
||||
const BIRD_FLIGHT_TIMER_SECONDS = 3.2
|
||||
const BIRD_FLIGHT_SPEED = 340
|
||||
const BIRD_TANK_SWING_SECONDS = 1.15
|
||||
const BIRD_TANK_SWING_DAMAGE = 5
|
||||
const BIRD_BODY_DAMAGE = 4
|
||||
const BIRD_CHARGE_DAMAGE = 12
|
||||
const CYBER_TRACK_SECONDS = 1.9
|
||||
const CYBER_TANK_FOCUS_SECONDS = 2.2
|
||||
const CYBER_TANK_SWING_SECONDS = 1
|
||||
const CYBER_TANK_SWING_DAMAGE = 12
|
||||
const CYBER_BODY_DAMAGE = 8
|
||||
const CYBER_CIRCLE_SECONDS = 1.35
|
||||
const CYBER_CHARGE_SECONDS = 0.72
|
||||
const CYBER_CHARGE_SPEED = 520
|
||||
const CYBER_CHARGE_DAMAGE = 18
|
||||
|
||||
export function getEnemyWindupSeconds(kind: EnemyKind) {
|
||||
if (kind === 'cyber-dragon') return getAttackNumber(kind, 'circularCharge', 'windupSeconds', CYBER_CIRCLE_SECONDS)
|
||||
const fallback = kind === 'bulldrome' ? BOSS_WINDUP_SECONDS : BULLFANGO_WINDUP_SECONDS
|
||||
return getAttackNumber(kind, 'charge', 'windupSeconds', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyChargeSeconds(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku' || kind === 'bird') return 0
|
||||
if (kind === 'cyber-dragon') return CYBER_CHARGE_SECONDS
|
||||
return kind === 'bulldrome' ? BOSS_CHARGE_SECONDS : BULLFANGO_CHARGE_SECONDS
|
||||
}
|
||||
|
||||
export function getEnemyChargeSpeed(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku') return 0
|
||||
if (kind === 'bird') return getAttackNumber(kind, 'birdDive', 'speed', BIRD_FLIGHT_SPEED)
|
||||
if (kind === 'cyber-dragon') return getAttackNumber(kind, 'circularCharge', 'speed', CYBER_CHARGE_SPEED)
|
||||
const fallback = kind === 'bulldrome' ? BOSS_CHARGE_SPEED : BULLFANGO_CHARGE_SPEED
|
||||
return getAttackNumber(kind, 'charge', 'speed', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyRecoverSeconds(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku') return getAttackNumber(kind, 'fireballVolley', 'recoverSeconds', YIAN_RECOVER_SECONDS)
|
||||
if (kind === 'bird') return 0
|
||||
if (kind === 'cyber-dragon') return getAttackNumber(kind, 'circularCharge', 'recoverSeconds', 0.8)
|
||||
const fallback = kind === 'bulldrome' ? BOSS_RECOVER_SECONDS : BULLFANGO_RECOVER_SECONDS
|
||||
return getAttackNumber(kind, 'charge', 'recoverSeconds', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyTankFocusSeconds(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku') return getAttackFrequency(kind, 'fireballVolley', YIAN_TANK_FOCUS_SECONDS)
|
||||
if (kind === 'bird') return getAttackFrequency(kind, 'birdDive', BIRD_FLIGHT_TIMER_SECONDS)
|
||||
if (kind === 'cyber-dragon') return getAttackFrequency(kind, 'circularCharge', CYBER_TANK_FOCUS_SECONDS)
|
||||
const fallback = kind === 'bulldrome' ? BOSS_TANK_FOCUS_SECONDS : BULLFANGO_TANK_FOCUS_SECONDS
|
||||
return getAttackFrequency(kind, 'charge', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyTankSwingSeconds(kind: EnemyKind) {
|
||||
const fallback = kind === 'yian-kut-ku'
|
||||
? YIAN_TANK_SWING_SECONDS
|
||||
: kind === 'bird'
|
||||
? BIRD_TANK_SWING_SECONDS
|
||||
: kind === 'cyber-dragon'
|
||||
? CYBER_TANK_SWING_SECONDS
|
||||
: kind === 'bulldrome'
|
||||
? BOSS_TANK_SWING_SECONDS
|
||||
: BULLFANGO_TANK_SWING_SECONDS
|
||||
return getAttackFrequency(kind, 'tankMelee', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyTankSwingDamage(kind: EnemyKind) {
|
||||
const fallback = kind === 'yian-kut-ku'
|
||||
? YIAN_TANK_SWING_DAMAGE
|
||||
: kind === 'bird'
|
||||
? BIRD_TANK_SWING_DAMAGE
|
||||
: kind === 'cyber-dragon'
|
||||
? CYBER_TANK_SWING_DAMAGE
|
||||
: kind === 'bulldrome'
|
||||
? BOSS_TANK_SWING_DAMAGE
|
||||
: BULLFANGO_TANK_SWING_DAMAGE
|
||||
return getEnemyAttackDamage(kind, 'tankMelee', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyChargeDamage(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku') return 0
|
||||
if (kind === 'bird') return getEnemyAttackDamage(kind, 'birdDive', BIRD_CHARGE_DAMAGE)
|
||||
if (kind === 'cyber-dragon') return getEnemyAttackDamage(kind, 'circularCharge', CYBER_CHARGE_DAMAGE)
|
||||
const fallback = kind === 'bulldrome' ? BOSS_CHARGE_DAMAGE : BULLFANGO_CHARGE_DAMAGE
|
||||
return getEnemyAttackDamage(kind, 'charge', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyBodyDamage(kind: EnemyKind) {
|
||||
const fallback = kind === 'yian-kut-ku'
|
||||
? YIAN_BODY_DAMAGE
|
||||
: kind === 'bird'
|
||||
? BIRD_BODY_DAMAGE
|
||||
: kind === 'cyber-dragon'
|
||||
? CYBER_BODY_DAMAGE
|
||||
: kind === 'bulldrome'
|
||||
? BOSS_BODY_DAMAGE
|
||||
: BULLFANGO_BODY_DAMAGE
|
||||
return getEnemyAttackDamage(kind, 'bodyContact', fallback)
|
||||
}
|
||||
|
||||
export function getEnemyTrackSeconds(kind: EnemyKind) {
|
||||
if (kind === 'yian-kut-ku') return getAttackFrequency(kind, 'fireballVolley', YIAN_TRACK_SECONDS)
|
||||
if (kind === 'bird') return getAttackFrequency(kind, 'birdDive', BIRD_FLIGHT_TIMER_SECONDS)
|
||||
if (kind === 'cyber-dragon') return CYBER_TRACK_SECONDS
|
||||
const fallback = kind === 'bulldrome' ? BOSS_TRACK_SECONDS : BULLFANGO_TRACK_SECONDS
|
||||
return getAttackFrequency(kind, 'charge', fallback)
|
||||
}
|
||||
|
||||
function getAttackNumber(
|
||||
kind: EnemyKind,
|
||||
attackKind: ActionAttackKind,
|
||||
field: 'windupSeconds' | 'recoverSeconds' | 'speed',
|
||||
fallback: number,
|
||||
) {
|
||||
return getActionAttack(kind, attackKind)?.[field] ?? fallback
|
||||
}
|
||||
|
||||
function getAttackFrequency(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) {
|
||||
return getActionAttack(kind, attackKind)?.frequencySeconds ?? fallback
|
||||
}
|
||||
|
||||
function getEnemyAttackDamage(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) {
|
||||
const attack = getActionAttack(kind, attackKind)
|
||||
if (!attack) return fallback
|
||||
return attack.enabled ? attack.damage : 0
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { PartyRole } from './actionCombatCore'
|
||||
|
||||
export type ArenaClassId =
|
||||
| 'priest'
|
||||
| 'knight'
|
||||
| 'ranger'
|
||||
| 'mage'
|
||||
| 'rogue'
|
||||
| 'warrior'
|
||||
| 'paladin'
|
||||
| 'druid'
|
||||
| 'shaman'
|
||||
| 'priest_holy'
|
||||
| 'priest_discipline'
|
||||
| 'priest_shadow'
|
||||
| 'warrior_protection'
|
||||
| 'warrior_arms'
|
||||
| 'warrior_fury'
|
||||
| 'paladin_protection'
|
||||
| 'paladin_holy'
|
||||
| 'paladin_retribution'
|
||||
| 'druid_restoration'
|
||||
| 'druid_feral_tank'
|
||||
| 'druid_feral_cat'
|
||||
| 'druid_balance'
|
||||
| 'mage_arcane'
|
||||
| 'mage_fire'
|
||||
| 'mage_frost'
|
||||
| 'rogue_assassination'
|
||||
| 'rogue_combat'
|
||||
| 'rogue_subtlety'
|
||||
| 'hunter_beast_mastery'
|
||||
| 'hunter_marksmanship'
|
||||
| 'hunter_survival'
|
||||
| 'shaman_restoration'
|
||||
| 'shaman_elemental'
|
||||
| 'shaman_enhancement'
|
||||
| 'warlock_affliction'
|
||||
| 'warlock_demonology'
|
||||
| 'warlock_destruction'
|
||||
|
||||
export type ArenaAbilitySchool = 'arcane' | 'fire' | 'frost' | 'holy' | 'nature' | 'physical' | 'shadow'
|
||||
|
||||
export type ArenaAbilityId = string
|
||||
|
||||
export type ArenaAbilityKind = 'attack' | 'buff' | 'damage' | 'heal' | 'shield'
|
||||
|
||||
export type ArenaAbilityDefinition = {
|
||||
animation: 'block' | 'dual' | 'melee' | 'ranged' | 'spell'
|
||||
castTime: number
|
||||
cooldown: number
|
||||
damage?: number
|
||||
dot?: {
|
||||
damage: number
|
||||
seconds: number
|
||||
}
|
||||
heal?: number
|
||||
id: ArenaAbilityId
|
||||
kind: ArenaAbilityKind
|
||||
name: string
|
||||
range: number
|
||||
school: ArenaAbilitySchool
|
||||
shield?: number
|
||||
}
|
||||
|
||||
export type ArenaClassKit = {
|
||||
actionBar: ArenaAbilityId[]
|
||||
attackSeconds: number
|
||||
basicAttack: ArenaAbilityId
|
||||
defaultPvpSpec: string
|
||||
displayName: string
|
||||
id: ArenaClassId
|
||||
model: string
|
||||
role: PartyRole
|
||||
weapons: Array<{ hand: 'handslot.l' | 'handslot.r'; model: string }>
|
||||
}
|
||||
|
||||
const ROOT = '/action-assets/models/claudecraft'
|
||||
|
||||
export const ARENA_PVP_EFFECTIVE_LEVEL = 20
|
||||
export const ARENA_PVP_DEFAULT_TALENT_POINTS = ARENA_PVP_EFFECTIVE_LEVEL - 9
|
||||
|
||||
export type ArenaClassVitalsTemplate = {
|
||||
baseHp: number
|
||||
baseMana: number
|
||||
baseStats: { int: number, sta: number }
|
||||
hpPerLevel: number
|
||||
manaPerLevel: number
|
||||
resourceType: 'energy' | 'mana' | 'rage'
|
||||
statsPerLevel: { int: number, sta: number }
|
||||
}
|
||||
|
||||
const ARENA_PVP_GEAR_STATS: Partial<Record<ArenaClassId, { int: number, sta: number }>> = {
|
||||
druid: { int: 12, sta: 10 },
|
||||
knight: { int: 0, sta: 16 },
|
||||
mage: { int: 12, sta: 8 },
|
||||
paladin: { int: 8, sta: 15 },
|
||||
priest: { int: 12, sta: 8 },
|
||||
ranger: { int: 5, sta: 10 },
|
||||
rogue: { int: 0, sta: 9 },
|
||||
shaman: { int: 11, sta: 11 },
|
||||
warrior: { int: 0, sta: 17 },
|
||||
}
|
||||
|
||||
const ARENA_CLASS_VITALS: Partial<Record<ArenaClassId, ArenaClassVitalsTemplate>> = {
|
||||
knight: {
|
||||
baseHp: 50,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 10, sta: 22 },
|
||||
hpPerLevel: 18,
|
||||
manaPerLevel: 0,
|
||||
resourceType: 'rage',
|
||||
statsPerLevel: { int: 0, sta: 2 },
|
||||
},
|
||||
mage: {
|
||||
baseHp: 40,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 24, sta: 14 },
|
||||
hpPerLevel: 12,
|
||||
manaPerLevel: 24,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 3, sta: 1 },
|
||||
},
|
||||
paladin: {
|
||||
baseHp: 52,
|
||||
baseMana: 90,
|
||||
baseStats: { int: 16, sta: 21 },
|
||||
hpPerLevel: 17,
|
||||
manaPerLevel: 14,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 1, sta: 2 },
|
||||
},
|
||||
priest: {
|
||||
baseHp: 38,
|
||||
baseMana: 110,
|
||||
baseStats: { int: 22, sta: 13 },
|
||||
hpPerLevel: 11,
|
||||
manaPerLevel: 26,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 2, sta: 1 },
|
||||
},
|
||||
druid: {
|
||||
baseHp: 44,
|
||||
baseMana: 115,
|
||||
baseStats: { int: 21, sta: 16 },
|
||||
hpPerLevel: 12,
|
||||
manaPerLevel: 24,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 2, sta: 1 },
|
||||
},
|
||||
shaman: {
|
||||
baseHp: 47,
|
||||
baseMana: 105,
|
||||
baseStats: { int: 20, sta: 17 },
|
||||
hpPerLevel: 13,
|
||||
manaPerLevel: 22,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 2, sta: 1 },
|
||||
},
|
||||
ranger: {
|
||||
baseHp: 50,
|
||||
baseMana: 80,
|
||||
baseStats: { int: 13, sta: 19 },
|
||||
hpPerLevel: 15,
|
||||
manaPerLevel: 18,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 1, sta: 2 },
|
||||
},
|
||||
rogue: {
|
||||
baseHp: 45,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 11, sta: 17 },
|
||||
hpPerLevel: 15,
|
||||
manaPerLevel: 0,
|
||||
resourceType: 'energy',
|
||||
statsPerLevel: { int: 0, sta: 1 },
|
||||
},
|
||||
warrior: {
|
||||
baseHp: 54,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 10, sta: 23 },
|
||||
hpPerLevel: 19,
|
||||
manaPerLevel: 0,
|
||||
resourceType: 'rage',
|
||||
statsPerLevel: { int: 0, sta: 2 },
|
||||
},
|
||||
}
|
||||
|
||||
export function getArenaClassVitals(classId: ArenaClassId, level: number = ARENA_PVP_EFFECTIVE_LEVEL) {
|
||||
const template = ARENA_CLASS_VITALS[classId] ?? getArenaFallbackVitals(classId)
|
||||
const gear = ARENA_PVP_GEAR_STATS[classId] ?? getArenaFallbackGear(classId)
|
||||
const stamina = template.baseStats.sta + template.statsPerLevel.sta * (level - 1) + gear.sta
|
||||
const intellect = template.baseStats.int + template.statsPerLevel.int * (level - 1) + gear.int
|
||||
const maxHp = template.baseHp + template.hpPerLevel * (level - 1) + statResourceFromPrimary(stamina, 10)
|
||||
const maxMana = template.resourceType === 'mana'
|
||||
? template.baseMana + template.manaPerLevel * (level - 1) + statResourceFromPrimary(intellect, 15)
|
||||
: 100
|
||||
return { maxHp, maxMana, resourceType: template.resourceType }
|
||||
}
|
||||
|
||||
function getArenaFallbackVitals(classId: ArenaClassId): ArenaClassVitalsTemplate {
|
||||
const kit = getArenaClassKit(classId)
|
||||
if (kit.role === 'tank') {
|
||||
return {
|
||||
baseHp: 52,
|
||||
baseMana: 95,
|
||||
baseStats: { int: 12, sta: 22 },
|
||||
hpPerLevel: 18,
|
||||
manaPerLevel: classId.includes('paladin') ? 14 : 0,
|
||||
resourceType: classId.includes('paladin') ? 'mana' : classId.includes('druid') ? 'rage' : 'rage',
|
||||
statsPerLevel: { int: classId.includes('paladin') ? 1 : 0, sta: 2 },
|
||||
}
|
||||
}
|
||||
if (kit.role === 'healer') {
|
||||
return {
|
||||
baseHp: 42,
|
||||
baseMana: 110,
|
||||
baseStats: { int: 22, sta: 15 },
|
||||
hpPerLevel: 12,
|
||||
manaPerLevel: 24,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 2, sta: 1 },
|
||||
}
|
||||
}
|
||||
if (kit.role === 'melee') {
|
||||
return {
|
||||
baseHp: 46,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 11, sta: 18 },
|
||||
hpPerLevel: 15,
|
||||
manaPerLevel: classId.includes('paladin') || classId.includes('shaman') ? 16 : 0,
|
||||
resourceType: classId.includes('paladin') || classId.includes('shaman') ? 'mana' : classId.includes('rogue') || classId.includes('druid_feral_cat') ? 'energy' : 'rage',
|
||||
statsPerLevel: { int: classId.includes('paladin') || classId.includes('shaman') ? 1 : 0, sta: 1 },
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseHp: 40,
|
||||
baseMana: 100,
|
||||
baseStats: { int: 22, sta: 14 },
|
||||
hpPerLevel: 12,
|
||||
manaPerLevel: 22,
|
||||
resourceType: 'mana',
|
||||
statsPerLevel: { int: 2, sta: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function getArenaFallbackGear(classId: ArenaClassId) {
|
||||
const kit = getArenaClassKit(classId)
|
||||
if (kit.role === 'tank') return { int: classId.includes('paladin') ? 8 : 0, sta: 16 }
|
||||
if (kit.role === 'healer') return { int: 12, sta: 9 }
|
||||
if (kit.role === 'melee') return { int: classId.includes('paladin') || classId.includes('shaman') ? 7 : 0, sta: 10 }
|
||||
return { int: 11, sta: 8 }
|
||||
}
|
||||
|
||||
function statResourceFromPrimary(value: number, overTwentyMultiplier: number) {
|
||||
const stat = Math.max(0, value)
|
||||
return Math.min(stat, 20) + Math.max(0, stat - 20) * overTwentyMultiplier
|
||||
}
|
||||
|
||||
export const ARENA_ABILITIES: Record<ArenaAbilityId, ArenaAbilityDefinition> = {
|
||||
aimed_shot: ability('aimed_shot', 'Aimed Shot', 'damage', 'physical', 'ranged', 3, 6, 312, { damage: 56 }),
|
||||
arcane_explosion: ability('arcane_explosion', 'Arcane Explosion', 'damage', 'arcane', 'spell', 0, 8, 120, { damage: 44 }),
|
||||
arcane_intellect: ability('arcane_intellect', 'Arcane Intellect', 'buff', 'arcane', 'spell', 0, 0, 288),
|
||||
arcane_missiles: ability('arcane_missiles', 'Arcane Missiles', 'damage', 'arcane', 'spell', 0, 0, 288, { damage: 66 }),
|
||||
arcane_shot: ability('arcane_shot', 'Arcane Shot', 'damage', 'arcane', 'ranged', 0, 6, 336, { damage: 43 }),
|
||||
ambush: ability('ambush', 'Ambush', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 92 }),
|
||||
aspect_of_the_hawk: ability('aspect_of_the_hawk', 'Aspect of the Hawk', 'buff', 'physical', 'ranged', 0, 0, 0),
|
||||
backstab: ability('backstab', 'Backstab', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 74 }),
|
||||
barkskin: ability('barkskin', 'Barkskin', 'shield', 'nature', 'spell', 0, 12, 0, { shield: 90 }),
|
||||
bear_charge: ability('bear_charge', 'Bear Charge', 'damage', 'physical', 'melee', 0, 15, 230, { damage: 8 }),
|
||||
blessing_of_might: ability('blessing_of_might', 'Blessing of Might', 'buff', 'holy', 'spell', 0, 0, 288),
|
||||
bloodthirst: ability('bloodthirst', 'Bloodthirst', 'attack', 'physical', 'melee', 0, 4, 58, { damage: 72 }),
|
||||
charge: ability('charge', 'Charge', 'damage', 'physical', 'melee', 0, 15, 230, { damage: 8 }),
|
||||
claw: ability('claw', 'Claw', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 48 }),
|
||||
cleave: ability('cleave', 'Cleave', 'attack', 'physical', 'melee', 0, 4, 86, { damage: 58 }),
|
||||
concussive_shot: ability('concussive_shot', 'Concussive Shot', 'damage', 'physical', 'ranged', 0, 12, 336, { damage: 5 }),
|
||||
consecration: ability('consecration', 'Consecration', 'damage', 'holy', 'spell', 0, 8, 96, { damage: 44 }),
|
||||
corruption: ability('corruption', 'Corruption', 'damage', 'shadow', 'spell', 0, 0, 288, { damage: 0, dot: { damage: 78, seconds: 18 } }),
|
||||
curse_of_agony: ability('curse_of_agony', 'Curse of Agony', 'damage', 'shadow', 'spell', 0, 0, 288, { damage: 0, dot: { damage: 86, seconds: 24 } }),
|
||||
demon_skin: ability('demon_skin', 'Demon Skin', 'shield', 'shadow', 'spell', 0, 20, 0, { shield: 80 }),
|
||||
demoralizing_roar: ability('demoralizing_roar', 'Demoralizing Roar', 'buff', 'physical', 'melee', 0, 10, 96),
|
||||
divine_protection: ability('divine_protection', 'Divine Protection', 'shield', 'holy', 'spell', 0, 30, 0, { shield: 130 }),
|
||||
drain_life: ability('drain_life', 'Drain Life', 'damage', 'shadow', 'spell', 0, 0, 240, { damage: 52, heal: 35 }),
|
||||
earth_shock: ability('earth_shock', 'Earth Shock', 'damage', 'nature', 'spell', 0, 6, 240, { damage: 62 }),
|
||||
eviscerate: ability('eviscerate', 'Eviscerate', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 104 }),
|
||||
exorcism: ability('exorcism', 'Exorcism', 'damage', 'holy', 'spell', 0, 15, 240, { damage: 70 }),
|
||||
faerie_fire: ability('faerie_fire', 'Faerie Fire', 'damage', 'nature', 'spell', 0, 6, 288, { damage: 18 }),
|
||||
fear: ability('fear', 'Fear', 'buff', 'shadow', 'spell', 0, 20, 240),
|
||||
ferocious_bite: ability('ferocious_bite', 'Ferocious Bite', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 94 }),
|
||||
fire_blast: ability('fire_blast', 'Fire Blast', 'damage', 'fire', 'spell', 0, 8, 240, { damage: 75 }),
|
||||
fireball: ability('fireball', 'Fireball', 'damage', 'fire', 'spell', 3, 0, 288, { damage: 68, dot: { damage: 12, seconds: 8 } }),
|
||||
flame_shock: ability('flame_shock', 'Flame Shock', 'damage', 'fire', 'spell', 0, 6, 240, { damage: 24, dot: { damage: 52, seconds: 12 } }),
|
||||
flash_heal: ability('flash_heal', 'Flash Heal', 'heal', 'holy', 'spell', 1.5, 0, 288, { heal: 131 }),
|
||||
flash_of_light: ability('flash_of_light', 'Flash of Light', 'heal', 'holy', 'spell', 1.5, 0, 288, { heal: 126 }),
|
||||
frost_shock: ability('frost_shock', 'Frost Shock', 'damage', 'frost', 'spell', 0, 6, 240, { damage: 56 }),
|
||||
frost_nova: ability('frost_nova', 'Frost Nova', 'damage', 'frost', 'spell', 0, 22, 110, { damage: 7 }),
|
||||
frostbolt: ability('frostbolt', 'Frostbolt', 'damage', 'frost', 'spell', 2.5, 0, 288, { damage: 70 }),
|
||||
gouge: ability('gouge', 'Gouge', 'attack', 'physical', 'dual', 0, 10, 58, { damage: 26 }),
|
||||
growl: ability('growl', 'Growl', 'buff', 'physical', 'block', 0, 10, 92),
|
||||
heal: ability('heal', 'Heal', 'heal', 'holy', 'spell', 2.5, 0, 288, { heal: 250 }),
|
||||
healing_touch: ability('healing_touch', 'Healing Touch', 'heal', 'nature', 'spell', 2.5, 0, 288, { heal: 248 }),
|
||||
healing_wave: ability('healing_wave', 'Healing Wave', 'heal', 'nature', 'spell', 2.5, 0, 288, { heal: 238 }),
|
||||
heroic_strike: ability('heroic_strike', 'Heroic Strike', 'attack', 'physical', 'melee', 0, 0, 58, { damage: 74 }),
|
||||
holy_light: ability('holy_light', 'Holy Light', 'heal', 'holy', 'spell', 2.5, 0, 288, { heal: 245 }),
|
||||
ice_barrier: ability('ice_barrier', 'Ice Barrier', 'shield', 'frost', 'spell', 0, 20, 0, { shield: 115 }),
|
||||
immolate: ability('immolate', 'Immolate', 'damage', 'fire', 'spell', 2, 0, 288, { damage: 44, dot: { damage: 44, seconds: 12 } }),
|
||||
insect_swarm: ability('insect_swarm', 'Insect Swarm', 'damage', 'nature', 'spell', 0, 0, 288, { damage: 0, dot: { damage: 70, seconds: 12 } }),
|
||||
judgement: ability('judgement', 'Judgement', 'damage', 'holy', 'spell', 0, 8, 240, { damage: 64 }),
|
||||
lay_on_hands: ability('lay_on_hands', 'Lay on Hands', 'heal', 'holy', 'spell', 0, 60, 288, { heal: 360 }),
|
||||
lesser_heal: ability('lesser_heal', 'Lesser Heal', 'heal', 'holy', 'spell', 2, 0, 288, { heal: 121 }),
|
||||
lightning_bolt: ability('lightning_bolt', 'Lightning Bolt', 'damage', 'nature', 'spell', 2.5, 0, 288, { damage: 68 }),
|
||||
lightning_shield: ability('lightning_shield', 'Lightning Shield', 'shield', 'nature', 'spell', 0, 10, 0, { shield: 72 }),
|
||||
maul: ability('maul', 'Maul', 'attack', 'physical', 'melee', 0, 0, 58, { damage: 72 }),
|
||||
mind_blast: ability('mind_blast', 'Mind Blast', 'damage', 'shadow', 'spell', 1.5, 8, 288, { damage: 90 }),
|
||||
mind_flay: ability('mind_flay', 'Mind Flay', 'damage', 'shadow', 'spell', 0, 0, 220, { damage: 36 }),
|
||||
moonfire: ability('moonfire', 'Moonfire', 'damage', 'arcane', 'spell', 0, 0, 288, { damage: 32, dot: { damage: 50, seconds: 12 } }),
|
||||
mortal_strike: ability('mortal_strike', 'Mortal Strike', 'attack', 'physical', 'melee', 0, 6, 58, { damage: 88 }),
|
||||
overpower: ability('overpower', 'Overpower', 'attack', 'physical', 'melee', 0, 5, 58, { damage: 64 }),
|
||||
power_word_fortitude: ability('power_word_fortitude', 'Power Word: Fortitude', 'shield', 'holy', 'spell', 0, 0, 288, { shield: 120 }),
|
||||
power_word_shield: ability('power_word_shield', 'Power Word: Shield', 'shield', 'holy', 'spell', 0, 6, 288, { shield: 145 }),
|
||||
pyroblast: ability('pyroblast', 'Pyroblast', 'damage', 'fire', 'spell', 4, 12, 288, { damage: 105, dot: { damage: 30, seconds: 12 } }),
|
||||
rapid_fire: ability('rapid_fire', 'Rapid Fire', 'buff', 'physical', 'ranged', 0, 300, 0),
|
||||
raptor_strike: ability('raptor_strike', 'Raptor Strike', 'attack', 'physical', 'melee', 0, 0, 58, { damage: 64 }),
|
||||
rake: ability('rake', 'Rake', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 34, dot: { damage: 48, seconds: 9 } }),
|
||||
regrowth: ability('regrowth', 'Regrowth', 'heal', 'nature', 'spell', 2, 0, 288, { heal: 145 }),
|
||||
rejuvenation: ability('rejuvenation', 'Rejuvenation', 'heal', 'nature', 'spell', 0, 0, 288, { heal: 32 }),
|
||||
rend: ability('rend', 'Rend', 'attack', 'physical', 'melee', 0, 0, 58, { damage: 18, dot: { damage: 52, seconds: 15 } }),
|
||||
rip: ability('rip', 'Rip', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 0, dot: { damage: 88, seconds: 12 } }),
|
||||
renew: ability('renew', 'Renew', 'heal', 'holy', 'spell', 0, 0, 288, { heal: 28 }),
|
||||
righteous_fury: ability('righteous_fury', 'Righteous Fury', 'buff', 'holy', 'block', 0, 10, 0),
|
||||
rupture: ability('rupture', 'Rupture', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 0, dot: { damage: 76, seconds: 12 } }),
|
||||
scorch: ability('scorch', 'Scorch', 'damage', 'fire', 'spell', 1.5, 0, 240, { damage: 54 }),
|
||||
searing_pain: ability('searing_pain', 'Searing Pain', 'damage', 'fire', 'spell', 1.5, 0, 240, { damage: 58 }),
|
||||
serpent_sting: ability('serpent_sting', 'Serpent Sting', 'damage', 'nature', 'ranged', 0, 0, 336, { damage: 0, dot: { damage: 55, seconds: 15 } }),
|
||||
seal_of_righteousness: ability('seal_of_righteousness', 'Seal of Righteousness', 'damage', 'holy', 'melee', 0, 0, 58, { damage: 56 }),
|
||||
shadow_word_pain: ability('shadow_word_pain', 'Shadow Word: Pain', 'damage', 'shadow', 'spell', 0, 0, 288, { damage: 0, dot: { damage: 84, seconds: 18 } }),
|
||||
shadow_bolt: ability('shadow_bolt', 'Shadow Bolt', 'damage', 'shadow', 'spell', 2.5, 0, 288, { damage: 76 }),
|
||||
shadowburn: ability('shadowburn', 'Shadowburn', 'damage', 'shadow', 'spell', 0, 15, 240, { damage: 92 }),
|
||||
shield_slam: ability('shield_slam', 'Shield Slam', 'attack', 'physical', 'block', 0, 6, 58, { damage: 66 }),
|
||||
slam: ability('slam', 'Slam', 'attack', 'physical', 'melee', 1.5, 0, 58, { damage: 78 }),
|
||||
sinister_strike: ability('sinister_strike', 'Sinister Strike', 'attack', 'physical', 'dual', 0, 0, 58, { damage: 42 }),
|
||||
smite: ability('smite', 'Smite', 'damage', 'holy', 'spell', 2.5, 0, 288, { damage: 71 }),
|
||||
starfire: ability('starfire', 'Starfire', 'damage', 'arcane', 'spell', 3, 0, 288, { damage: 82 }),
|
||||
stormstrike: ability('stormstrike', 'Stormstrike', 'attack', 'nature', 'melee', 0, 10, 58, { damage: 82 }),
|
||||
sunder_armor: ability('sunder_armor', 'Sunder Armor', 'attack', 'physical', 'melee', 0, 0, 58, { damage: 12 }),
|
||||
swipe: ability('swipe', 'Swipe', 'attack', 'physical', 'melee', 0, 5, 86, { damage: 42 }),
|
||||
taunt: ability('taunt', 'Taunt', 'buff', 'physical', 'block', 0, 10, 92),
|
||||
thunder_clap: ability('thunder_clap', 'Thunder Clap', 'damage', 'physical', 'melee', 0, 4, 96, { damage: 18 }),
|
||||
whirlwind: ability('whirlwind', 'Whirlwind', 'attack', 'physical', 'melee', 0, 8, 86, { damage: 70 }),
|
||||
wing_clip: ability('wing_clip', 'Wing Clip', 'attack', 'physical', 'melee', 0, 6, 58, { damage: 28 }),
|
||||
wrath: ability('wrath', 'Wrath', 'damage', 'nature', 'spell', 2, 0, 288, { damage: 64 }),
|
||||
}
|
||||
|
||||
export const ARENA_CLASS_KITS: Record<ArenaClassId, ArenaClassKit> = {
|
||||
priest: kit('priest', 'Priest', 'holy', 'healer', `${ROOT}/chars/players/mage_classic.glb`, ['smite', 'lesser_heal', 'power_word_fortitude', 'shadow_word_pain', 'power_word_shield', 'renew', 'mind_blast', 'heal', 'mind_flay', 'flash_heal'], 'smite', 1.35, staff('staff')),
|
||||
knight: kit('knight', 'Knight', 'protection', 'tank', `${ROOT}/chars/players/knight.glb`, ['heroic_strike', 'charge', 'thunder_clap', 'sunder_armor', 'taunt'], 'heroic_strike', 1.1, swordShield('sword_1handed', 'shield_round')),
|
||||
ranger: kit('ranger', 'Hunter', 'marksmanship', 'ranged', `${ROOT}/chars/players/ranger.glb`, ['serpent_sting', 'arcane_shot', 'concussive_shot', 'aimed_shot', 'rapid_fire'], 'arcane_shot', 1.05, bow()),
|
||||
mage: kit('mage', 'Mage', 'fire', 'ranged', `${ROOT}/chars/players/mage.glb`, ['fireball', 'scorch', 'fire_blast', 'pyroblast', 'frost_nova'], 'fireball', 1.2, staff('staff')),
|
||||
rogue: kit('rogue', 'Rogue', 'combat', 'melee', `${ROOT}/chars/players/rogue.glb`, ['sinister_strike', 'eviscerate', 'backstab', 'gouge', 'rupture'], 'sinister_strike', 0.82, daggers('dagger', 'dagger_a')),
|
||||
warrior: kit('warrior', 'Warrior', 'protection', 'tank', `${ROOT}/chars/players/barbarian.glb`, ['charge', 'thunder_clap', 'sunder_armor', 'shield_slam', 'taunt'], 'heroic_strike', 1.02, swordShield('axe_1handed', 'shield_square')),
|
||||
paladin: kit('paladin', 'Paladin', 'protection', 'tank', `${ROOT}/chars/players/paladin.glb`, ['judgement', 'consecration', 'divine_protection', 'righteous_fury', 'taunt'], 'judgement', 1.18, swordShield('hammer_b', 'shield_badge')),
|
||||
druid: kit('druid', 'Druid', 'restoration', 'healer', `${ROOT}/chars/players/druid.glb`, ['rejuvenation', 'regrowth', 'healing_touch', 'barkskin', 'wrath'], 'wrath', 1.3, staff('adv_druid_staff')),
|
||||
shaman: kit('shaman', 'Shaman', 'restoration', 'healer', `${ROOT}/chars/players/barbarian.glb`, ['healing_wave', 'lightning_shield', 'frost_shock', 'flame_shock', 'lightning_bolt'], 'lightning_bolt', 1.25, oneHand('hammer_a')),
|
||||
|
||||
priest_holy: kit('priest_holy', 'Priest', 'holy', 'healer', `${ROOT}/chars/players/mage_classic.glb`, ['lesser_heal', 'renew', 'heal', 'flash_heal', 'smite'], 'smite', 1.35, staff('staff')),
|
||||
priest_discipline: kit('priest_discipline', 'Priest', 'discipline', 'healer', `${ROOT}/chars/players/mage_classic.glb`, ['power_word_shield', 'power_word_fortitude', 'lesser_heal', 'renew', 'smite'], 'smite', 1.3, staff('staff_b')),
|
||||
priest_shadow: kit('priest_shadow', 'Priest', 'shadow', 'ranged', `${ROOT}/chars/players/mage_classic.glb`, ['shadow_word_pain', 'mind_blast', 'mind_flay', 'smite', 'power_word_shield'], 'mind_blast', 1.2, staff('scythe')),
|
||||
|
||||
warrior_protection: kit('warrior_protection', 'Warrior', 'protection', 'tank', `${ROOT}/chars/players/barbarian.glb`, ['charge', 'thunder_clap', 'sunder_armor', 'shield_slam', 'taunt'], 'heroic_strike', 1.02, swordShield('axe_1handed', 'shield_square')),
|
||||
warrior_arms: kit('warrior_arms', 'Warrior', 'arms', 'melee', `${ROOT}/chars/players/barbarian.glb`, ['charge', 'mortal_strike', 'rend', 'overpower', 'slam'], 'mortal_strike', 1.12, twoHand('sword_2handed')),
|
||||
warrior_fury: kit('warrior_fury', 'Warrior', 'fury', 'melee', `${ROOT}/chars/players/barbarian.glb`, ['charge', 'bloodthirst', 'whirlwind', 'heroic_strike', 'cleave'], 'bloodthirst', 0.86, daggers('axe_1handed', 'axe_b')),
|
||||
|
||||
paladin_protection: kit('paladin_protection', 'Paladin', 'protection', 'tank', `${ROOT}/chars/players/paladin.glb`, ['judgement', 'consecration', 'divine_protection', 'righteous_fury', 'taunt'], 'judgement', 1.18, swordShield('hammer_b', 'shield_badge')),
|
||||
paladin_holy: kit('paladin_holy', 'Paladin', 'holy', 'healer', `${ROOT}/chars/players/paladin.glb`, ['holy_light', 'flash_of_light', 'lay_on_hands', 'blessing_of_might', 'judgement'], 'judgement', 1.28, oneHand('hammer_a')),
|
||||
paladin_retribution: kit('paladin_retribution', 'Paladin', 'retribution', 'melee', `${ROOT}/chars/players/paladin.glb`, ['seal_of_righteousness', 'judgement', 'exorcism', 'consecration', 'blessing_of_might'], 'judgement', 1.05, twoHand('hammer_d')),
|
||||
|
||||
druid_restoration: kit('druid_restoration', 'Druid', 'restoration', 'healer', `${ROOT}/chars/players/druid.glb`, ['rejuvenation', 'regrowth', 'healing_touch', 'barkskin', 'wrath'], 'wrath', 1.3, staff('adv_druid_staff')),
|
||||
druid_feral_tank: kit('druid_feral_tank', 'Druid', 'feral tank', 'tank', `${ROOT}/chars/players/druid.glb`, ['bear_charge', 'maul', 'swipe', 'demoralizing_roar', 'growl'], 'maul', 1.05, twoHand('staff_c')),
|
||||
druid_feral_cat: kit('druid_feral_cat', 'Druid', 'feral cat', 'melee', `${ROOT}/chars/players/druid.glb`, ['claw', 'rake', 'rip', 'ferocious_bite', 'faerie_fire'], 'claw', 0.84, daggers('dagger_b', 'dagger_c')),
|
||||
druid_balance: kit('druid_balance', 'Druid', 'balance', 'ranged', `${ROOT}/chars/players/druid.glb`, ['wrath', 'moonfire', 'starfire', 'insect_swarm', 'faerie_fire'], 'wrath', 1.18, staff('staff_d')),
|
||||
|
||||
mage_arcane: kit('mage_arcane', 'Mage', 'arcane', 'ranged', `${ROOT}/chars/players/mage.glb`, ['arcane_missiles', 'arcane_explosion', 'arcane_intellect', 'frost_nova', 'fire_blast'], 'arcane_missiles', 1.12, staff('staff')),
|
||||
mage_fire: kit('mage_fire', 'Mage', 'fire', 'ranged', `${ROOT}/chars/players/mage.glb`, ['fireball', 'scorch', 'fire_blast', 'pyroblast', 'frost_nova'], 'fireball', 1.2, staff('staff_a')),
|
||||
mage_frost: kit('mage_frost', 'Mage', 'frost', 'ranged', `${ROOT}/chars/players/mage.glb`, ['frostbolt', 'frost_nova', 'ice_barrier', 'arcane_missiles', 'fire_blast'], 'frostbolt', 1.2, staff('staff_b')),
|
||||
|
||||
rogue_assassination: kit('rogue_assassination', 'Rogue', 'assassination', 'melee', `${ROOT}/chars/players/rogue_hooded.glb`, ['ambush', 'backstab', 'rupture', 'eviscerate', 'sinister_strike'], 'backstab', 0.78, daggers('adv_dagger', 'dagger_c')),
|
||||
rogue_combat: kit('rogue_combat', 'Rogue', 'combat', 'melee', `${ROOT}/chars/players/rogue.glb`, ['sinister_strike', 'eviscerate', 'backstab', 'gouge', 'rupture'], 'sinister_strike', 0.82, daggers('dagger', 'dagger_a')),
|
||||
rogue_subtlety: kit('rogue_subtlety', 'Rogue', 'subtlety', 'melee', `${ROOT}/chars/players/rogue_hooded.glb`, ['ambush', 'gouge', 'backstab', 'eviscerate', 'rupture'], 'ambush', 0.8, daggers('dagger_b', 'dagger_a')),
|
||||
|
||||
hunter_beast_mastery: kit('hunter_beast_mastery', 'Hunter', 'beast mastery', 'ranged', `${ROOT}/chars/players/ranger.glb`, ['aspect_of_the_hawk', 'serpent_sting', 'arcane_shot', 'raptor_strike', 'rapid_fire'], 'arcane_shot', 1.05, bow()),
|
||||
hunter_marksmanship: kit('hunter_marksmanship', 'Hunter', 'marksmanship', 'ranged', `${ROOT}/chars/players/ranger.glb`, ['serpent_sting', 'arcane_shot', 'concussive_shot', 'aimed_shot', 'rapid_fire'], 'arcane_shot', 1.05, bow()),
|
||||
hunter_survival: kit('hunter_survival', 'Hunter', 'survival', 'ranged', `${ROOT}/chars/players/ranger.glb`, ['serpent_sting', 'wing_clip', 'raptor_strike', 'concussive_shot', 'arcane_shot'], 'arcane_shot', 0.98, bow()),
|
||||
|
||||
shaman_restoration: kit('shaman_restoration', 'Shaman', 'restoration', 'healer', `${ROOT}/chars/players/barbarian.glb`, ['healing_wave', 'lightning_shield', 'frost_shock', 'flame_shock', 'lightning_bolt'], 'lightning_bolt', 1.25, oneHand('hammer_a')),
|
||||
shaman_elemental: kit('shaman_elemental', 'Shaman', 'elemental', 'ranged', `${ROOT}/chars/players/barbarian.glb`, ['lightning_bolt', 'earth_shock', 'flame_shock', 'frost_shock', 'lightning_shield'], 'lightning_bolt', 1.15, oneHand('hammer_c')),
|
||||
shaman_enhancement: kit('shaman_enhancement', 'Shaman', 'enhancement', 'melee', `${ROOT}/chars/players/barbarian.glb`, ['stormstrike', 'earth_shock', 'flame_shock', 'frost_shock', 'lightning_shield'], 'stormstrike', 0.98, twoHand('hammer_d')),
|
||||
|
||||
warlock_affliction: kit('warlock_affliction', 'Warlock', 'affliction', 'ranged', `${ROOT}/chars/players/mage_classic.glb`, ['corruption', 'curse_of_agony', 'drain_life', 'shadow_bolt', 'fear'], 'shadow_bolt', 1.25, staff('scythe')),
|
||||
warlock_demonology: kit('warlock_demonology', 'Warlock', 'demonology', 'ranged', `${ROOT}/chars/players/mage_classic.glb`, ['demon_skin', 'shadow_bolt', 'corruption', 'drain_life', 'immolate'], 'shadow_bolt', 1.22, staff('adv_staff')),
|
||||
warlock_destruction: kit('warlock_destruction', 'Warlock', 'destruction', 'ranged', `${ROOT}/chars/players/mage_classic.glb`, ['shadow_bolt', 'immolate', 'searing_pain', 'shadowburn', 'corruption'], 'shadow_bolt', 1.18, staff('staff_d')),
|
||||
}
|
||||
|
||||
function kit(
|
||||
id: ArenaClassId,
|
||||
displayName: string,
|
||||
defaultPvpSpec: string,
|
||||
role: PartyRole,
|
||||
model: string,
|
||||
actionBar: ArenaAbilityId[],
|
||||
basicAttack: ArenaAbilityId,
|
||||
attackSeconds: number,
|
||||
weapons: ArenaClassKit['weapons'],
|
||||
): ArenaClassKit {
|
||||
return { actionBar, attackSeconds, basicAttack, defaultPvpSpec, displayName, id, model, role, weapons }
|
||||
}
|
||||
|
||||
function staff(model: string): ArenaClassKit['weapons'] {
|
||||
return [{ hand: 'handslot.r', model: `${ROOT}/weapons/${model}.glb` }]
|
||||
}
|
||||
|
||||
function oneHand(model: string): ArenaClassKit['weapons'] {
|
||||
return [{ hand: 'handslot.r', model: `${ROOT}/weapons/${model}.glb` }]
|
||||
}
|
||||
|
||||
function twoHand(model: string): ArenaClassKit['weapons'] {
|
||||
return [{ hand: 'handslot.r', model: `${ROOT}/weapons/${model}.glb` }]
|
||||
}
|
||||
|
||||
function daggers(right: string, left: string): ArenaClassKit['weapons'] {
|
||||
return [
|
||||
{ hand: 'handslot.r', model: `${ROOT}/weapons/${right}.glb` },
|
||||
{ hand: 'handslot.l', model: `${ROOT}/weapons/${left}.glb` },
|
||||
]
|
||||
}
|
||||
|
||||
function swordShield(right: string, left: string): ArenaClassKit['weapons'] {
|
||||
return [
|
||||
{ hand: 'handslot.r', model: `${ROOT}/weapons/${right}.glb` },
|
||||
{ hand: 'handslot.l', model: `${ROOT}/weapons/${left}.glb` },
|
||||
]
|
||||
}
|
||||
|
||||
function bow(): ArenaClassKit['weapons'] {
|
||||
return [
|
||||
{ hand: 'handslot.r', model: `${ROOT}/weapons/crossbow_1handed.glb` },
|
||||
{ hand: 'handslot.l', model: `${ROOT}/weapons/quiver.glb` },
|
||||
]
|
||||
}
|
||||
|
||||
export function createArenaAbilityCooldowns(classId: ArenaClassId) {
|
||||
return Object.fromEntries(ARENA_CLASS_KITS[classId].actionBar.map((id) => [id, 0])) as Record<string, number>
|
||||
}
|
||||
|
||||
export function getArenaClassKit(classId: ArenaClassId) {
|
||||
return ARENA_CLASS_KITS[classId]
|
||||
}
|
||||
|
||||
export function getArenaAbility(abilityId: ArenaAbilityId) {
|
||||
return ARENA_ABILITIES[abilityId]
|
||||
}
|
||||
|
||||
function ability(
|
||||
id: ArenaAbilityId,
|
||||
name: string,
|
||||
kind: ArenaAbilityKind,
|
||||
school: ArenaAbilitySchool,
|
||||
animation: ArenaAbilityDefinition['animation'],
|
||||
castTime: number,
|
||||
cooldown: number,
|
||||
range: number,
|
||||
effects: Partial<Pick<ArenaAbilityDefinition, 'damage' | 'dot' | 'heal' | 'shield'>> = {},
|
||||
): ArenaAbilityDefinition {
|
||||
return { animation, castTime, cooldown, id, kind, name, range, school, ...effects }
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
import { getEmptyClaudeCraftAbilityModifier, type TalentModifiers } from './claudeCraftTalents'
|
||||
import { getArenaAbility, getArenaClassVitals, type ArenaClassId } from './actionClassKits'
|
||||
|
||||
export type PartyRole = 'healer' | 'tank' | 'melee' | 'ranged'
|
||||
export type SpellSlot = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||
|
||||
export type SpellDefinition = {
|
||||
slot: SpellSlot
|
||||
abilityId: ClaudeCraftHealerAbilityId
|
||||
name: string
|
||||
cooldown: number
|
||||
castTime: number
|
||||
}
|
||||
|
||||
export type ActionHealerClassId = 'priest' | 'paladin' | 'shaman' | 'druid'
|
||||
|
||||
export type ClaudeCraftHealerAbilityId =
|
||||
| 'smite'
|
||||
| 'lesser_heal'
|
||||
| 'power_word_fortitude'
|
||||
| 'shadow_word_pain'
|
||||
| 'power_word_shield'
|
||||
| 'renew'
|
||||
| 'mind_blast'
|
||||
| 'heal'
|
||||
| 'mind_flay'
|
||||
| 'flash_heal'
|
||||
| 'judgement'
|
||||
| 'holy_light'
|
||||
| 'flash_of_light'
|
||||
| 'blessing_of_might'
|
||||
| 'consecration'
|
||||
| 'divine_protection'
|
||||
| 'exorcism'
|
||||
| 'lay_on_hands'
|
||||
| 'seal_of_righteousness'
|
||||
| 'lightning_bolt'
|
||||
| 'healing_wave'
|
||||
| 'lightning_shield'
|
||||
| 'earth_shock'
|
||||
| 'flame_shock'
|
||||
| 'frost_shock'
|
||||
| 'healing_touch'
|
||||
| 'rejuvenation'
|
||||
| 'regrowth'
|
||||
| 'barkskin'
|
||||
| 'wrath'
|
||||
| 'moonfire'
|
||||
| 'insect_swarm'
|
||||
| 'starfire'
|
||||
| 'faerie_fire'
|
||||
|
||||
export type ClaudeCraftPriestAbilityId = Extract<
|
||||
ClaudeCraftHealerAbilityId,
|
||||
| 'smite'
|
||||
| 'lesser_heal'
|
||||
| 'power_word_fortitude'
|
||||
| 'shadow_word_pain'
|
||||
| 'power_word_shield'
|
||||
| 'renew'
|
||||
| 'mind_blast'
|
||||
| 'heal'
|
||||
| 'mind_flay'
|
||||
| 'flash_heal'
|
||||
>
|
||||
|
||||
export type ActionSpellTarget = 'Enemy target' | 'Friendly target'
|
||||
export type ActionSpellSchool = 'Holy' | 'Shadow' | 'Nature' | 'Fire' | 'Frost' | 'Arcane' | 'Physical'
|
||||
|
||||
export type ActionSpellbook = {
|
||||
classId: ActionHealerClassId
|
||||
iconFolder: string
|
||||
spells: Record<SpellSlot, SpellDefinition>
|
||||
manaCosts: Record<SpellSlot, number>
|
||||
ranks: Record<SpellSlot, number>
|
||||
schools: Record<SpellSlot, ActionSpellSchool>
|
||||
targets: Record<SpellSlot, ActionSpellTarget>
|
||||
descriptions: Record<SpellSlot, string>
|
||||
bar: SpellSlot[]
|
||||
}
|
||||
|
||||
export type CastState = {
|
||||
spell: SpellSlot
|
||||
targetId: string
|
||||
remaining: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type HealOverTimeEffectId = 'renew' | 'rejuvenation' | 'regrowth'
|
||||
|
||||
export type HealOverTimeEffect = {
|
||||
id: HealOverTimeEffectId
|
||||
label: string
|
||||
remaining: number
|
||||
tickHeal: number
|
||||
tickSeconds: number
|
||||
tickTimer: number
|
||||
}
|
||||
|
||||
export type HealOverTimeFrame = Pick<HealOverTimeEffect, 'id' | 'label' | 'remaining'>
|
||||
|
||||
export type SpellResourceState = {
|
||||
mana: number
|
||||
maxMana: number
|
||||
storedMomentumCasts: number
|
||||
storedMomentumReady: boolean
|
||||
}
|
||||
|
||||
export type ActionRangePoint = {
|
||||
x: number
|
||||
y: number
|
||||
radius?: number
|
||||
}
|
||||
|
||||
export type ActionSpellReachBlocker = 'range' | 'lineOfSight'
|
||||
|
||||
export type ActionSpellReachResult = {
|
||||
distance: number
|
||||
effectiveRange: number
|
||||
ok: true
|
||||
} | {
|
||||
distance: number
|
||||
effectiveRange: number
|
||||
ok: false
|
||||
reason: ActionSpellReachBlocker
|
||||
}
|
||||
|
||||
export type ShieldedHealthState = {
|
||||
hp: number
|
||||
maxHp: number
|
||||
shield: number
|
||||
invulnerableTimer?: number
|
||||
}
|
||||
|
||||
export type RenewState = {
|
||||
healOverTimes?: HealOverTimeEffect[]
|
||||
renewTimer: number
|
||||
renewTickTimer: number
|
||||
}
|
||||
|
||||
export const ACTION_SQUARE_SIZE = 48
|
||||
export const HEAL_RANGE = ACTION_SQUARE_SIZE * 6
|
||||
export const RANGED_ATTACK_RANGE = ACTION_SQUARE_SIZE * 6
|
||||
|
||||
export const ACTION_MOVEMENT_RULES = {
|
||||
baseSpeed: 245,
|
||||
} as const
|
||||
|
||||
export const ACTION_CONTROL_RULES = {
|
||||
stunSeconds: 0.92,
|
||||
invulnerableSeconds: 0.7,
|
||||
} as const
|
||||
|
||||
export const HEALING_SPELL_RULES = {
|
||||
mendHeal: 34,
|
||||
renewSeconds: 15,
|
||||
renewTickSeconds: 3,
|
||||
renewHeal: 28,
|
||||
radianceHeal: 250,
|
||||
sunWardShield: 145,
|
||||
purifyHeal: 131,
|
||||
dungeonShieldCap: 60,
|
||||
arenaShieldCapRatio: 0.72,
|
||||
} as const
|
||||
|
||||
export const SPELLS: Record<SpellSlot, SpellDefinition> = {
|
||||
1: { slot: 1, abilityId: 'smite', name: 'Smite', cooldown: 0, castTime: 2.5 },
|
||||
2: { slot: 2, abilityId: 'lesser_heal', name: 'Lesser Heal', cooldown: 0, castTime: 2 },
|
||||
3: { slot: 3, abilityId: 'power_word_fortitude', name: 'Power Word: Fortitude', cooldown: 0, castTime: 0 },
|
||||
4: { slot: 4, abilityId: 'shadow_word_pain', name: 'Shadow Word: Pain', cooldown: 0, castTime: 0 },
|
||||
5: { slot: 5, abilityId: 'power_word_shield', name: 'Power Word: Shield', cooldown: 6, castTime: 0 },
|
||||
6: { slot: 6, abilityId: 'renew', name: 'Renew', cooldown: 0, castTime: 0 },
|
||||
7: { slot: 7, abilityId: 'mind_blast', name: 'Mind Blast', cooldown: 8, castTime: 1.5 },
|
||||
8: { slot: 8, abilityId: 'heal', name: 'Heal', cooldown: 0, castTime: 2.5 },
|
||||
9: { slot: 9, abilityId: 'mind_flay', name: 'Mind Flay', cooldown: 0, castTime: 0 },
|
||||
10: { slot: 10, abilityId: 'flash_heal', name: 'Flash Heal', cooldown: 0, castTime: 1.5 },
|
||||
}
|
||||
|
||||
export const SPELL_MANA_COSTS: Record<SpellSlot, number> = {
|
||||
1: 70,
|
||||
2: 65,
|
||||
3: 80,
|
||||
4: 55,
|
||||
5: 100,
|
||||
6: 75,
|
||||
7: 95,
|
||||
8: 130,
|
||||
9: 45,
|
||||
10: 75,
|
||||
}
|
||||
|
||||
const ALL_SPELL_SLOTS: SpellSlot[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
export const CLAUDECRAFT_PRIEST_ACTION_BAR: SpellSlot[] = ALL_SPELL_SLOTS
|
||||
export const DUNGEON_PRIEST_SPELL_BAR: SpellSlot[] = CLAUDECRAFT_PRIEST_ACTION_BAR
|
||||
export const ARENA_PRIEST_SPELL_BAR: SpellSlot[] = CLAUDECRAFT_PRIEST_ACTION_BAR
|
||||
export const SPELL_GLOBAL_COOLDOWN_SECONDS = 1.5
|
||||
export const SPELL_RESOURCE_MAX = 900
|
||||
export const SPELL_RESOURCE_REGEN = 10
|
||||
|
||||
export const PRIEST_SPELL_ICONS: Record<SpellSlot, ClaudeCraftPriestAbilityId> = {
|
||||
1: 'smite',
|
||||
2: 'lesser_heal',
|
||||
3: 'power_word_fortitude',
|
||||
4: 'shadow_word_pain',
|
||||
5: 'power_word_shield',
|
||||
6: 'renew',
|
||||
7: 'mind_blast',
|
||||
8: 'heal',
|
||||
9: 'mind_flay',
|
||||
10: 'flash_heal',
|
||||
}
|
||||
|
||||
export const PRIEST_SPELL_RANKS: Record<SpellSlot, number> = {
|
||||
1: 4,
|
||||
2: 3,
|
||||
3: 3,
|
||||
4: 3,
|
||||
5: 3,
|
||||
6: 3,
|
||||
7: 3,
|
||||
8: 2,
|
||||
9: 1,
|
||||
10: 1,
|
||||
}
|
||||
|
||||
export const PRIEST_SPELL_SCHOOLS: Record<SpellSlot, 'Holy' | 'Shadow'> = {
|
||||
1: 'Holy',
|
||||
2: 'Holy',
|
||||
3: 'Holy',
|
||||
4: 'Shadow',
|
||||
5: 'Holy',
|
||||
6: 'Holy',
|
||||
7: 'Shadow',
|
||||
8: 'Holy',
|
||||
9: 'Shadow',
|
||||
10: 'Holy',
|
||||
}
|
||||
|
||||
export const PRIEST_SPELL_TARGETS: Record<SpellSlot, 'Enemy target' | 'Friendly target'> = {
|
||||
1: 'Enemy target',
|
||||
2: 'Friendly target',
|
||||
3: 'Friendly target',
|
||||
4: 'Enemy target',
|
||||
5: 'Friendly target',
|
||||
6: 'Friendly target',
|
||||
7: 'Enemy target',
|
||||
8: 'Friendly target',
|
||||
9: 'Enemy target',
|
||||
10: 'Friendly target',
|
||||
}
|
||||
|
||||
export const PRIEST_SPELL_DESCRIPTIONS: Record<SpellSlot, string> = {
|
||||
1: 'Smites the enemy for 64 to 78 Holy damage.',
|
||||
2: 'Heals a friendly target for 110 to 132.',
|
||||
3: 'Increases the target Stamina by 12 for 30 min.',
|
||||
4: 'A word of darkness causes 84 Shadow damage over 18 sec.',
|
||||
5: 'Shields the target, absorbing 145 damage for 30 sec.',
|
||||
6: 'Heals the target for 140 over 15 sec.',
|
||||
7: 'Blasts the target mind for 86 to 94 Shadow damage.',
|
||||
8: 'A slow but powerful prayer that heals a friendly target for 230 to 270.',
|
||||
9: 'Assaults the target mind, causing 12 Shadow damage each second for 3 sec.',
|
||||
10: 'A fast prayer that heals a friendly target for 120 to 142.',
|
||||
}
|
||||
|
||||
export const ACTION_HEALER_SPELLBOOKS: Record<ActionHealerClassId, ActionSpellbook> = {
|
||||
priest: createSpellbook('priest', 'priest', SPELLS, SPELL_MANA_COSTS, PRIEST_SPELL_RANKS, PRIEST_SPELL_SCHOOLS, PRIEST_SPELL_TARGETS, PRIEST_SPELL_DESCRIPTIONS),
|
||||
paladin: createSpellbook(
|
||||
'paladin',
|
||||
'paladin',
|
||||
{
|
||||
1: spell(1, 'judgement', 'Judgement', 8, 0),
|
||||
2: spell(2, 'holy_light', 'Holy Light', 0, 2.5),
|
||||
3: spell(3, 'flash_of_light', 'Flash of Light', 0, 1.5),
|
||||
4: spell(4, 'blessing_of_might', 'Blessing of Might', 0, 0),
|
||||
5: spell(5, 'consecration', 'Consecration', 8, 0),
|
||||
6: spell(6, 'divine_protection', 'Divine Protection', 30, 0),
|
||||
7: spell(7, 'exorcism', 'Exorcism', 15, 0),
|
||||
8: spell(8, 'lay_on_hands', 'Lay on Hands', 60, 0),
|
||||
9: spell(9, 'seal_of_righteousness', 'Seal of Righteousness', 0, 0),
|
||||
10: spell(10, 'flash_of_light', 'Flash of Light', 0, 1.5),
|
||||
},
|
||||
costs(70, 130, 75, 80, 105, 100, 95, 220, 45, 75),
|
||||
ranks(4, 3, 3, 3, 3, 2, 2, 1, 2, 3),
|
||||
schools('Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy', 'Holy'),
|
||||
targets('Enemy target', 'Friendly target', 'Friendly target', 'Friendly target', 'Enemy target', 'Friendly target', 'Enemy target', 'Friendly target', 'Enemy target', 'Friendly target'),
|
||||
descriptions(
|
||||
'Judges the enemy for Holy damage.',
|
||||
'Heals a friendly target with a large burst of Holy light.',
|
||||
'A fast, efficient Holy heal.',
|
||||
'Blesses an ally, represented here as a small protective ward.',
|
||||
'Consecrates nearby ground under the enemy for Holy damage.',
|
||||
'Wraps the target in divine protection.',
|
||||
'Strikes an enemy with Holy force.',
|
||||
'A long-cooldown emergency heal.',
|
||||
'Seals your weapon with Holy damage against the enemy.',
|
||||
'A fast, efficient Holy heal.',
|
||||
),
|
||||
),
|
||||
shaman: createSpellbook(
|
||||
'shaman',
|
||||
'shaman',
|
||||
{
|
||||
1: spell(1, 'lightning_bolt', 'Lightning Bolt', 0, 2.5),
|
||||
2: spell(2, 'healing_wave', 'Healing Wave', 0, 2.5),
|
||||
3: spell(3, 'lightning_shield', 'Lightning Shield', 10, 0),
|
||||
4: spell(4, 'earth_shock', 'Earth Shock', 6, 0),
|
||||
5: spell(5, 'flame_shock', 'Flame Shock', 6, 0),
|
||||
6: spell(6, 'frost_shock', 'Frost Shock', 6, 0),
|
||||
7: spell(7, 'healing_wave', 'Healing Wave', 0, 2.5),
|
||||
8: spell(8, 'lightning_shield', 'Lightning Shield', 10, 0),
|
||||
9: spell(9, 'lightning_bolt', 'Lightning Bolt', 0, 2.5),
|
||||
10: spell(10, 'healing_wave', 'Healing Wave', 0, 2.5),
|
||||
},
|
||||
costs(70, 125, 90, 75, 80, 80, 125, 90, 70, 125),
|
||||
ranks(4, 3, 3, 3, 3, 3, 3, 3, 4, 3),
|
||||
schools('Nature', 'Nature', 'Nature', 'Nature', 'Fire', 'Frost', 'Nature', 'Nature', 'Nature', 'Nature'),
|
||||
targets('Enemy target', 'Friendly target', 'Friendly target', 'Enemy target', 'Enemy target', 'Enemy target', 'Friendly target', 'Friendly target', 'Enemy target', 'Friendly target'),
|
||||
descriptions(
|
||||
'Hurls lightning at the enemy.',
|
||||
'Heals a friendly target with a steady wave.',
|
||||
'Surrounds an ally with a lightning shield.',
|
||||
'Shocks an enemy with earth power.',
|
||||
'Burns an enemy with flame shock damage over time.',
|
||||
'Shocks an enemy with frost.',
|
||||
'Heals a friendly target with a steady wave.',
|
||||
'Surrounds an ally with a lightning shield.',
|
||||
'Hurls lightning at the enemy.',
|
||||
'Heals a friendly target with a steady wave.',
|
||||
),
|
||||
),
|
||||
druid: createSpellbook(
|
||||
'druid',
|
||||
'druid',
|
||||
{
|
||||
1: spell(1, 'wrath', 'Wrath', 0, 2),
|
||||
2: spell(2, 'rejuvenation', 'Rejuvenation', 0, 0),
|
||||
3: spell(3, 'regrowth', 'Regrowth', 0, 2),
|
||||
4: spell(4, 'healing_touch', 'Healing Touch', 0, 2.5),
|
||||
5: spell(5, 'barkskin', 'Barkskin', 12, 0),
|
||||
6: spell(6, 'moonfire', 'Moonfire', 0, 0),
|
||||
7: spell(7, 'insect_swarm', 'Insect Swarm', 0, 0),
|
||||
8: spell(8, 'starfire', 'Starfire', 0, 3),
|
||||
9: spell(9, 'faerie_fire', 'Faerie Fire', 6, 0),
|
||||
10: spell(10, 'regrowth', 'Regrowth', 0, 2),
|
||||
},
|
||||
costs(70, 75, 105, 130, 100, 55, 65, 120, 50, 105),
|
||||
ranks(4, 3, 3, 3, 2, 3, 2, 2, 2, 3),
|
||||
schools('Nature', 'Nature', 'Nature', 'Nature', 'Nature', 'Arcane', 'Nature', 'Arcane', 'Nature', 'Nature'),
|
||||
targets('Enemy target', 'Friendly target', 'Friendly target', 'Friendly target', 'Friendly target', 'Enemy target', 'Enemy target', 'Enemy target', 'Enemy target', 'Friendly target'),
|
||||
descriptions(
|
||||
'Calls nature damage down on the enemy.',
|
||||
'Heals the target over time.',
|
||||
'Heals immediately and leaves a rejuvenating bloom.',
|
||||
'A slow, powerful nature heal.',
|
||||
'Protects the target with barkskin.',
|
||||
'Burns the enemy with arcane lunar damage over time.',
|
||||
'Sends insects to wear down the enemy.',
|
||||
'A slow, heavy arcane strike.',
|
||||
'Weakens the enemy with faerie fire.',
|
||||
'Heals immediately and leaves a rejuvenating bloom.',
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
function spell(slot: SpellSlot, abilityId: ClaudeCraftHealerAbilityId, name: string, cooldown: number, castTime: number): SpellDefinition {
|
||||
return { abilityId, castTime, cooldown, name, slot }
|
||||
}
|
||||
|
||||
function createSpellbook(
|
||||
classId: ActionHealerClassId,
|
||||
iconFolder: string,
|
||||
spells: Record<SpellSlot, SpellDefinition>,
|
||||
manaCosts: Record<SpellSlot, number>,
|
||||
ranks: Record<SpellSlot, number>,
|
||||
schools: Record<SpellSlot, ActionSpellSchool>,
|
||||
targets: Record<SpellSlot, ActionSpellTarget>,
|
||||
descriptions: Record<SpellSlot, string>,
|
||||
): ActionSpellbook {
|
||||
return {
|
||||
bar: ALL_SPELL_SLOTS,
|
||||
classId,
|
||||
descriptions,
|
||||
iconFolder,
|
||||
manaCosts,
|
||||
ranks,
|
||||
schools,
|
||||
spells,
|
||||
targets,
|
||||
}
|
||||
}
|
||||
|
||||
function costs(
|
||||
one: number,
|
||||
two: number,
|
||||
three: number,
|
||||
four: number,
|
||||
five: number,
|
||||
six: number,
|
||||
seven: number,
|
||||
eight: number,
|
||||
nine: number,
|
||||
ten: number,
|
||||
): Record<SpellSlot, number> {
|
||||
return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten }
|
||||
}
|
||||
|
||||
function ranks(
|
||||
one: number,
|
||||
two: number,
|
||||
three: number,
|
||||
four: number,
|
||||
five: number,
|
||||
six: number,
|
||||
seven: number,
|
||||
eight: number,
|
||||
nine: number,
|
||||
ten: number,
|
||||
): Record<SpellSlot, number> {
|
||||
return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten }
|
||||
}
|
||||
|
||||
function schools(
|
||||
one: ActionSpellSchool,
|
||||
two: ActionSpellSchool,
|
||||
three: ActionSpellSchool,
|
||||
four: ActionSpellSchool,
|
||||
five: ActionSpellSchool,
|
||||
six: ActionSpellSchool,
|
||||
seven: ActionSpellSchool,
|
||||
eight: ActionSpellSchool,
|
||||
nine: ActionSpellSchool,
|
||||
ten: ActionSpellSchool,
|
||||
): Record<SpellSlot, ActionSpellSchool> {
|
||||
return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten }
|
||||
}
|
||||
|
||||
function targets(
|
||||
one: ActionSpellTarget,
|
||||
two: ActionSpellTarget,
|
||||
three: ActionSpellTarget,
|
||||
four: ActionSpellTarget,
|
||||
five: ActionSpellTarget,
|
||||
six: ActionSpellTarget,
|
||||
seven: ActionSpellTarget,
|
||||
eight: ActionSpellTarget,
|
||||
nine: ActionSpellTarget,
|
||||
ten: ActionSpellTarget,
|
||||
): Record<SpellSlot, ActionSpellTarget> {
|
||||
return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten }
|
||||
}
|
||||
|
||||
function descriptions(
|
||||
one: string,
|
||||
two: string,
|
||||
three: string,
|
||||
four: string,
|
||||
five: string,
|
||||
six: string,
|
||||
seven: string,
|
||||
eight: string,
|
||||
nine: string,
|
||||
ten: string,
|
||||
): Record<SpellSlot, string> {
|
||||
return { 1: one, 2: two, 3: three, 4: four, 5: five, 6: six, 7: seven, 8: eight, 9: nine, 10: ten }
|
||||
}
|
||||
|
||||
function normalizeHealerClassId(classId: string | undefined): ActionHealerClassId {
|
||||
if (classId === 'paladin' || classId === 'shaman' || classId === 'druid') return classId
|
||||
return 'priest'
|
||||
}
|
||||
|
||||
export function createSpellCooldowns(): Record<SpellSlot, number> {
|
||||
return {
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
4: 0,
|
||||
5: 0,
|
||||
6: 0,
|
||||
7: 0,
|
||||
8: 0,
|
||||
9: 0,
|
||||
10: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpellResourceState(classId?: string): SpellResourceState {
|
||||
const vitals = classId ? getArenaClassVitals(classId as ArenaClassId) : null
|
||||
const maxMana = vitals?.maxMana ?? SPELL_RESOURCE_MAX
|
||||
return {
|
||||
mana: maxMana,
|
||||
maxMana,
|
||||
storedMomentumCasts: 0,
|
||||
storedMomentumReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function tickSpellCooldowns(cooldowns: Record<SpellSlot, number>, deltaSeconds: number) {
|
||||
for (const slot of ALL_SPELL_SLOTS) {
|
||||
cooldowns[slot] = Math.max(0, cooldowns[slot] - deltaSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
export function tickSpellResource(resource: SpellResourceState, deltaSeconds: number) {
|
||||
resource.mana = Math.min(resource.maxMana, resource.mana + SPELL_RESOURCE_REGEN * deltaSeconds)
|
||||
}
|
||||
|
||||
export function getSpellManaCost(spell: SpellSlot, options?: { costMultiplier?: number, freeCast?: boolean }) {
|
||||
if (options?.freeCast) return 0
|
||||
return Math.max(1, Math.round(SPELL_MANA_COSTS[spell] * (options?.costMultiplier ?? 1)))
|
||||
}
|
||||
|
||||
export function getActionSpellbook(classId: string | undefined): ActionSpellbook {
|
||||
return ACTION_HEALER_SPELLBOOKS[normalizeHealerClassId(classId)]
|
||||
}
|
||||
|
||||
export function getActionSpellDefinition(classId: string | undefined, spell: SpellSlot, talentMods?: TalentModifiers) {
|
||||
const definition = getActionSpellbook(classId).spells[spell]
|
||||
const modifier = getActionTalentAbilityModifier(talentMods, definition.abilityId)
|
||||
return {
|
||||
...definition,
|
||||
castTime: Math.max(0, roundTenths(definition.castTime * (1 + modifier.castPct))),
|
||||
cooldown: Math.max(0, roundTenths(definition.cooldown * (1 + modifier.cooldownPct))),
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionSpellRange(classId: string | undefined, spell: SpellSlot, talentMods?: TalentModifiers) {
|
||||
return getArenaAbility(getActionSpellDefinition(classId, spell, talentMods).abilityId).range
|
||||
}
|
||||
|
||||
export function getActionSpellReach(options: {
|
||||
caster: ActionRangePoint
|
||||
target: ActionRangePoint
|
||||
classId: string | undefined
|
||||
spell: SpellSlot
|
||||
talentMods?: TalentModifiers
|
||||
includeTargetRadius?: boolean
|
||||
hasLineOfSight?: (from: ActionRangePoint, to: ActionRangePoint) => boolean
|
||||
}): ActionSpellReachResult {
|
||||
const baseRange = getActionSpellRange(options.classId, options.spell, options.talentMods)
|
||||
const effectiveRange = baseRange + (options.includeTargetRadius ? options.target.radius ?? 0 : 0)
|
||||
const distance = Math.hypot(options.caster.x - options.target.x, options.caster.y - options.target.y)
|
||||
if (distance > effectiveRange) return { ok: false, reason: 'range', distance, effectiveRange }
|
||||
if (options.hasLineOfSight && !options.hasLineOfSight(options.caster, options.target)) {
|
||||
return { ok: false, reason: 'lineOfSight', distance, effectiveRange }
|
||||
}
|
||||
return { ok: true, distance, effectiveRange }
|
||||
}
|
||||
|
||||
export function getActionSpellManaCost(classId: string | undefined, spell: SpellSlot, options?: { costMultiplier?: number, freeCast?: boolean, talentMods?: TalentModifiers }) {
|
||||
if (options?.freeCast) return 0
|
||||
const definition = getActionSpellbook(classId).spells[spell]
|
||||
const modifier = getActionTalentAbilityModifier(options?.talentMods, definition.abilityId)
|
||||
return Math.max(1, Math.round(getActionSpellbook(classId).manaCosts[spell] * (1 + modifier.costPct) * (options?.costMultiplier ?? 1)))
|
||||
}
|
||||
|
||||
export function getActionTalentAdjustedAmount(classId: string | undefined, spell: SpellSlot, baseAmount: number, kind: 'damage' | 'heal' | 'shield', talentMods?: TalentModifiers) {
|
||||
const definition = getActionSpellbook(classId).spells[spell]
|
||||
const abilityModifier = getActionTalentAbilityModifier(talentMods, definition.abilityId)
|
||||
const globalMultiplier = kind === 'heal'
|
||||
? talentMods?.global.healPct ?? 0
|
||||
: kind === 'damage'
|
||||
? talentMods?.global.spellDmgPct ?? 0
|
||||
: 0
|
||||
return Math.max(0, Math.round((baseAmount + abilityModifier.flatDmg) * (1 + abilityModifier.dmgPct + globalMultiplier)))
|
||||
}
|
||||
|
||||
export function getActionSpellTarget(classId: string | undefined, spell: SpellSlot) {
|
||||
return getActionSpellbook(classId).targets[spell]
|
||||
}
|
||||
|
||||
export function getActionSpellIconUrl(classId: string | undefined, spell: SpellSlot) {
|
||||
const book = getActionSpellbook(classId)
|
||||
return `/ui/skills/${book.iconFolder}/${book.spells[spell].abilityId}.png`
|
||||
}
|
||||
|
||||
export function getActionSpellTooltip(classId: string | undefined, spell: SpellSlot) {
|
||||
const book = getActionSpellbook(classId)
|
||||
return {
|
||||
description: book.descriptions[spell],
|
||||
rank: book.ranks[spell],
|
||||
school: book.schools[spell],
|
||||
target: book.targets[spell],
|
||||
}
|
||||
}
|
||||
|
||||
export function getDungeonSpellManaCost(spell: SpellSlot, options?: { freeCast?: boolean }) {
|
||||
if (options?.freeCast) return 0
|
||||
return getSpellManaCost(spell, options)
|
||||
}
|
||||
|
||||
export function spendSpellMana(resource: SpellResourceState, spell: SpellSlot, options?: { costMultiplier?: number }) {
|
||||
const cost = getSpellManaCost(spell, {
|
||||
costMultiplier: options?.costMultiplier,
|
||||
freeCast: resource.storedMomentumReady,
|
||||
})
|
||||
if (!resource.storedMomentumReady && resource.mana < cost) {
|
||||
return { ok: false, cost }
|
||||
}
|
||||
|
||||
if (resource.storedMomentumReady) resource.storedMomentumReady = false
|
||||
else resource.mana = Math.max(0, resource.mana - cost)
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
export function spendActionSpellMana(resource: SpellResourceState, classId: string | undefined, spell: SpellSlot, options?: { costMultiplier?: number, talentMods?: TalentModifiers }) {
|
||||
const cost = getActionSpellManaCost(classId, spell, {
|
||||
costMultiplier: options?.costMultiplier,
|
||||
freeCast: resource.storedMomentumReady,
|
||||
talentMods: options?.talentMods,
|
||||
})
|
||||
if (!resource.storedMomentumReady && resource.mana < cost) {
|
||||
return { ok: false, cost }
|
||||
}
|
||||
|
||||
if (resource.storedMomentumReady) resource.storedMomentumReady = false
|
||||
else resource.mana = Math.max(0, resource.mana - cost)
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
function getActionTalentAbilityModifier(talentMods: TalentModifiers | undefined, abilityId: ClaudeCraftHealerAbilityId) {
|
||||
return talentMods?.abilities[abilityId] ?? getEmptyClaudeCraftAbilityModifier()
|
||||
}
|
||||
|
||||
function roundTenths(value: number) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
export function spendDungeonSpellMana(resource: SpellResourceState, spell: SpellSlot) {
|
||||
const cost = getDungeonSpellManaCost(spell, { freeCast: resource.storedMomentumReady })
|
||||
if (!resource.storedMomentumReady && resource.mana < cost) {
|
||||
return { ok: false, cost }
|
||||
}
|
||||
|
||||
if (resource.storedMomentumReady) resource.storedMomentumReady = false
|
||||
else resource.mana = Math.max(0, resource.mana - cost)
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
export function recordSpellCast(resource: SpellResourceState, options?: { storedMomentum?: boolean }) {
|
||||
if (!options?.storedMomentum) return
|
||||
resource.storedMomentumCasts += 1
|
||||
if (resource.storedMomentumCasts >= 5) {
|
||||
resource.storedMomentumCasts = 0
|
||||
resource.storedMomentumReady = true
|
||||
}
|
||||
}
|
||||
|
||||
export function applyShieldedDamage(target: ShieldedHealthState, amount: number) {
|
||||
if ((target.invulnerableTimer ?? 0) > 0) return 0
|
||||
const absorbed = Math.min(target.shield, amount)
|
||||
target.shield -= absorbed
|
||||
const damage = amount - absorbed
|
||||
target.hp = Math.max(0, target.hp - damage)
|
||||
return damage
|
||||
}
|
||||
|
||||
export function addShield(target: ShieldedHealthState, amount: number, options?: { cap?: number, capRatio?: number }) {
|
||||
const cap = options?.cap ?? target.maxHp * (options?.capRatio ?? HEALING_SPELL_RULES.arenaShieldCapRatio)
|
||||
target.shield = Math.min(cap, target.shield + amount)
|
||||
}
|
||||
|
||||
export function dampenHealing(amount: number, dampeningPercent = 0) {
|
||||
return amount * Math.max(0, 1 - dampeningPercent / 100)
|
||||
}
|
||||
|
||||
export function applyRenewTimers(target: RenewState, durationSeconds: number = HEALING_SPELL_RULES.renewSeconds) {
|
||||
applyHealOverTimeTimers(target, 'renew', { durationSeconds })
|
||||
}
|
||||
|
||||
export function applyHealOverTimeTimers(
|
||||
target: RenewState,
|
||||
id: HealOverTimeEffectId,
|
||||
options: {
|
||||
durationSeconds?: number
|
||||
tickHeal?: number
|
||||
} = {},
|
||||
) {
|
||||
const durationSeconds = options.durationSeconds ?? HEALING_SPELL_RULES.renewSeconds
|
||||
const tickSeconds = HEALING_SPELL_RULES.renewTickSeconds
|
||||
const healOverTimes = target.healOverTimes ?? []
|
||||
const existing = healOverTimes.find((effect) => effect.id === id)
|
||||
if (existing) {
|
||||
existing.remaining = Math.max(existing.remaining, durationSeconds)
|
||||
existing.tickHeal = options.tickHeal ?? existing.tickHeal
|
||||
existing.tickTimer = tickSeconds
|
||||
} else {
|
||||
healOverTimes.push({
|
||||
id,
|
||||
label: HEAL_OVER_TIME_LABELS[id],
|
||||
remaining: durationSeconds,
|
||||
tickHeal: options.tickHeal ?? HEALING_SPELL_RULES.renewHeal,
|
||||
tickSeconds,
|
||||
tickTimer: tickSeconds,
|
||||
})
|
||||
}
|
||||
target.healOverTimes = healOverTimes
|
||||
syncRenewCompatibilityTimers(target)
|
||||
}
|
||||
|
||||
export function tickHealOverTimes(target: RenewState, deltaSeconds: number, onTick: (effect: HealOverTimeEffect) => void) {
|
||||
const healOverTimes = target.healOverTimes
|
||||
if (!healOverTimes || healOverTimes.length === 0) {
|
||||
tickLegacyRenew(target, deltaSeconds, onTick)
|
||||
return
|
||||
}
|
||||
|
||||
for (const effect of healOverTimes) {
|
||||
effect.remaining = Math.max(0, effect.remaining - deltaSeconds)
|
||||
effect.tickTimer -= deltaSeconds
|
||||
while (effect.remaining > 0 && effect.tickTimer <= 0) {
|
||||
onTick(effect)
|
||||
effect.tickTimer += effect.tickSeconds
|
||||
}
|
||||
}
|
||||
target.healOverTimes = healOverTimes.filter((effect) => effect.remaining > 0)
|
||||
syncRenewCompatibilityTimers(target)
|
||||
}
|
||||
|
||||
export function getActiveHealOverTimeFrames(target: RenewState): HealOverTimeFrame[] {
|
||||
const healOverTimes = target.healOverTimes?.filter((effect) => effect.remaining > 0) ?? []
|
||||
if (healOverTimes.length > 0) {
|
||||
return healOverTimes.map(({ id, label, remaining }) => ({ id, label, remaining }))
|
||||
}
|
||||
return target.renewTimer > 0 ? [{ id: 'renew', label: HEAL_OVER_TIME_LABELS.renew, remaining: target.renewTimer }] : []
|
||||
}
|
||||
|
||||
export function getHealOverTimeEffectId(abilityId: string): HealOverTimeEffectId | null {
|
||||
if (abilityId === 'renew' || abilityId === 'rejuvenation' || abilityId === 'regrowth') return abilityId
|
||||
return null
|
||||
}
|
||||
|
||||
function tickLegacyRenew(target: RenewState, deltaSeconds: number, onTick: (effect: HealOverTimeEffect) => void) {
|
||||
if (target.renewTimer <= 0) return
|
||||
target.renewTimer = Math.max(0, target.renewTimer - deltaSeconds)
|
||||
target.renewTickTimer -= deltaSeconds
|
||||
if (target.renewTickTimer <= 0) {
|
||||
onTick({
|
||||
id: 'renew',
|
||||
label: HEAL_OVER_TIME_LABELS.renew,
|
||||
remaining: target.renewTimer,
|
||||
tickHeal: HEALING_SPELL_RULES.renewHeal,
|
||||
tickSeconds: HEALING_SPELL_RULES.renewTickSeconds,
|
||||
tickTimer: target.renewTickTimer,
|
||||
})
|
||||
target.renewTickTimer += HEALING_SPELL_RULES.renewTickSeconds
|
||||
}
|
||||
}
|
||||
|
||||
function syncRenewCompatibilityTimers(target: RenewState) {
|
||||
const renew = target.healOverTimes?.find((effect) => effect.id === 'renew')
|
||||
target.renewTimer = renew?.remaining ?? 0
|
||||
target.renewTickTimer = renew?.tickTimer ?? 0
|
||||
}
|
||||
|
||||
const HEAL_OVER_TIME_LABELS: Record<HealOverTimeEffectId, string> = {
|
||||
regrowth: 'Regrowth',
|
||||
rejuvenation: 'Rejuvenation',
|
||||
renew: 'Renew',
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computeTalentModifiers, type TalentAllocation, type TalentModifiers } from './claudeCraftTalents'
|
||||
import type { PlayerClass } from './claudeCraftTypes'
|
||||
|
||||
export type ActionCombatProfile = {
|
||||
classId: PlayerClass
|
||||
talentModifiers: TalentModifiers
|
||||
}
|
||||
|
||||
export function createActionCombatProfile(character?: { classId: PlayerClass, talents: TalentAllocation } | null): ActionCombatProfile {
|
||||
const classId = character?.classId ?? 'priest'
|
||||
return {
|
||||
classId,
|
||||
talentModifiers: character ? computeTalentModifiers(classId, character.talents) : createEmptyTalentModifiers(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyTalentModifiers(): TalentModifiers {
|
||||
return {
|
||||
spec: null,
|
||||
role: null,
|
||||
abilities: {},
|
||||
global: { healPct: 0, meleeDmgPct: 0, spellDmgPct: 0, threatPct: 0 },
|
||||
grants: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HealOverTimeFrame, PartyRole, SpellSlot } from './actionCombatCore'
|
||||
|
||||
export type BossPhase = 'tracking' | 'windup' | 'charging' | 'recovering' | 'mauling' | 'slamWindup' | 'circling' | 'defeated' | 'victory'
|
||||
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Telegraph = {
|
||||
active: boolean
|
||||
start: Point
|
||||
end: Point
|
||||
width: number
|
||||
}
|
||||
|
||||
export type ArcTelegraph = {
|
||||
active: boolean
|
||||
center: Point
|
||||
radius: number
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export type RaidFrame = {
|
||||
id: string
|
||||
name: string
|
||||
role: PartyRole
|
||||
specLabel?: string
|
||||
damageDone?: number
|
||||
hp: number
|
||||
maxHp: number
|
||||
healOverTimes: HealOverTimeFrame[]
|
||||
renewTimer: number
|
||||
burnTimer: number
|
||||
bleedTimer: number
|
||||
selected: boolean
|
||||
shield: number
|
||||
}
|
||||
|
||||
export type BossInput = {
|
||||
xAxis: number
|
||||
yAxis: number
|
||||
reset: boolean
|
||||
targetDelta: -1 | 0 | 1
|
||||
targetId: string | null
|
||||
castSpell: SpellSlot | null
|
||||
aimX?: number
|
||||
aimY?: number
|
||||
}
|
||||
+464
-201
@@ -1,9 +1,39 @@
|
||||
import { canEquipItem } from './claudeCraftEquipmentRules'
|
||||
import { ACTION_DUNGEON_LOOT_MOBS, CLAUDECRAFT_DUNGEON_MOBS } from './claudeCraftDungeons'
|
||||
import { getClaudeCraftItem } from './claudeCraftItems'
|
||||
import {
|
||||
allocateTalentPoint,
|
||||
emptyTalentAllocation,
|
||||
normalizeTalentAllocation,
|
||||
pointsSpent,
|
||||
setTalentSpec,
|
||||
talentPointsAtLevel,
|
||||
type TalentAllocation,
|
||||
} from './claudeCraftTalents'
|
||||
import { EQUIP_SLOTS, type EquipSlot, type InvSlot, type ItemDef, type PlayerClass } from './claudeCraftTypes'
|
||||
|
||||
export type ActionDifficulty = 'ilvl-1' | 'ilvl-10' | 'ilvl-20' | 'ilvl-30'
|
||||
export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' | 'rathian'
|
||||
export type ActionRunMode = 'hunt' | 'marathon'
|
||||
export type ActionGearSource = 'bulldrome' | 'yian-kut-ku'
|
||||
export type ActionDungeonId =
|
||||
| 'bulldrome'
|
||||
| 'yian-kut-ku'
|
||||
| 'cyber-dragon'
|
||||
export type ActionRunMode = 'hunt' | 'marathon' | 'boss'
|
||||
export type ActionGearSource = ActionDungeonId
|
||||
export type ActionCoinColor = 'white' | 'green' | 'blue' | 'purple'
|
||||
export type ActionCoinWallet = Record<ActionGearSource, Record<ActionCoinColor, number>>
|
||||
export type ActionEquipment = Partial<Record<EquipSlot, string>>
|
||||
export type ActionSkinCatalog = 'class' | 'mech'
|
||||
|
||||
export type ActionCharacterAppearance = {
|
||||
skin: number
|
||||
skinCatalog: ActionSkinCatalog
|
||||
}
|
||||
|
||||
export type ActionCharacterCreateInput = {
|
||||
appearance: ActionCharacterAppearance
|
||||
classId: PlayerClass
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ActionDifficultyTier = {
|
||||
id: ActionDifficulty
|
||||
@@ -17,44 +47,29 @@ export type ActionDifficultyTier = {
|
||||
experience: number
|
||||
}
|
||||
|
||||
export type ActionGearSlot =
|
||||
| 'weapon'
|
||||
| 'helmet'
|
||||
| 'chest'
|
||||
| 'gloves'
|
||||
| 'boots'
|
||||
| 'pants'
|
||||
| 'ring'
|
||||
| 'necklace'
|
||||
| 'trinket'
|
||||
|
||||
export type ActionGearPiece = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
source: ActionGearSource
|
||||
slot: ActionGearSlot
|
||||
itemLevel: number
|
||||
}
|
||||
|
||||
export type ActionGearStats = {
|
||||
healingPower: number
|
||||
stamina: number
|
||||
}
|
||||
|
||||
export type ActionRunReward = {
|
||||
coins: number
|
||||
coinName: string
|
||||
experience: number
|
||||
gear: ActionGearPiece[]
|
||||
items: ItemDef[]
|
||||
leveledUp: boolean
|
||||
}
|
||||
|
||||
export type ActionPvpRoundReward = {
|
||||
experience: number
|
||||
matchBonusExperience: number
|
||||
roundExperience: number
|
||||
leveledUp: boolean
|
||||
}
|
||||
|
||||
export type ActionCharacter = {
|
||||
id: string
|
||||
name: string
|
||||
classId: PlayerClass
|
||||
appearance: ActionCharacterAppearance
|
||||
level: number
|
||||
experience: number
|
||||
copper: number
|
||||
actionCoins: ActionCoinWallet
|
||||
bulldromeCoins: number
|
||||
yianKutKuCoins: number
|
||||
@@ -62,25 +77,39 @@ export type ActionCharacter = {
|
||||
bulldromeHardClears: number
|
||||
yianKutKuNormalClears: number
|
||||
yianKutKuHardClears: number
|
||||
inventory: ActionGearPiece[]
|
||||
inventory: InvSlot[]
|
||||
equipment: ActionEquipment
|
||||
talents: TalentAllocation
|
||||
}
|
||||
|
||||
export const ACTION_GEAR_SLOTS: Array<{
|
||||
slot: ActionGearSlot
|
||||
export type ActionCharacterRoster = {
|
||||
activeCharacterId: string | null
|
||||
characters: ActionCharacter[]
|
||||
}
|
||||
|
||||
export const ACTION_EQUIP_SLOTS: Array<{
|
||||
slot: EquipSlot
|
||||
label: string
|
||||
glyph: string
|
||||
}> = [
|
||||
{ slot: 'weapon', label: 'Weapon', glyph: '/' },
|
||||
{ slot: 'helmet', label: 'Helmet', glyph: 'H' },
|
||||
{ slot: 'mainhand', label: 'Weapon', glyph: '/' },
|
||||
{ slot: 'helmet', label: 'Head', glyph: 'H' },
|
||||
{ slot: 'shoulder', label: 'Shoulders', glyph: 'S' },
|
||||
{ slot: 'chest', label: 'Chest', glyph: 'C' },
|
||||
{ slot: 'gloves', label: 'Gloves', glyph: 'G' },
|
||||
{ slot: 'boots', label: 'Boots', glyph: 'B' },
|
||||
{ slot: 'pants', label: 'Pants', glyph: 'P' },
|
||||
{ slot: 'ring', label: 'Ring', glyph: 'O' },
|
||||
{ slot: 'necklace', label: 'Necklace', glyph: 'N' },
|
||||
{ slot: 'trinket', label: 'Trinket', glyph: 'T' },
|
||||
{ slot: 'waist', label: 'Waist', glyph: 'W' },
|
||||
{ slot: 'legs', label: 'Legs', glyph: 'L' },
|
||||
{ slot: 'gloves', label: 'Hands', glyph: 'G' },
|
||||
{ slot: 'feet', label: 'Feet', glyph: 'B' },
|
||||
]
|
||||
|
||||
const ACTION_GEAR_SOURCE_NAMES: Record<ActionGearSource, string> = {
|
||||
bulldrome: 'Bulldrome',
|
||||
'yian-kut-ku': 'Yian Kut-Ku',
|
||||
'cyber-dragon': 'Cyber Dragon',
|
||||
}
|
||||
|
||||
const ACTION_GEAR_SOURCES = Object.keys(ACTION_GEAR_SOURCE_NAMES) as ActionGearSource[]
|
||||
|
||||
export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
|
||||
{
|
||||
id: 'ilvl-1',
|
||||
@@ -128,17 +157,40 @@ export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
|
||||
const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1'
|
||||
const MAX_ACTION_LEVEL = 25
|
||||
export const BULLDROME_UPGRADE_COST = 5
|
||||
export const ACTION_GEAR_UPGRADE_COST = 5
|
||||
export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v3'
|
||||
export const ACTION_ROSTER_SAVE_KEY = 'i-want-to-heal:action-mode-roster:v1'
|
||||
const LEGACY_ACTION_SAVE_KEYS = [
|
||||
'i-want-to-heal:action-mode-save:v2',
|
||||
'i-want-to-heal:action-mode-save:v1',
|
||||
]
|
||||
const CLAUDECRAFT_XP_TABLE = [
|
||||
400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, 8800, 10100, 11400, 12900, 14400, 16000,
|
||||
17700, 19400, 21300, 23200,
|
||||
] as const
|
||||
const MAX_ACTION_LEVEL = 20
|
||||
export const ACTION_PVP_ROUND_EXPERIENCE = 30
|
||||
export const ACTION_PVP_MATCH_WIN_BONUS_EXPERIENCE = 120
|
||||
export const HEALER_ACTION_CLASSES: PlayerClass[] = ['priest', 'paladin', 'shaman', 'druid']
|
||||
export const ACTION_CLASS_SKIN_COUNTS: Record<PlayerClass, number> = {
|
||||
warrior: 4,
|
||||
paladin: 2,
|
||||
hunter: 4,
|
||||
rogue: 4,
|
||||
priest: 4,
|
||||
shaman: 4,
|
||||
mage: 4,
|
||||
warlock: 4,
|
||||
druid: 4,
|
||||
}
|
||||
|
||||
const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
|
||||
id: 'action-local-1',
|
||||
name: 'Action Healer',
|
||||
classId: 'priest',
|
||||
appearance: { skin: 0, skinCatalog: 'class' },
|
||||
level: 1,
|
||||
experience: 0,
|
||||
copper: 0,
|
||||
actionCoins: createEmptyCoinWallet(),
|
||||
bulldromeCoins: 0,
|
||||
yianKutKuCoins: 0,
|
||||
@@ -147,117 +199,161 @@ const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
|
||||
yianKutKuNormalClears: 0,
|
||||
yianKutKuHardClears: 0,
|
||||
inventory: [],
|
||||
equipment: {
|
||||
chest: 'apprentice_robe',
|
||||
mainhand: 'gnarled_staff',
|
||||
},
|
||||
talents: emptyTalentAllocation(),
|
||||
}
|
||||
|
||||
export function loadActionRoster(): ActionCharacterRoster {
|
||||
const savedRoster = window.localStorage.getItem(ACTION_ROSTER_SAVE_KEY)
|
||||
if (savedRoster) {
|
||||
try {
|
||||
return normalizeRoster(JSON.parse(savedRoster))
|
||||
} catch {
|
||||
return { activeCharacterId: null, characters: [] }
|
||||
}
|
||||
}
|
||||
|
||||
const legacyCharacter = loadLegacyActionCharacter()
|
||||
if (legacyCharacter) {
|
||||
return {
|
||||
activeCharacterId: legacyCharacter.id,
|
||||
characters: [legacyCharacter],
|
||||
}
|
||||
}
|
||||
|
||||
return { activeCharacterId: null, characters: [] }
|
||||
}
|
||||
|
||||
export function saveActionRoster(roster: ActionCharacterRoster) {
|
||||
window.localStorage.setItem(ACTION_ROSTER_SAVE_KEY, JSON.stringify(normalizeRoster(roster)))
|
||||
}
|
||||
|
||||
export function loadActionCharacter(): ActionCharacter {
|
||||
const roster = loadActionRoster()
|
||||
return getActiveActionCharacter(roster) ?? DEFAULT_ACTION_CHARACTER
|
||||
}
|
||||
|
||||
function loadLegacyActionCharacter(): ActionCharacter | null {
|
||||
const saved = window.localStorage.getItem(ACTION_SAVE_KEY)
|
||||
?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY)
|
||||
if (!saved) return DEFAULT_ACTION_CHARACTER
|
||||
?? LEGACY_ACTION_SAVE_KEYS.map((key) => window.localStorage.getItem(key)).find(Boolean)
|
||||
if (!saved) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as Partial<ActionCharacter>
|
||||
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
|
||||
return {
|
||||
...DEFAULT_ACTION_CHARACTER,
|
||||
...parsed,
|
||||
id: DEFAULT_ACTION_CHARACTER.id,
|
||||
experience,
|
||||
level: getActionLevel(experience),
|
||||
actionCoins: normalizeCoinWallet(parsed),
|
||||
bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins),
|
||||
yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins),
|
||||
bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears),
|
||||
bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears),
|
||||
yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears),
|
||||
yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears),
|
||||
inventory: Array.isArray(parsed.inventory) ? parsed.inventory.map(normalizeGearPiece) : [],
|
||||
}
|
||||
return normalizeActionCharacter(JSON.parse(saved), DEFAULT_ACTION_CHARACTER.id)
|
||||
} catch {
|
||||
return DEFAULT_ACTION_CHARACTER
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveActionCharacter(character: ActionCharacter) {
|
||||
const roster = loadActionRoster()
|
||||
saveActionRoster(updateActionRosterCharacter(
|
||||
roster.characters.length ? roster : { activeCharacterId: character.id, characters: [character] },
|
||||
character,
|
||||
))
|
||||
window.localStorage.setItem(ACTION_SAVE_KEY, JSON.stringify(character))
|
||||
}
|
||||
|
||||
export function completeBulldromeHunt(
|
||||
character: ActionCharacter,
|
||||
difficulty: ActionDifficulty,
|
||||
): { character: ActionCharacter, reward: ActionRunReward } {
|
||||
const tier = getActionDifficultyTier(difficulty)
|
||||
const coinRoll = randomInt(1, 3)
|
||||
const lootMultiplier = tier.lootMultiplier
|
||||
const coinReward = coinRoll * lootMultiplier
|
||||
const experienceReward = tier.experience
|
||||
const nextExperience = character.experience + experienceReward
|
||||
const nextLevel = getActionLevel(nextExperience)
|
||||
const gear = rollBulldromeGear(lootMultiplier, tier.itemLevel)
|
||||
const actionCoins = addActionCoins(character.actionCoins, 'bulldrome', tier.coinColor, coinReward)
|
||||
export function getActiveActionCharacter(roster: ActionCharacterRoster) {
|
||||
return roster.characters.find((character) => character.id === roster.activeCharacterId)
|
||||
?? roster.characters[0]
|
||||
?? null
|
||||
}
|
||||
|
||||
export function createActionCharacter(input: ActionCharacterCreateInput): ActionCharacter {
|
||||
const classId = HEALER_ACTION_CLASSES.includes(input.classId) ? input.classId : 'priest'
|
||||
const id = `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const starter = starterEquipmentForClass(classId)
|
||||
return {
|
||||
character: {
|
||||
...character,
|
||||
level: nextLevel,
|
||||
experience: nextExperience,
|
||||
actionCoins,
|
||||
bulldromeCoins: actionCoins.bulldrome.white,
|
||||
bulldromeNormalClears: character.bulldromeNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
|
||||
bulldromeHardClears: character.bulldromeHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
|
||||
inventory: [...character.inventory, ...gear],
|
||||
},
|
||||
reward: {
|
||||
coins: coinReward,
|
||||
experience: experienceReward,
|
||||
gear,
|
||||
coinName: `${tier.coinLabel.replace(' Coins', '')} Bulldrome Coins`,
|
||||
leveledUp: nextLevel > character.level,
|
||||
},
|
||||
...DEFAULT_ACTION_CHARACTER,
|
||||
id,
|
||||
name: normalizeCharacterName(input.name),
|
||||
classId,
|
||||
appearance: normalizeAppearance(input.appearance, classId),
|
||||
actionCoins: createEmptyCoinWallet(),
|
||||
inventory: [],
|
||||
equipment: starter,
|
||||
talents: emptyTalentAllocation(),
|
||||
}
|
||||
}
|
||||
|
||||
export function addActionRosterCharacter(roster: ActionCharacterRoster, input: ActionCharacterCreateInput) {
|
||||
const character = createActionCharacter(input)
|
||||
return {
|
||||
activeCharacterId: character.id,
|
||||
characters: [...roster.characters, character],
|
||||
}
|
||||
}
|
||||
|
||||
export function updateActionRosterCharacter(roster: ActionCharacterRoster, character: ActionCharacter): ActionCharacterRoster {
|
||||
const normalized = normalizeActionCharacter(character, character.id)
|
||||
const exists = roster.characters.some((candidate) => candidate.id === normalized.id)
|
||||
return normalizeRoster({
|
||||
activeCharacterId: roster.activeCharacterId ?? normalized.id,
|
||||
characters: exists
|
||||
? roster.characters.map((candidate) => candidate.id === normalized.id ? normalized : candidate)
|
||||
: [...roster.characters, normalized],
|
||||
})
|
||||
}
|
||||
|
||||
export function switchActionRosterCharacter(roster: ActionCharacterRoster, characterId: string): ActionCharacterRoster {
|
||||
if (!roster.characters.some((character) => character.id === characterId)) return roster
|
||||
return { ...roster, activeCharacterId: characterId }
|
||||
}
|
||||
|
||||
export function completeActionDungeonHunt(
|
||||
character: ActionCharacter,
|
||||
dungeonId: ActionDungeonId,
|
||||
difficulty: ActionDifficulty,
|
||||
): { character: ActionCharacter, reward: ActionRunReward } {
|
||||
if (dungeonId === 'bulldrome') return completeBulldromeHunt(character, difficulty)
|
||||
|
||||
if (dungeonId === 'yian-kut-ku') {
|
||||
const tier = getActionDifficultyTier(difficulty)
|
||||
const coinRoll = randomInt(1, 3)
|
||||
const lootMultiplier = tier.lootMultiplier
|
||||
const coinReward = coinRoll * lootMultiplier
|
||||
const experienceReward = Math.ceil(tier.experience * 1.25)
|
||||
const coinReward = coinRoll * tier.lootMultiplier
|
||||
const loot = rollActionDungeonLoot(dungeonId, character.classId, tier.lootMultiplier)
|
||||
const experienceReward = getExperienceReward(dungeonId, tier)
|
||||
const nextExperience = character.experience + experienceReward
|
||||
const nextLevel = getActionLevel(nextExperience)
|
||||
const gear = rollDungeonGear('yian-kut-ku', lootMultiplier, tier.itemLevel)
|
||||
const actionCoins = addActionCoins(character.actionCoins, 'yian-kut-ku', tier.coinColor, coinReward)
|
||||
const actionCoins = addActionCoins(character.actionCoins, dungeonId, tier.coinColor, coinReward)
|
||||
|
||||
return {
|
||||
character: {
|
||||
...character,
|
||||
level: nextLevel,
|
||||
experience: nextExperience,
|
||||
copper: character.copper + loot.copper,
|
||||
actionCoins,
|
||||
yianKutKuCoins: actionCoins['yian-kut-ku'].white,
|
||||
yianKutKuNormalClears: character.yianKutKuNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
|
||||
yianKutKuHardClears: character.yianKutKuHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
|
||||
inventory: [...character.inventory, ...gear],
|
||||
bulldromeCoins: dungeonId === 'bulldrome' ? actionCoins.bulldrome.white : character.bulldromeCoins,
|
||||
yianKutKuCoins: dungeonId === 'yian-kut-ku' ? actionCoins['yian-kut-ku'].white : character.yianKutKuCoins,
|
||||
bulldromeNormalClears: character.bulldromeNormalClears + (dungeonId === 'bulldrome' && difficulty === 'ilvl-1' ? 1 : 0),
|
||||
bulldromeHardClears: character.bulldromeHardClears + (dungeonId === 'bulldrome' && difficulty !== 'ilvl-1' ? 1 : 0),
|
||||
yianKutKuNormalClears: character.yianKutKuNormalClears + (dungeonId === 'yian-kut-ku' && difficulty === 'ilvl-1' ? 1 : 0),
|
||||
yianKutKuHardClears: character.yianKutKuHardClears + (dungeonId === 'yian-kut-ku' && difficulty !== 'ilvl-1' ? 1 : 0),
|
||||
inventory: addInventoryItems(character.inventory, loot.items.map((item) => item.id)),
|
||||
},
|
||||
reward: {
|
||||
coins: coinReward,
|
||||
coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`,
|
||||
coins: loot.copper,
|
||||
coinName: 'Copper',
|
||||
experience: experienceReward,
|
||||
gear,
|
||||
items: loot.items,
|
||||
leveledUp: nextLevel > character.level,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const experienceReward = getActionDifficultyTier(difficulty).experience
|
||||
export function completeActionPvpRound(
|
||||
character: ActionCharacter,
|
||||
playerWonRound: boolean,
|
||||
playerWonMatch: boolean,
|
||||
): { character: ActionCharacter, reward: ActionPvpRoundReward } {
|
||||
const roundExperience = ACTION_PVP_ROUND_EXPERIENCE * (playerWonRound ? 2 : 1)
|
||||
const matchBonusExperience = playerWonMatch ? ACTION_PVP_MATCH_WIN_BONUS_EXPERIENCE : 0
|
||||
const experienceReward = roundExperience + matchBonusExperience
|
||||
const nextExperience = character.experience + experienceReward
|
||||
const nextLevel = getActionLevel(nextExperience)
|
||||
|
||||
return {
|
||||
character: {
|
||||
...character,
|
||||
@@ -265,63 +361,60 @@ export function completeActionDungeonHunt(
|
||||
experience: nextExperience,
|
||||
},
|
||||
reward: {
|
||||
coins: 0,
|
||||
coinName: 'Coins',
|
||||
experience: experienceReward,
|
||||
gear: [],
|
||||
matchBonusExperience,
|
||||
roundExperience,
|
||||
leveledUp: nextLevel > character.level,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeBulldromeGear(character: ActionCharacter, itemId: string): ActionCharacter {
|
||||
const item = character.inventory.find((candidate) => candidate.id === itemId)
|
||||
if (!item || item.itemLevel >= getActionGearUpgradeCap(item) || getUpgradeCoinCount(character, item) < ACTION_GEAR_UPGRADE_COST) return character
|
||||
export function equipActionItem(character: ActionCharacter, itemId: string): ActionCharacter {
|
||||
const item = getClaudeCraftItem(itemId)
|
||||
if (!item?.slot || (item.kind !== 'weapon' && item.kind !== 'armor')) return character
|
||||
if (!canEquipItem(character.classId, item)) return character
|
||||
if (getInventoryCount(character.inventory, itemId) <= 0) return character
|
||||
|
||||
const oldItemId = character.equipment[item.slot]
|
||||
return {
|
||||
...character,
|
||||
...spendUpgradeCoins(character, item),
|
||||
inventory: character.inventory.map((candidate) => (
|
||||
candidate.id === itemId
|
||||
? { ...candidate, itemLevel: candidate.itemLevel + 1 }
|
||||
: candidate
|
||||
)),
|
||||
equipment: {
|
||||
...character.equipment,
|
||||
[item.slot]: itemId,
|
||||
},
|
||||
inventory: oldItemId
|
||||
? addInventoryItems(removeInventoryItem(character.inventory, itemId), [oldItemId])
|
||||
: removeInventoryItem(character.inventory, itemId),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpgradeCoinCount(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
|
||||
const fullItem = item as Partial<Pick<ActionGearPiece, 'itemLevel'>>
|
||||
const coinColor = getCoinColorForItemLevel(fullItem.itemLevel ?? 1)
|
||||
return getActionCoinCount(character, item.source, coinColor)
|
||||
export function unequipActionSlot(character: ActionCharacter, slot: EquipSlot): ActionCharacter {
|
||||
const itemId = character.equipment[slot]
|
||||
if (!itemId) return character
|
||||
const equipment = { ...character.equipment }
|
||||
delete equipment[slot]
|
||||
return {
|
||||
...character,
|
||||
equipment,
|
||||
inventory: addInventoryItems(character.inventory, [itemId]),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpgradeCoinName(item: Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>) {
|
||||
const tier = getTierForItemLevel(item.itemLevel ?? 1)
|
||||
const sourceName = item.source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
|
||||
return `${tier.coinLabel.replace(' Coins', '')} ${sourceName} Coins`
|
||||
}
|
||||
|
||||
export function getActionGearStats(item: Pick<ActionGearPiece, 'slot' | 'itemLevel'>): ActionGearStats {
|
||||
const slotWeight = item.slot === 'weapon'
|
||||
? 2
|
||||
: item.slot === 'chest' || item.slot === 'helmet' || item.slot === 'pants'
|
||||
? 1.5
|
||||
: 1
|
||||
const healingPower = Math.ceil(item.itemLevel * slotWeight)
|
||||
const stamina = item.slot === 'ring' || item.slot === 'necklace' || item.slot === 'trinket'
|
||||
? item.itemLevel * 2
|
||||
: item.itemLevel
|
||||
|
||||
return { healingPower, stamina }
|
||||
export function getEquippedActionItem(character: ActionCharacter, slot: EquipSlot) {
|
||||
const itemId = character.equipment[slot]
|
||||
return itemId ? getClaudeCraftItem(itemId) : null
|
||||
}
|
||||
|
||||
export function getActionLevel(experience: number) {
|
||||
return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220))
|
||||
const lifetimeXp = Math.max(0, experience)
|
||||
let level = 1
|
||||
while (level < MAX_ACTION_LEVEL && lifetimeXp >= getActionXpToReachLevel(level + 1)) level += 1
|
||||
return level
|
||||
}
|
||||
|
||||
export function getActionLevelProgress(character: ActionCharacter) {
|
||||
const currentLevelStart = (character.level - 1) * 220
|
||||
const nextLevelStart = character.level * 220
|
||||
const currentLevelStart = getActionXpToReachLevel(character.level)
|
||||
const nextLevelStart = getActionXpToReachLevel(character.level + 1)
|
||||
const earnedThisLevel = Math.max(0, character.experience - currentLevelStart)
|
||||
const neededThisLevel = Math.max(1, nextLevelStart - currentLevelStart)
|
||||
return {
|
||||
@@ -333,6 +426,45 @@ export function getActionLevelProgress(character: ActionCharacter) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionXpToReachLevel(level: number) {
|
||||
const targetLevel = Math.max(1, Math.min(MAX_ACTION_LEVEL, Math.floor(level)))
|
||||
let total = 0
|
||||
for (let currentLevel = 1; currentLevel < targetLevel; currentLevel += 1) {
|
||||
total += CLAUDECRAFT_XP_TABLE[currentLevel - 1] ?? CLAUDECRAFT_XP_TABLE[CLAUDECRAFT_XP_TABLE.length - 1]
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
export function getActionTalentPoints(character: Pick<ActionCharacter, 'level' | 'talents'>) {
|
||||
const total = talentPointsAtLevel(character.level)
|
||||
const spent = pointsSpent(character.talents)
|
||||
return {
|
||||
available: Math.max(0, total - spent),
|
||||
spent,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseActionTalentSpec(character: ActionCharacter, specId: string): ActionCharacter {
|
||||
return {
|
||||
...character,
|
||||
talents: setTalentSpec(character.classId, character.talents, specId),
|
||||
}
|
||||
}
|
||||
|
||||
export function spendActionTalentPoint(character: ActionCharacter, nodeId: string, choiceId?: string): { character: ActionCharacter, ok: boolean, reason?: string } {
|
||||
const result = allocateTalentPoint(character.classId, character.talents, nodeId, getActionTalentPoints(character).total, choiceId)
|
||||
return {
|
||||
character: result.ok ? { ...character, talents: result.allocation } : character,
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
}
|
||||
}
|
||||
|
||||
export function respecActionTalents(character: ActionCharacter): ActionCharacter {
|
||||
return { ...character, talents: emptyTalentAllocation() }
|
||||
}
|
||||
|
||||
export function getActionDifficultyTier(difficulty: ActionDifficulty) {
|
||||
return ACTION_DIFFICULTY_TIERS.find((tier) => tier.id === difficulty) ?? ACTION_DIFFICULTY_TIERS[0]
|
||||
}
|
||||
@@ -342,83 +474,214 @@ export function getActionCoinCount(
|
||||
source: ActionGearSource,
|
||||
color: ActionCoinColor,
|
||||
) {
|
||||
if (color === 'white') return source === 'yian-kut-ku' ? character.yianKutKuCoins : character.bulldromeCoins
|
||||
if (color === 'white' && source === 'yian-kut-ku') return character.yianKutKuCoins
|
||||
if (color === 'white' && source === 'bulldrome') return character.bulldromeCoins
|
||||
return character.actionCoins?.[source]?.[color] ?? 0
|
||||
}
|
||||
|
||||
export function getTierForItemLevel(itemLevel: number) {
|
||||
return [...ACTION_DIFFICULTY_TIERS]
|
||||
.reverse()
|
||||
.find((tier) => itemLevel >= tier.itemLevel) ?? ACTION_DIFFICULTY_TIERS[0]
|
||||
}
|
||||
function rollActionDungeonLoot(dungeonId: ActionDungeonId, classId: PlayerClass, rolls: number) {
|
||||
const mobIds = ACTION_DUNGEON_LOOT_MOBS[dungeonId] ?? []
|
||||
const items: ItemDef[] = []
|
||||
let copper = 0
|
||||
|
||||
export function getActionGearUpgradeCap(item: Pick<ActionGearPiece, 'itemLevel'>) {
|
||||
return getTierForItemLevel(item.itemLevel).itemLevel + 4
|
||||
}
|
||||
|
||||
function rollBulldromeGear(rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
|
||||
return rollDungeonGear('bulldrome', rolls, itemLevel)
|
||||
}
|
||||
|
||||
function rollDungeonGear(source: ActionGearSource, rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
|
||||
const gear: ActionGearPiece[] = []
|
||||
for (let index = 0; index < rolls; index += 1) {
|
||||
if (Math.random() > 0.75) continue
|
||||
const slot = ACTION_GEAR_SLOTS[randomInt(0, ACTION_GEAR_SLOTS.length - 1)]
|
||||
gear.push(createDungeonGear(source, slot.slot, itemLevel))
|
||||
for (let rollIndex = 0; rollIndex < Math.max(1, rolls); rollIndex += 1) {
|
||||
for (const mobId of mobIds) {
|
||||
const mob = CLAUDECRAFT_DUNGEON_MOBS[mobId]
|
||||
if (!mob) continue
|
||||
const result = rollMobLoot(mob.loot)
|
||||
copper += result.copper
|
||||
for (const itemId of result.itemIds) {
|
||||
const item = getClaudeCraftItem(itemId)
|
||||
if (!item || (item.kind !== 'weapon' && item.kind !== 'armor')) continue
|
||||
if (!canEquipItem(classId, item)) continue
|
||||
items.push(item)
|
||||
}
|
||||
return gear
|
||||
}
|
||||
}
|
||||
|
||||
return { copper, items }
|
||||
}
|
||||
|
||||
function createDungeonGear(source: ActionGearSource, slot: ActionGearSlot, itemLevel: ActionDifficultyTier['itemLevel']): ActionGearPiece {
|
||||
const slotMeta = ACTION_GEAR_SLOTS.find((candidate) => candidate.slot === slot)!
|
||||
const sourceName = source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
|
||||
function rollMobLoot(loot: Array<{ itemId?: string; copper?: number; chance: number; rollGroup?: string }>) {
|
||||
const itemIds: string[] = []
|
||||
let copper = 0
|
||||
const rolledGroups = new Set<string>()
|
||||
|
||||
for (const entry of loot) {
|
||||
if (entry.rollGroup) {
|
||||
if (rolledGroups.has(entry.rollGroup)) continue
|
||||
rolledGroups.add(entry.rollGroup)
|
||||
const group = loot.filter((candidate) => candidate.rollGroup === entry.rollGroup)
|
||||
const roll = Math.random()
|
||||
let cumulative = 0
|
||||
for (const groupedEntry of group) {
|
||||
cumulative += groupedEntry.chance
|
||||
if (roll < cumulative) {
|
||||
if (groupedEntry.itemId) itemIds.push(groupedEntry.itemId)
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Math.random() >= entry.chance) continue
|
||||
if (entry.copper) copper += randomInt(Math.ceil(entry.copper * 0.6), Math.ceil(entry.copper * 1.4))
|
||||
if (entry.itemId) itemIds.push(entry.itemId)
|
||||
}
|
||||
|
||||
return { copper, itemIds }
|
||||
}
|
||||
|
||||
function getExperienceReward(dungeonId: ActionDungeonId, tier: ActionDifficultyTier) {
|
||||
if (dungeonId === 'yian-kut-ku') return Math.ceil(tier.experience * 1.25)
|
||||
if (dungeonId === 'cyber-dragon') return Math.ceil(tier.experience * 1.15)
|
||||
return tier.experience
|
||||
}
|
||||
|
||||
function normalizeInventory(raw: unknown): InvSlot[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_ACTION_CHARACTER.inventory
|
||||
const slots = new Map<string, number>()
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const candidate = item as Partial<InvSlot> & { slug?: string }
|
||||
const itemId = typeof candidate.itemId === 'string'
|
||||
? candidate.itemId
|
||||
: typeof candidate.slug === 'string' && getClaudeCraftItem(candidate.slug)
|
||||
? candidate.slug
|
||||
: ''
|
||||
if (!itemId || !getClaudeCraftItem(itemId)) continue
|
||||
slots.set(itemId, (slots.get(itemId) ?? 0) + Math.max(1, Number(candidate.count ?? 1)))
|
||||
}
|
||||
return Array.from(slots, ([itemId, count]) => ({ itemId, count }))
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: unknown): ActionCharacterRoster {
|
||||
if (!raw || typeof raw !== 'object') return { activeCharacterId: null, characters: [] }
|
||||
const parsed = raw as Partial<ActionCharacterRoster> & { character?: unknown }
|
||||
const rawCharacters = Array.isArray(parsed.characters)
|
||||
? parsed.characters
|
||||
: parsed.character
|
||||
? [parsed.character]
|
||||
: []
|
||||
const seenIds = new Set<string>()
|
||||
const characters = rawCharacters
|
||||
.map((character, index) => normalizeActionCharacter(character, `action-local-${index + 1}`))
|
||||
.filter((character) => {
|
||||
if (seenIds.has(character.id)) return false
|
||||
seenIds.add(character.id)
|
||||
return true
|
||||
})
|
||||
const activeCharacterId = characters.some((character) => character.id === parsed.activeCharacterId)
|
||||
? parsed.activeCharacterId ?? null
|
||||
: characters[0]?.id ?? null
|
||||
return { activeCharacterId, characters }
|
||||
}
|
||||
|
||||
function normalizeActionCharacter(raw: unknown, fallbackId: string): ActionCharacter {
|
||||
const parsed = raw && typeof raw === 'object' ? raw as Partial<ActionCharacter> & { inventory?: unknown[] } : {}
|
||||
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
|
||||
const classId = normalizePlayerClass(parsed.classId)
|
||||
return {
|
||||
id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
|
||||
slug: `${source}-${slot}`,
|
||||
name: `${sourceName} ${slotMeta.label}`,
|
||||
source,
|
||||
slot,
|
||||
itemLevel,
|
||||
...DEFAULT_ACTION_CHARACTER,
|
||||
...parsed,
|
||||
id: typeof parsed.id === 'string' && parsed.id ? parsed.id : fallbackId,
|
||||
name: normalizeCharacterName(parsed.name),
|
||||
classId,
|
||||
appearance: normalizeAppearance(parsed.appearance, classId),
|
||||
copper: Number(parsed.copper ?? 0),
|
||||
experience,
|
||||
level: getActionLevel(experience),
|
||||
actionCoins: normalizeCoinWallet(parsed),
|
||||
bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins),
|
||||
yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins),
|
||||
bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears),
|
||||
bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears),
|
||||
yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears),
|
||||
yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears),
|
||||
inventory: normalizeInventory(parsed.inventory),
|
||||
equipment: normalizeEquipment(parsed.equipment),
|
||||
talents: normalizeTalentAllocation(parsed.talents, classId),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGearPiece(item: ActionGearPiece): ActionGearPiece {
|
||||
const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome')
|
||||
function normalizeAppearance(raw: unknown, classId: PlayerClass): ActionCharacterAppearance {
|
||||
const parsed = raw && typeof raw === 'object' ? raw as Partial<ActionCharacterAppearance> : {}
|
||||
const maxSkin = ACTION_CLASS_SKIN_COUNTS[classId] - 1
|
||||
return {
|
||||
...item,
|
||||
source,
|
||||
skin: Math.max(0, Math.min(maxSkin, Math.floor(Number(parsed.skin ?? 0)))),
|
||||
skinCatalog: parsed.skinCatalog === 'mech' ? 'mech' : 'class',
|
||||
}
|
||||
}
|
||||
|
||||
function spendUpgradeCoins(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
|
||||
const itemWithLevel = item as Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>
|
||||
const coinColor = getCoinColorForItemLevel(itemWithLevel.itemLevel ?? 1)
|
||||
const actionCoins = addActionCoins(character.actionCoins, item.source, coinColor, -ACTION_GEAR_UPGRADE_COST)
|
||||
if (item.source === 'yian-kut-ku' && coinColor === 'white') {
|
||||
return { actionCoins, yianKutKuCoins: actionCoins['yian-kut-ku'].white }
|
||||
}
|
||||
if (item.source === 'bulldrome' && coinColor === 'white') {
|
||||
return { actionCoins, bulldromeCoins: actionCoins.bulldrome.white }
|
||||
}
|
||||
return { actionCoins }
|
||||
function normalizeCharacterName(value: unknown) {
|
||||
const name = typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''
|
||||
if (name.length < 2) return DEFAULT_ACTION_CHARACTER.name
|
||||
return name.slice(0, 20)
|
||||
}
|
||||
|
||||
function getCoinColorForItemLevel(itemLevel: number) {
|
||||
return getTierForItemLevel(itemLevel).coinColor
|
||||
function starterEquipmentForClass(classId: PlayerClass): ActionEquipment {
|
||||
if (classId === 'paladin' || classId === 'shaman') {
|
||||
return {
|
||||
chest: 'recruit_tunic',
|
||||
mainhand: 'worn_sword',
|
||||
}
|
||||
}
|
||||
return {
|
||||
chest: 'apprentice_robe',
|
||||
mainhand: 'gnarled_staff',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEquipment(raw: unknown): ActionEquipment {
|
||||
if (!raw || typeof raw !== 'object') return DEFAULT_ACTION_CHARACTER.equipment
|
||||
const equipment: ActionEquipment = {}
|
||||
const parsed = raw as Partial<Record<EquipSlot, string>>
|
||||
for (const slot of EQUIP_SLOTS) {
|
||||
const itemId = parsed[slot]
|
||||
const item = itemId ? getClaudeCraftItem(itemId) : null
|
||||
if (item?.slot === slot) equipment[slot] = itemId
|
||||
}
|
||||
return Object.keys(equipment).length > 0 ? equipment : DEFAULT_ACTION_CHARACTER.equipment
|
||||
}
|
||||
|
||||
function addInventoryItems(inventory: InvSlot[], itemIds: string[]) {
|
||||
const next = inventory.map((slot) => ({ ...slot }))
|
||||
for (const itemId of itemIds) {
|
||||
const existing = next.find((slot) => slot.itemId === itemId)
|
||||
if (existing) existing.count += 1
|
||||
else next.push({ itemId, count: 1 })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function removeInventoryItem(inventory: InvSlot[], itemId: string) {
|
||||
const next = inventory.map((slot) => ({ ...slot }))
|
||||
const slot = next.find((candidate) => candidate.itemId === itemId)
|
||||
if (!slot) return next
|
||||
slot.count -= 1
|
||||
return next.filter((candidate) => candidate.count > 0)
|
||||
}
|
||||
|
||||
function getInventoryCount(inventory: InvSlot[], itemId: string) {
|
||||
return inventory.find((slot) => slot.itemId === itemId)?.count ?? 0
|
||||
}
|
||||
|
||||
function normalizePlayerClass(value: unknown): PlayerClass {
|
||||
const classes: PlayerClass[] = ['warrior', 'paladin', 'hunter', 'rogue', 'priest', 'shaman', 'mage', 'warlock', 'druid']
|
||||
return classes.includes(value as PlayerClass) ? value as PlayerClass : DEFAULT_ACTION_CHARACTER.classId
|
||||
}
|
||||
|
||||
function createEmptyCoinWallet(): ActionCoinWallet {
|
||||
return {
|
||||
bulldrome: { white: 0, green: 0, blue: 0, purple: 0 },
|
||||
'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 },
|
||||
}
|
||||
return ACTION_GEAR_SOURCES.reduce((wallet, source) => {
|
||||
wallet[source] = { white: 0, green: 0, blue: 0, purple: 0 }
|
||||
return wallet
|
||||
}, {} as ActionCoinWallet)
|
||||
}
|
||||
|
||||
function normalizeCoinWallet(parsed: Partial<ActionCharacter>) {
|
||||
const wallet = createEmptyCoinWallet()
|
||||
const saved = parsed.actionCoins
|
||||
for (const source of ['bulldrome', 'yian-kut-ku'] as const) {
|
||||
for (const source of ACTION_GEAR_SOURCES) {
|
||||
for (const color of ['white', 'green', 'blue', 'purple'] as const) {
|
||||
wallet[source][color] = Number(saved?.[source]?.[color] ?? 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { EnemyKind } from './actionBoss/actionCombatSimulation'
|
||||
|
||||
export type ActionModelAssetCategory = 'action-bosses' | 'claudecraft-players' | 'claudecraft-mobs' | 'downloaded-dungeon-props' | 'downloaded-monsters' | 'downloaded-previews' | 'kaykit-skeletons' | 'opengameart-mobs'
|
||||
|
||||
type ActionModelFileFormat = 'dae' | 'gltf' | 'obj'
|
||||
|
||||
export type ActionFileModelAsset = {
|
||||
category: ActionModelAssetCategory
|
||||
defaultAnimation?: string
|
||||
format: ActionModelFileFormat
|
||||
id: string
|
||||
label: string
|
||||
materialVariant?: 'black' | 'emerald' | 'frost' | 'red'
|
||||
mtlUrl?: string
|
||||
source: string
|
||||
type: 'file'
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ActionProceduralModelAsset = {
|
||||
category: ActionModelAssetCategory
|
||||
enemyKind: EnemyKind
|
||||
id: string
|
||||
label: string
|
||||
source: string
|
||||
type: 'procedural'
|
||||
}
|
||||
|
||||
export type ActionModelAsset = ActionFileModelAsset | ActionProceduralModelAsset
|
||||
|
||||
export type ActionWeaponAsset = {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const CLAUDECRAFT_ROOT = '/action-assets/models/claudecraft'
|
||||
const DOWNLOADED_ROOT = '/action-assets/models/downloaded'
|
||||
const KAYKIT_DUNGEON_ROOT = `${DOWNLOADED_ROOT}/kaykit-dungeon-remastered`
|
||||
const KAYKIT_ROOT = '/action-assets/models/kaykit-skeletons/characters'
|
||||
const OPENGAMEART_ROOT = '/action-assets/models/opengameart'
|
||||
const YUGIOH_ROOT = `${DOWNLOADED_ROOT}/yugioh`
|
||||
|
||||
export const ACTION_MODEL_CATEGORIES: Array<{ id: ActionModelAssetCategory; label: string }> = [
|
||||
{ id: 'claudecraft-players', label: 'ClaudeCraft Players' },
|
||||
{ id: 'claudecraft-mobs', label: 'ClaudeCraft Mobs' },
|
||||
{ id: 'opengameart-mobs', label: 'OpenGameArt Mobs' },
|
||||
{ id: 'downloaded-monsters', label: 'Downloaded Monsters' },
|
||||
{ id: 'downloaded-dungeon-props', label: 'Downloaded Dungeon Props' },
|
||||
{ id: 'downloaded-previews', label: 'Downloaded Previews' },
|
||||
{ id: 'action-bosses', label: 'Current Bosses' },
|
||||
{ id: 'kaykit-skeletons', label: 'KayKit Skeletons' },
|
||||
]
|
||||
|
||||
export const ACTION_MODEL_ASSETS: ActionModelAsset[] = [
|
||||
model('claudecraft-players', 'woc-knight', 'Knight', `${CLAUDECRAFT_ROOT}/chars/players/knight.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-paladin', 'Paladin', `${CLAUDECRAFT_ROOT}/chars/players/paladin.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-ranger', 'Ranger', `${CLAUDECRAFT_ROOT}/chars/players/ranger.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-rogue', 'Rogue', `${CLAUDECRAFT_ROOT}/chars/players/rogue.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-rogue-hooded', 'Rogue Hooded', `${CLAUDECRAFT_ROOT}/chars/players/rogue_hooded.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-mage', 'Mage', `${CLAUDECRAFT_ROOT}/chars/players/mage.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-mage-classic', 'Mage Classic', `${CLAUDECRAFT_ROOT}/chars/players/mage_classic.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-druid', 'Druid', `${CLAUDECRAFT_ROOT}/chars/players/druid.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-barbarian', 'Barbarian', `${CLAUDECRAFT_ROOT}/chars/players/barbarian.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-players', 'woc-combat-mech', 'Combat Mech', `${CLAUDECRAFT_ROOT}/chars/players/CombatMech.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
|
||||
model('claudecraft-mobs', 'woc-wild-boar', 'Wild Boar', `${CLAUDECRAFT_ROOT}/creatures/wild_boar.glb`, 'World of ClaudeCraft', 'Idle1'),
|
||||
model('claudecraft-mobs', 'woc-wolf', 'Wolf', `${CLAUDECRAFT_ROOT}/creatures/wolf.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-spider', 'Spider', `${CLAUDECRAFT_ROOT}/creatures/spider.glb`, 'World of ClaudeCraft', 'Spider_Idle'),
|
||||
model('claudecraft-mobs', 'woc-velociraptor', 'Velociraptor', `${CLAUDECRAFT_ROOT}/creatures/velociraptor.glb`, 'World of ClaudeCraft', 'Velociraptor_Idle'),
|
||||
model('claudecraft-mobs', 'woc-dragon-evolved', 'Dragon Evolved', `${CLAUDECRAFT_ROOT}/creatures/dragonevolved.glb`, 'World of ClaudeCraft', 'Flying_Idle'),
|
||||
model('claudecraft-mobs', 'woc-golem-evolved', 'Goleling Evolved', `${CLAUDECRAFT_ROOT}/creatures/golelingevolved.glb`, 'World of ClaudeCraft', 'Flying_Idle'),
|
||||
model('claudecraft-mobs', 'woc-goblin', 'Goblin', `${CLAUDECRAFT_ROOT}/creatures/goblin.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-orc', 'Orc', `${CLAUDECRAFT_ROOT}/creatures/orc.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-giant', 'Giant', `${CLAUDECRAFT_ROOT}/creatures/giant.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-yeti', 'Yeti', `${CLAUDECRAFT_ROOT}/creatures/yetialt.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-necromancer', 'Necromancer', `${CLAUDECRAFT_ROOT}/chars/enemies/necromancer.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-skeleton-warrior', 'Skeleton Warrior', `${CLAUDECRAFT_ROOT}/chars/enemies/skeleton_warrior.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
model('claudecraft-mobs', 'woc-skeleton-mage', 'Skeleton Mage', `${CLAUDECRAFT_ROOT}/chars/enemies/skeleton_mage.glb`, 'World of ClaudeCraft', 'Idle_Combat'),
|
||||
model('claudecraft-mobs', 'woc-skeleton-golem', 'Skeleton Golem Boss', `${CLAUDECRAFT_ROOT}/chars/enemies/skeleton_golem.glb`, 'World of ClaudeCraft', 'Idle'),
|
||||
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon', 'Cethiel Dragon Original', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-animated.glb`, 'OpenGameArt CC0 - Cethiel and Drummyfish', 'Dragon_Idle'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-red', 'Cethiel Dragon Red', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-animated.glb`, 'OpenGameArt CC0 - color preview', 'Dragon_Idle', 'red'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-black', 'Cethiel Dragon Black', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-animated.glb`, 'OpenGameArt CC0 - color preview', 'Dragon_Idle', 'black'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-frost', 'Cethiel Dragon Frost', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-animated.glb`, 'OpenGameArt CC0 - color preview', 'Dragon_Idle', 'frost'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-emerald', 'Cethiel Dragon Emerald', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-animated.glb`, 'OpenGameArt CC0 - color preview', 'Dragon_Idle', 'emerald'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-idle', 'Cethiel Dragon Idle Anim', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-idle-anim.glb`, 'OpenGameArt CC0 - animation preview', 'Armature_Armature'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-walk', 'Cethiel Dragon Walk Anim', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-walk-anim.glb`, 'OpenGameArt CC0 - animation preview', 'Armature_Armature'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-attack', 'Cethiel Dragon Attack Anim', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-attack-anim.glb`, 'OpenGameArt CC0 - animation preview', 'Armature_Armature'),
|
||||
model('opengameart-mobs', 'oga-cethiels-dragon-die', 'Cethiel Dragon Death Anim', `${OPENGAMEART_ROOT}/dragon-oga/dragon-oga-die-anim.glb`, 'OpenGameArt CC0 - animation preview', 'Armature_Armature'),
|
||||
|
||||
daeModel('downloaded-monsters', 'downloaded-blue-eyes-white-dragon', 'Blue-Eyes White Dragon', `${YUGIOH_ROOT}/blue-eyes-white-dragon/MMD_004007.dae`, 'Downloaded DAE monster model'),
|
||||
daeModel('downloaded-monsters', 'downloaded-blue-eyes-ultimate-dragon', 'Blue-Eyes Ultimate Dragon', `${YUGIOH_ROOT}/blue-eyes-ultimate-dragon/MMD_004386.dae`, 'Downloaded DAE monster model'),
|
||||
daeModel('downloaded-monsters', 'downloaded-red-eyes-black-dragon', 'Red-Eyes Black Dragon', `${YUGIOH_ROOT}/red-eyes-black-dragon/MMD_004088.dae`, 'Downloaded DAE monster model'),
|
||||
objModel('downloaded-monsters', 'downloaded-big-eyes-red-dragon', 'Big Eyes Red Dragon', `${DOWNLOADED_ROOT}/big-eyes-red-dragon/redeyesdragon.obj`, 'Downloaded OBJ monster model', `${DOWNLOADED_ROOT}/big-eyes-red-dragon/redeyesdragon.mtl`),
|
||||
daeModel('downloaded-monsters', 'downloaded-gandora-dragon', 'Gandora Dragon', `${YUGIOH_ROOT}/gandora-the-dragon-of-destruction/MMD_006076.dae`, 'Downloaded DAE monster model'),
|
||||
model('downloaded-monsters', 'downloaded-black-dragon-animated', 'Black Dragon Animated', `${DOWNLOADED_ROOT}/black-dragon-new/black-dragon-baked.glb`, 'Downloaded FBX monster model converted to GLB', 'Armature|Idel_New'),
|
||||
model('downloaded-monsters', 'downloaded-low-poly-spider-animated', 'Low-Poly Spider Animated', `${DOWNLOADED_ROOT}/low-poly-spider/low-poly-spider.glb`, 'Downloaded FBX monster model converted to GLB', 'Spider_Armature|warte_pose'),
|
||||
daeModel('downloaded-monsters', 'downloaded-gate-guardian', 'Gate Guardian', `${YUGIOH_ROOT}/gate-guardian/MMD_004380.dae`, 'Downloaded DAE monster model'),
|
||||
daeModel('downloaded-monsters', 'downloaded-insect-queen', 'Insect Queen', `${YUGIOH_ROOT}/insect-queen/MMD_004768.dae`, 'Downloaded DAE monster model'),
|
||||
daeModel('downloaded-monsters', 'downloaded-pumpking', 'Pumpking the King of Ghosts', `${YUGIOH_ROOT}/pumpking-the-king-of-ghosts/MMD_004105.dae`, 'Downloaded DAE monster model'),
|
||||
|
||||
...downloadedDungeonProps(
|
||||
'banner_blue',
|
||||
'barrel_large',
|
||||
'barrel_large_decorated',
|
||||
'barrel_small_stack',
|
||||
'barrier_column',
|
||||
'bed_decorated',
|
||||
'box_stacked',
|
||||
'candle_lit',
|
||||
'candle_triple',
|
||||
'chair',
|
||||
'chest',
|
||||
'chest_gold',
|
||||
'coin_stack_large',
|
||||
'column',
|
||||
'crates_stacked',
|
||||
'floor_tile_big_grate_open',
|
||||
'floor_tile_big_spikes',
|
||||
'keyring_hanging',
|
||||
'pillar',
|
||||
'pillar_decorated',
|
||||
'rubble_large',
|
||||
'shelf_small_candles',
|
||||
'stairs_long',
|
||||
'stairs_wide',
|
||||
'sword_shield_gold',
|
||||
'table_long_decorated_A',
|
||||
'torch_lit',
|
||||
'torch_mounted',
|
||||
'wall_archedwindow_open',
|
||||
'wall_broken',
|
||||
'wall_doorway',
|
||||
'wall_gated',
|
||||
'wall_window_open',
|
||||
),
|
||||
|
||||
model('downloaded-previews', 'ual1-standard', 'UAL 1 Mannequin Animations', `${DOWNLOADED_ROOT}/ual/UAL1_Standard.glb`, 'Universal Animation Library / Quaternius CC0', 'Idle_Loop'),
|
||||
model('downloaded-previews', 'ual1-standard-rm', 'UAL 1 Root Motion Animations', `${DOWNLOADED_ROOT}/ual/UAL1_Standard_RM.glb`, 'Universal Animation Library / Quaternius CC0 - root motion', 'Jog_Fwd_Loop'),
|
||||
model('downloaded-previews', 'ual2-standard', 'UAL 2 Mannequin Animations', `${DOWNLOADED_ROOT}/ual/UAL2_Standard.glb`, 'Universal Animation Library 2 / Quaternius CC0', 'Idle_Shield_Loop'),
|
||||
model('downloaded-previews', 'ual2-standard-rm', 'UAL 2 Root Motion Animations', `${DOWNLOADED_ROOT}/ual/UAL2_Standard_RM.glb`, 'Universal Animation Library 2 / Quaternius CC0 - root motion', 'Melee_Hook'),
|
||||
model('downloaded-previews', 'ual2-female-mannequin', 'UAL Female Mannequin', `${DOWNLOADED_ROOT}/ual/Mannequin_F.glb`, 'Universal Animation Library 2 / Quaternius CC0'),
|
||||
model('downloaded-previews', 'kaykit-mannequin-medium', 'KayKit Medium Mannequin', `${DOWNLOADED_ROOT}/kaykit-character-animations/mannequin/Mannequin_Medium.glb`, 'KayKit Character Animations CC0'),
|
||||
model('downloaded-previews', 'kaykit-mannequin-large', 'KayKit Large Mannequin', `${DOWNLOADED_ROOT}/kaykit-character-animations/mannequin/Mannequin_Large.glb`, 'KayKit Character Animations CC0'),
|
||||
model('downloaded-previews', 'kaykit-medium-combat-melee', 'KayKit Medium Melee Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_CombatMelee.glb`, 'KayKit Character Animations CC0', 'Melee_1H_Attack_Chop'),
|
||||
model('downloaded-previews', 'kaykit-medium-combat-ranged', 'KayKit Medium Ranged Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_CombatRanged.glb`, 'KayKit Character Animations CC0', 'Ranged_Magic_Spellcasting'),
|
||||
model('downloaded-previews', 'kaykit-medium-general', 'KayKit Medium General Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_General.glb`, 'KayKit Character Animations CC0', 'Idle_A'),
|
||||
model('downloaded-previews', 'kaykit-medium-movement-basic', 'KayKit Medium Basic Movement', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_MovementBasic.glb`, 'KayKit Character Animations CC0', 'Running_A'),
|
||||
model('downloaded-previews', 'kaykit-medium-movement-advanced', 'KayKit Medium Advanced Movement', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_MovementAdvanced.glb`, 'KayKit Character Animations CC0', 'Dodge_Forward'),
|
||||
model('downloaded-previews', 'kaykit-medium-special', 'KayKit Medium Special Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_Special.glb`, 'KayKit Character Animations CC0', 'Skeletons_Idle'),
|
||||
model('downloaded-previews', 'kaykit-medium-simulation', 'KayKit Medium Simulation Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_Simulation.glb`, 'KayKit Character Animations CC0', 'Cheering'),
|
||||
model('downloaded-previews', 'kaykit-medium-tools', 'KayKit Medium Tool Animations', `${DOWNLOADED_ROOT}/kaykit-character-animations/rig-medium/Rig_Medium_Tools.glb`, 'KayKit Character Animations CC0', 'Chopping'),
|
||||
|
||||
model('action-bosses', 'action-bulldrome', 'Bulldrome', `${CLAUDECRAFT_ROOT}/creatures/wild_boar.glb`, 'Action Mode boss model', 'Idle1'),
|
||||
procedural('action-bosses', 'action-bullfango', 'Bullfango', 'bullfango'),
|
||||
procedural('action-bosses', 'action-yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku'),
|
||||
procedural('action-bosses', 'action-cyber-dragon', 'Cyber Dragon', 'cyber-dragon'),
|
||||
|
||||
model('kaykit-skeletons', 'kaykit-warrior', 'Skeleton Warrior', `${KAYKIT_ROOT}/Skeleton_Warrior.glb`, 'KayKit Skeletons', 'Idle_Combat'),
|
||||
model('kaykit-skeletons', 'kaykit-rogue', 'Skeleton Rogue', `${KAYKIT_ROOT}/Skeleton_Rogue.glb`, 'KayKit Skeletons', 'Idle_Combat'),
|
||||
model('kaykit-skeletons', 'kaykit-mage', 'Skeleton Mage', `${KAYKIT_ROOT}/Skeleton_Mage.glb`, 'KayKit Skeletons', 'Spellcasting'),
|
||||
model('kaykit-skeletons', 'kaykit-minion', 'Skeleton Minion', `${KAYKIT_ROOT}/Skeleton_Minion.glb`, 'KayKit Skeletons', 'Idle_Combat'),
|
||||
]
|
||||
|
||||
export const ACTION_WEAPON_ASSETS: ActionWeaponAsset[] = [
|
||||
{ id: 'none', label: 'None', url: '' },
|
||||
{ id: 'sword-1h', label: 'Sword 1H', url: `${CLAUDECRAFT_ROOT}/weapons/sword_1handed.glb` },
|
||||
{ id: 'sword-2h', label: 'Sword 2H', url: `${CLAUDECRAFT_ROOT}/weapons/sword_2handed.glb` },
|
||||
{ id: 'dagger', label: 'Dagger', url: `${CLAUDECRAFT_ROOT}/weapons/dagger.glb` },
|
||||
{ id: 'axe-1h', label: 'Axe 1H', url: `${CLAUDECRAFT_ROOT}/weapons/axe_1handed.glb` },
|
||||
{ id: 'axe-2h', label: 'Axe 2H', url: `${CLAUDECRAFT_ROOT}/weapons/axe_2handed.glb` },
|
||||
{ id: 'staff', label: 'Staff', url: `${CLAUDECRAFT_ROOT}/weapons/staff.glb` },
|
||||
{ id: 'crossbow', label: 'Crossbow', url: `${CLAUDECRAFT_ROOT}/weapons/crossbow_1handed.glb` },
|
||||
{ id: 'wand', label: 'Wand', url: `${CLAUDECRAFT_ROOT}/weapons/wand.glb` },
|
||||
{ id: 'shield-round', label: 'Round Shield', url: `${CLAUDECRAFT_ROOT}/weapons/shield_round.glb` },
|
||||
{ id: 'skeleton-blade', label: 'Skeleton Blade', url: `${CLAUDECRAFT_ROOT}/weapons/skeleton_blade.glb` },
|
||||
{ id: 'skeleton-staff', label: 'Skeleton Staff', url: `${CLAUDECRAFT_ROOT}/weapons/skeleton_staff.glb` },
|
||||
{ id: 'halberd', label: 'Halberd', url: `${CLAUDECRAFT_ROOT}/weapons/halberd.glb` },
|
||||
{ id: 'scythe', label: 'Scythe', url: `${CLAUDECRAFT_ROOT}/weapons/scythe.glb` },
|
||||
]
|
||||
|
||||
function model(
|
||||
category: ActionModelAssetCategory,
|
||||
id: string,
|
||||
label: string,
|
||||
url: string,
|
||||
source: string,
|
||||
defaultAnimation?: string,
|
||||
materialVariant?: ActionFileModelAsset['materialVariant'],
|
||||
): ActionFileModelAsset {
|
||||
return { category, defaultAnimation, format: 'gltf', id, label, materialVariant, source, type: 'file', url }
|
||||
}
|
||||
|
||||
function daeModel(
|
||||
category: ActionModelAssetCategory,
|
||||
id: string,
|
||||
label: string,
|
||||
url: string,
|
||||
source: string,
|
||||
): ActionFileModelAsset {
|
||||
return { category, format: 'dae', id, label, source, type: 'file', url }
|
||||
}
|
||||
|
||||
function objModel(
|
||||
category: ActionModelAssetCategory,
|
||||
id: string,
|
||||
label: string,
|
||||
url: string,
|
||||
source: string,
|
||||
mtlUrl?: string,
|
||||
): ActionFileModelAsset {
|
||||
return { category, format: 'obj', id, label, mtlUrl, source, type: 'file', url }
|
||||
}
|
||||
|
||||
function downloadedDungeonProps(...ids: string[]): ActionFileModelAsset[] {
|
||||
return ids.map((id) => model(
|
||||
'downloaded-dungeon-props',
|
||||
`kaykit-dungeon-${id.toLowerCase().replace(/_/g, '-')}`,
|
||||
toLabel(id),
|
||||
`${KAYKIT_DUNGEON_ROOT}/${id}.gltf`,
|
||||
'KayKit Dungeon Remastered CC0',
|
||||
))
|
||||
}
|
||||
|
||||
function toLabel(id: string) {
|
||||
return id
|
||||
.split('_')
|
||||
.map((part) => part.length === 1 ? part : `${part[0].toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function procedural(
|
||||
category: ActionModelAssetCategory,
|
||||
id: string,
|
||||
label: string,
|
||||
enemyKind: EnemyKind,
|
||||
): ActionProceduralModelAsset {
|
||||
return { category, enemyKind, id, label, source: 'Action Mode procedural', type: 'procedural' }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ActionModelViewer } from '../components/ActionModelViewer'
|
||||
import '../styles.css'
|
||||
|
||||
function AdminApp() {
|
||||
return (
|
||||
<main className="game-shell action-mode-shell">
|
||||
<section className="content-screen action-mode-screen">
|
||||
<div className="screen-heading action-screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Admin</p>
|
||||
<h1>Model Viewer</h1>
|
||||
</div>
|
||||
</div>
|
||||
<ActionModelViewer />
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AdminApp />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { EnemyKind } from './actionBoss/actionCombatSimulation'
|
||||
|
||||
export type ClaudeCraftRuntimeDungeonId =
|
||||
| 'hollow-crypt'
|
||||
| 'sunken-bastion'
|
||||
| 'gravewyrm-sanctum'
|
||||
| 'abandoned-crypt'
|
||||
| 'nythraxis-raid'
|
||||
|
||||
export type ClaudeCraftRuntimeSpawn = {
|
||||
hp: number
|
||||
kind?: EnemyKind
|
||||
mobId: string
|
||||
name: string
|
||||
radius?: number
|
||||
x: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export type ClaudeCraftRuntimeLayout = {
|
||||
entry: { x: number; z: number }
|
||||
scale: number
|
||||
source: ClaudeCraftRuntimeLayoutSource
|
||||
xMin: number
|
||||
xMax: number
|
||||
zMin: number
|
||||
zMax: number
|
||||
}
|
||||
|
||||
export type ClaudeCraftRuntimeLayoutSource = {
|
||||
dais: { x: number; z: number; r: number }
|
||||
endWallHw?: number
|
||||
pillars: Array<{ x: number; z: number }>
|
||||
sideWallHd: number
|
||||
sideWallZ: number
|
||||
stubs: Array<{ x: number; z: number; hw: number; hd: number }>
|
||||
tombs: Array<{ x: number; z: number }>
|
||||
wallX?: number
|
||||
}
|
||||
|
||||
export type ClaudeCraftRuntimeCollider =
|
||||
| { type: 'rect'; x: number; y: number; halfWidth: number; halfHeight: number }
|
||||
| { type: 'circle'; x: number; y: number; radius: number }
|
||||
|
||||
export type ClaudeCraftRuntimeDungeon = {
|
||||
finalBossName: string
|
||||
id: ClaudeCraftRuntimeDungeonId
|
||||
layout: ClaudeCraftRuntimeLayout
|
||||
name: string
|
||||
raid: boolean
|
||||
sourceDungeonId: string
|
||||
spawns: ClaudeCraftRuntimeSpawn[]
|
||||
}
|
||||
|
||||
const CRYPT_LAYOUT: ClaudeCraftRuntimeLayout = {
|
||||
entry: { x: 0, z: 4 },
|
||||
scale: 5,
|
||||
source: {
|
||||
dais: { x: 0, z: 96, r: 9.5 },
|
||||
pillars: grid(10, 100, 15, [-14, 14]),
|
||||
sideWallHd: 66,
|
||||
sideWallZ: 47,
|
||||
stubs: [],
|
||||
tombs: grid(16, 92, 19, [-19, 19]),
|
||||
},
|
||||
xMin: -23,
|
||||
xMax: 23,
|
||||
zMin: -19,
|
||||
zMax: 112,
|
||||
}
|
||||
|
||||
const SANCTUM_LAYOUT: ClaudeCraftRuntimeLayout = {
|
||||
entry: { x: 0, z: 4 },
|
||||
scale: 5,
|
||||
source: {
|
||||
dais: { x: 0, z: 146, r: 11.5 },
|
||||
pillars: [
|
||||
...grid(10, 55, 15, [-14, 14]),
|
||||
...grid(85, 100, 15, [-14, 14]),
|
||||
...grid(125, 140, 15, [-14, 14]),
|
||||
],
|
||||
sideWallHd: 89,
|
||||
sideWallZ: 69.5,
|
||||
stubs: [
|
||||
{ x: -14, z: 67, hw: 9, hd: 5 },
|
||||
{ x: 14, z: 67, hw: 9, hd: 5 },
|
||||
{ x: -14, z: 115, hw: 9, hd: 3 },
|
||||
{ x: 14, z: 115, hw: 9, hd: 3 },
|
||||
],
|
||||
tombs: [],
|
||||
},
|
||||
xMin: -23,
|
||||
xMax: 23,
|
||||
zMin: -19,
|
||||
zMax: 158,
|
||||
}
|
||||
|
||||
const NYTHRAXIS_LAYOUT: ClaudeCraftRuntimeLayout = {
|
||||
entry: { x: 0, z: 4 },
|
||||
scale: 3,
|
||||
source: {
|
||||
dais: { x: 0, z: 96, r: 13.5 },
|
||||
endWallHw: 231,
|
||||
pillars: [
|
||||
...points([18, 38, 60, 82, 106], [-90, -45, 45, 90]),
|
||||
],
|
||||
sideWallHd: 73,
|
||||
sideWallZ: 53.5,
|
||||
stubs: [],
|
||||
tombs: [
|
||||
{ x: -210, z: 20 },
|
||||
{ x: 210, z: 20 },
|
||||
{ x: -210, z: 42 },
|
||||
{ x: 210, z: 42 },
|
||||
{ x: -210, z: 64 },
|
||||
{ x: 210, z: 64 },
|
||||
],
|
||||
wallX: 230,
|
||||
},
|
||||
xMin: -230,
|
||||
xMax: 230,
|
||||
zMin: -19,
|
||||
zMax: 126,
|
||||
}
|
||||
|
||||
export const CLAUDECRAFT_RUNTIME_DUNGEONS: Record<ClaudeCraftRuntimeDungeonId, ClaudeCraftRuntimeDungeon> = {
|
||||
'hollow-crypt': {
|
||||
finalBossName: 'Morthen the Gravecaller',
|
||||
id: 'hollow-crypt',
|
||||
layout: CRYPT_LAYOUT,
|
||||
name: 'The Hollow Crypt',
|
||||
raid: false,
|
||||
sourceDungeonId: 'hollow_crypt',
|
||||
spawns: [
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, -3, 18),
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, 3, 19),
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, -9, 38),
|
||||
spawn('hollow_acolyte', 'Hollow Acolyte', 170, -5, 39),
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, 9, 54),
|
||||
spawn('hollow_acolyte', 'Hollow Acolyte', 170, 5, 55),
|
||||
spawn('bonechill_widow', 'Bonechill Widow', 200, -5, 68),
|
||||
spawn('bonechill_widow', 'Bonechill Widow', 200, -1, 70),
|
||||
spawn('sexton_marrow', 'Sexton Marrow', 326, -4, 82, 'claudecraft-mob', 24),
|
||||
spawn('hollow_acolyte', 'Hollow Acolyte', 170, 1, 83),
|
||||
spawn('morthen', 'Morthen the Gravecaller', 550, 0, 98, 'morthen-the-gravecaller', 32),
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, -4, 96),
|
||||
spawn('crypt_shambler', 'Crypt Shambler', 190, 4, 96),
|
||||
],
|
||||
},
|
||||
'sunken-bastion': {
|
||||
finalBossName: 'Vael the Mistcaller',
|
||||
id: 'sunken-bastion',
|
||||
layout: CRYPT_LAYOUT,
|
||||
name: 'The Sunken Bastion',
|
||||
raid: false,
|
||||
sourceDungeonId: 'sunken_bastion',
|
||||
spawns: [
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, -3, 18),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, 3, 19),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, -9, 38),
|
||||
spawn('tidebound_acolyte', 'Tidebound Acolyte', 310, -5, 39),
|
||||
spawn('tidebound_acolyte', 'Tidebound Acolyte', 310, 9, 54),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, 5, 55),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, -5, 68),
|
||||
spawn('tidebound_acolyte', 'Tidebound Acolyte', 310, -1, 70),
|
||||
spawn('knight_commander_olen', 'Knight-Commander Olen', 458, -4, 82, 'claudecraft-mob', 25),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, 1, 83),
|
||||
spawn('vael_the_mistcaller', 'Vael the Mistcaller', 682, 0, 98, 'vael-the-mistcaller', 34),
|
||||
spawn('tidebound_acolyte', 'Tidebound Acolyte', 310, -4, 96),
|
||||
spawn('bastion_revenant', 'Bastion Revenant', 327, 4, 96),
|
||||
],
|
||||
},
|
||||
'gravewyrm-sanctum': {
|
||||
finalBossName: 'Korzul the Gravewyrm',
|
||||
id: 'gravewyrm-sanctum',
|
||||
layout: SANCTUM_LAYOUT,
|
||||
name: 'Gravewyrm Sanctum',
|
||||
raid: false,
|
||||
sourceDungeonId: 'gravewyrm_sanctum',
|
||||
spawns: [
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, -3, 16),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, 3, 17),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, -8, 30),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -4, 31, 'claudecraft-mob', 24),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, 7, 44, 'claudecraft-mob', 24),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, 3, 45),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, -6, 58),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -2, 59, 'claudecraft-mob', 24),
|
||||
spawn('korgath_the_bound', 'Korgath the Bound', 980, 0, 72, 'claudecraft-mob', 34),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -7, 86, 'claudecraft-mob', 24),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, -3, 87),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, 6, 100),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, 2, 101, 'claudecraft-mob', 24),
|
||||
spawn('grand_necromancer_velkhar', 'Grand Necromancer Velkhar', 890, 0, 114, 'morthen-the-gravecaller', 30),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, -4, 112),
|
||||
spawn('sanctum_boneguard', 'Sanctum Boneguard', 501, 4, 112),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -5, 130, 'claudecraft-mob', 24),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -1, 132, 'claudecraft-mob', 24),
|
||||
spawn('korzul_the_gravewyrm', 'Korzul the Gravewyrm', 1380, 0, 146, 'korzul-the-gravewyrm', 42),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, -5, 144, 'claudecraft-mob', 24),
|
||||
spawn('sanctum_drakonid', 'Sanctum Drakonid', 548, 5, 144, 'claudecraft-mob', 24),
|
||||
],
|
||||
},
|
||||
'abandoned-crypt': {
|
||||
finalBossName: 'Sealed Royal Door',
|
||||
id: 'abandoned-crypt',
|
||||
layout: CRYPT_LAYOUT,
|
||||
name: 'Abandoned Crypt',
|
||||
raid: false,
|
||||
sourceDungeonId: 'nythraxis_crypt',
|
||||
spawns: [],
|
||||
},
|
||||
'nythraxis-raid': {
|
||||
finalBossName: 'Nythraxis, Scourge of Thornpeak',
|
||||
id: 'nythraxis-raid',
|
||||
layout: NYTHRAXIS_LAYOUT,
|
||||
name: 'Nythraxis Raid Arena',
|
||||
raid: true,
|
||||
sourceDungeonId: 'nythraxis_boss_arena',
|
||||
spawns: [
|
||||
spawn('nythraxis_scourge_of_thornpeak', 'Nythraxis, Scourge of Thornpeak', 51239, 0, 96, 'korzul-the-gravewyrm', 54),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function isClaudeCraftRuntimeDungeon(dungeonId: string): dungeonId is ClaudeCraftRuntimeDungeonId {
|
||||
return dungeonId in CLAUDECRAFT_RUNTIME_DUNGEONS
|
||||
}
|
||||
|
||||
export function getClaudeCraftRuntimeDungeon(dungeonId: ClaudeCraftRuntimeDungeonId) {
|
||||
return CLAUDECRAFT_RUNTIME_DUNGEONS[dungeonId]
|
||||
}
|
||||
|
||||
export function mapClaudeCraftPoint(dungeonId: ClaudeCraftRuntimeDungeonId, point: { x: number; z: number }) {
|
||||
const dungeon = getClaudeCraftRuntimeDungeon(dungeonId)
|
||||
const padding = getClaudeCraftRuntimePadding()
|
||||
const contentWidth = (dungeon.layout.xMax - dungeon.layout.xMin) * dungeon.layout.scale + padding * 2
|
||||
const contentHeight = (dungeon.layout.zMax - dungeon.layout.zMin) * dungeon.layout.scale + padding * 2
|
||||
const arena = getClaudeCraftRuntimeArena(dungeonId)
|
||||
const xOffset = Math.max(0, (arena.width - contentWidth) / 2)
|
||||
const yOffset = Math.max(0, (arena.height - contentHeight) / 2)
|
||||
return {
|
||||
x: xOffset + padding + (point.x - dungeon.layout.xMin) * dungeon.layout.scale,
|
||||
y: yOffset + padding + (point.z - dungeon.layout.zMin) * dungeon.layout.scale,
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeCraftRuntimeArena(dungeonId: ClaudeCraftRuntimeDungeonId) {
|
||||
const dungeon = getClaudeCraftRuntimeDungeon(dungeonId)
|
||||
const padding = getClaudeCraftRuntimePadding()
|
||||
return {
|
||||
width: Math.max(960, (dungeon.layout.xMax - dungeon.layout.xMin) * dungeon.layout.scale + padding * 2),
|
||||
height: Math.max(820, (dungeon.layout.zMax - dungeon.layout.zMin) * dungeon.layout.scale + padding * 2),
|
||||
padding,
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeCraftRuntimeColliders(dungeonId: ClaudeCraftRuntimeDungeonId): ClaudeCraftRuntimeCollider[] {
|
||||
const dungeon = getClaudeCraftRuntimeDungeon(dungeonId)
|
||||
const layout = dungeon.layout.source
|
||||
const colliders: ClaudeCraftRuntimeCollider[] = []
|
||||
const wallX = layout.wallX ?? 23
|
||||
const endWallHw = layout.endWallHw ?? 24
|
||||
const wallHw = 1
|
||||
|
||||
for (const side of [-1, 1]) {
|
||||
colliders.push(rect(dungeonId, side * wallX, layout.sideWallZ, wallHw, layout.sideWallHd))
|
||||
}
|
||||
colliders.push(rect(dungeonId, 0, dungeon.layout.zMax, endWallHw, wallHw))
|
||||
colliders.push(rect(dungeonId, 0, dungeon.layout.zMin, endWallHw, wallHw))
|
||||
for (const stub of layout.stubs) colliders.push(rect(dungeonId, stub.x, stub.z, stub.hw, stub.hd))
|
||||
for (const pillar of layout.pillars) colliders.push(circle(dungeonId, pillar.x, pillar.z, 1))
|
||||
for (const tomb of layout.tombs) colliders.push(rect(dungeonId, tomb.x, tomb.z, 1.1, 2.1))
|
||||
return colliders
|
||||
}
|
||||
|
||||
function getClaudeCraftRuntimePadding() {
|
||||
return 72
|
||||
}
|
||||
|
||||
function rect(
|
||||
dungeonId: ClaudeCraftRuntimeDungeonId,
|
||||
x: number,
|
||||
z: number,
|
||||
halfWidth: number,
|
||||
halfHeight: number,
|
||||
): ClaudeCraftRuntimeCollider {
|
||||
const dungeon = getClaudeCraftRuntimeDungeon(dungeonId)
|
||||
const point = mapClaudeCraftPoint(dungeonId, { x, z })
|
||||
return {
|
||||
type: 'rect',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
halfWidth: halfWidth * dungeon.layout.scale,
|
||||
halfHeight: halfHeight * dungeon.layout.scale,
|
||||
}
|
||||
}
|
||||
|
||||
function circle(dungeonId: ClaudeCraftRuntimeDungeonId, x: number, z: number, radius: number): ClaudeCraftRuntimeCollider {
|
||||
const dungeon = getClaudeCraftRuntimeDungeon(dungeonId)
|
||||
const point = mapClaudeCraftPoint(dungeonId, { x, z })
|
||||
return {
|
||||
type: 'circle',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
radius: radius * dungeon.layout.scale,
|
||||
}
|
||||
}
|
||||
|
||||
function spawn(
|
||||
mobId: string,
|
||||
name: string,
|
||||
hp: number,
|
||||
x: number,
|
||||
z: number,
|
||||
kind: EnemyKind = 'claudecraft-mob',
|
||||
radius = 20,
|
||||
): ClaudeCraftRuntimeSpawn {
|
||||
return { hp, kind, mobId, name, radius, x, z }
|
||||
}
|
||||
|
||||
function grid(zFrom: number, zTo: number, zStep: number, xs: readonly number[]) {
|
||||
const out: Array<{ x: number; z: number }> = []
|
||||
for (let z = zFrom; z <= zTo; z += zStep) {
|
||||
for (const x of xs) out.push({ x, z })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function points(zs: readonly number[], xs: readonly number[]) {
|
||||
const out: Array<{ x: number; z: number }> = []
|
||||
for (const z of zs) {
|
||||
for (const x of xs) out.push({ x, z })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { LootEntry, MobTemplate } from './claudeCraftTypes'
|
||||
|
||||
function mob(
|
||||
id: string,
|
||||
name: string,
|
||||
level: number,
|
||||
family: MobTemplate['family'],
|
||||
loot: LootEntry[],
|
||||
options: Partial<Pick<MobTemplate, 'boss' | 'elite' | 'scale' | 'color' | 'aggroRadius'>> = {},
|
||||
): MobTemplate {
|
||||
return {
|
||||
aggroRadius: options.aggroRadius ?? (options.boss ? 16 : 12),
|
||||
armorPerLevel: 18,
|
||||
attackSpeed: 2.2,
|
||||
color: options.color ?? 0x839192,
|
||||
dmgBase: options.boss ? 12 : 8,
|
||||
dmgPerLevel: options.boss ? 2.8 : 2.3,
|
||||
elite: options.elite ?? true,
|
||||
family,
|
||||
hpBase: options.boss ? 230 : 54,
|
||||
hpPerLevel: options.boss ? 34 : 20,
|
||||
id,
|
||||
loot,
|
||||
maxLevel: level,
|
||||
minLevel: level,
|
||||
moveSpeed: 7,
|
||||
name,
|
||||
scale: options.scale ?? (options.boss ? 1.35 : 1.05),
|
||||
boss: options.boss,
|
||||
}
|
||||
}
|
||||
|
||||
export const CLAUDECRAFT_DUNGEON_MOBS: Record<string, MobTemplate> = {
|
||||
crypt_shambler: mob('crypt_shambler', 'Crypt Shambler', 8, 'undead', [
|
||||
{ copper: 90, chance: 1 },
|
||||
{ itemId: 'bone_fragments', chance: 0.8 },
|
||||
]),
|
||||
hollow_acolyte: mob('hollow_acolyte', 'Hollow Acolyte', 8, 'undead', [
|
||||
{ copper: 110, chance: 1 },
|
||||
{ itemId: 'linen_scrap', chance: 0.6 },
|
||||
]),
|
||||
bonechill_widow: mob('bonechill_widow', 'Bonechill Widow', 9, 'spider', [
|
||||
{ copper: 120, chance: 1 },
|
||||
{ itemId: 'spider_leg', chance: 0.7 },
|
||||
]),
|
||||
sexton_marrow: mob('sexton_marrow', 'Sexton Marrow', 9, 'undead', [
|
||||
{ copper: 400, chance: 1 },
|
||||
{ itemId: 'quilted_trousers', chance: 0.4 },
|
||||
{ itemId: 'oiled_boots', chance: 0.4 },
|
||||
]),
|
||||
morthen: mob('morthen', 'Morthen the Gravecaller', 10, 'undead', [
|
||||
{ copper: 2500, chance: 1 },
|
||||
{ itemId: 'cryptbone_greaves', chance: 0.34, rollGroup: 'morthen_guaranteed_uncommon' },
|
||||
{ itemId: 'quilted_trousers', chance: 0.33, rollGroup: 'morthen_guaranteed_uncommon' },
|
||||
{ itemId: 'oiled_boots', chance: 0.33, rollGroup: 'morthen_guaranteed_uncommon' },
|
||||
{ itemId: 'greyjaw_hide_boots', chance: 0.25, rollGroup: 'morthen_bonus' },
|
||||
{ itemId: 'cryptbone_helm', chance: 0.18, rollGroup: 'morthen_bonus' },
|
||||
{ itemId: 'cryptbone_pauldrons', chance: 0.18, rollGroup: 'morthen_bonus' },
|
||||
], { boss: true, color: 0x4a235a }),
|
||||
|
||||
bastion_revenant: mob('bastion_revenant', 'Bastion Revenant', 13, 'undead', [
|
||||
{ copper: 150, chance: 1 },
|
||||
{ itemId: 'bone_fragments', chance: 0.7 },
|
||||
{ itemId: 'mistveil_cord', chance: 0.06, rollGroup: 'revenant_bonus' },
|
||||
]),
|
||||
tidebound_acolyte: mob('tidebound_acolyte', 'Tidebound Acolyte', 13, 'humanoid', [
|
||||
{ copper: 170, chance: 1 },
|
||||
{ itemId: 'linen_scrap', chance: 0.5 },
|
||||
{ itemId: 'mistveil_grips', chance: 0.06, rollGroup: 'acolyte_bonus' },
|
||||
]),
|
||||
knight_commander_olen: mob('knight_commander_olen', 'Knight-Commander Olen', 13, 'undead', [
|
||||
{ copper: 800, chance: 1 },
|
||||
{ itemId: 'trollhide_leggings', chance: 0.5, rollGroup: 'olen_guaranteed_uncommon' },
|
||||
{ itemId: 'marshstrider_boots', chance: 0.5, rollGroup: 'olen_guaranteed_uncommon' },
|
||||
{ itemId: 'fenmist_robe', chance: 0.25, rollGroup: 'olen_bonus' },
|
||||
{ itemId: 'tideguard_greaves', chance: 0.1, rollGroup: 'olen_bonus' },
|
||||
{ itemId: 'tideguard_sabatons', chance: 0.1, rollGroup: 'olen_bonus' },
|
||||
{ itemId: 'eelscale_leggings', chance: 0.1, rollGroup: 'olen_bonus' },
|
||||
], { scale: 1.2 }),
|
||||
vael_the_mistcaller: mob('vael_the_mistcaller', 'Vael the Mistcaller', 13, 'humanoid', [
|
||||
{ copper: 5000, chance: 1 },
|
||||
{ itemId: 'trollhide_leggings', chance: 0.34, rollGroup: 'vael_guaranteed_uncommon' },
|
||||
{ itemId: 'marshstrider_boots', chance: 0.33, rollGroup: 'vael_guaranteed_uncommon' },
|
||||
{ itemId: 'fenmist_robe', chance: 0.33, rollGroup: 'vael_guaranteed_uncommon' },
|
||||
{ itemId: 'deepfen_pearl', chance: 1 },
|
||||
{ itemId: 'eelskin_tunic', chance: 0.2, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'tidescale_vest', chance: 0.1, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'drowned_prayer_leggings', chance: 0.1, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'drowned_prayer_sandals', chance: 0.1, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'eelscale_treads', chance: 0.1, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'mistveil_cord', chance: 0.12, rollGroup: 'vael_bonus' },
|
||||
{ itemId: 'mistveil_grips', chance: 0.12, rollGroup: 'vael_bonus' },
|
||||
], { boss: true, color: 0x48c9b0 }),
|
||||
|
||||
sanctum_boneguard: mob('sanctum_boneguard', 'Sanctum Boneguard', 19, 'undead', [
|
||||
{ copper: 300, chance: 1 },
|
||||
{ itemId: 'bone_fragments', chance: 0.6 },
|
||||
{ itemId: 'boundstone_helm', chance: 0.04, rollGroup: 'boneguard_bonus' },
|
||||
{ itemId: 'boundstone_girdle', chance: 0.04, rollGroup: 'boneguard_bonus' },
|
||||
]),
|
||||
sanctum_drakonid: mob('sanctum_drakonid', 'Sanctum Drakonid', 20, 'dragonkin', [
|
||||
{ copper: 350, chance: 1 },
|
||||
{ itemId: 'cracked_wyrm_scale', chance: 0.5 },
|
||||
{ itemId: 'gravewyrm_mantle', chance: 0.05, rollGroup: 'drakonid_bonus' },
|
||||
{ itemId: 'gravewyrm_gauntlets', chance: 0.05, rollGroup: 'drakonid_bonus' },
|
||||
]),
|
||||
korgath_the_bound: mob('korgath_the_bound', 'Korgath the Bound', 20, 'ogre', [
|
||||
{ copper: 5000, chance: 1 },
|
||||
{ itemId: 'boneplate_vest', chance: 0.34, rollGroup: 'korgath_guaranteed_uncommon' },
|
||||
{ itemId: 'revenant_silk_robe', chance: 0.33, rollGroup: 'korgath_guaranteed_uncommon' },
|
||||
{ itemId: 'nightwalk_jerkin', chance: 0.33, rollGroup: 'korgath_guaranteed_uncommon' },
|
||||
{ itemId: 'zealotsbane_blade', chance: 0.2, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'korgaths_chainwraps', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'staff_of_velkhar', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'shadowmeld_tunic', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'wyrmcult_grand_robe', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'gravewyrm_sabatons', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'wyrmcult_soulsteps', chance: 0.1, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'wyrmshadow_treads', chance: 0.05, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'boundstone_helm', chance: 0.08, rollGroup: 'korgath_bonus' },
|
||||
{ itemId: 'gravewyrm_mantle', chance: 0.08, rollGroup: 'korgath_bonus' },
|
||||
], { scale: 1.5 }),
|
||||
grand_necromancer_velkhar: mob('grand_necromancer_velkhar', 'Grand Necromancer Velkhar', 20, 'humanoid', [
|
||||
{ copper: 5000, chance: 1 },
|
||||
{ itemId: 'boneplate_vest', chance: 0.34, rollGroup: 'velkhar_guaranteed_uncommon' },
|
||||
{ itemId: 'revenant_silk_robe', chance: 0.33, rollGroup: 'velkhar_guaranteed_uncommon' },
|
||||
{ itemId: 'nightwalk_jerkin', chance: 0.33, rollGroup: 'velkhar_guaranteed_uncommon' },
|
||||
{ itemId: 'emberwood_staff', chance: 0.2, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'boneguard_breastplate', chance: 0.1, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'shadowmeld_tunic', chance: 0.1, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'staff_of_velkhar', chance: 0.1, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'gravewyrm_stalkers_treads', chance: 0.1, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'deathlord_legguards', chance: 0.05, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'necromancers_soulsteps', chance: 0.05, rollGroup: 'velkhar_bonus' },
|
||||
{ itemId: 'wyrmshadow_legguards', chance: 0.05, rollGroup: 'velkhar_bonus' },
|
||||
], { scale: 1.25 }),
|
||||
korzul_the_gravewyrm: mob('korzul_the_gravewyrm', 'Korzul the Gravewyrm', 20, 'dragonkin', [
|
||||
{ copper: 50000, chance: 1 },
|
||||
{ itemId: 'boneplate_vest', chance: 0.34, rollGroup: 'korzul_guaranteed_uncommon' },
|
||||
{ itemId: 'revenant_silk_robe', chance: 0.33, rollGroup: 'korzul_guaranteed_uncommon' },
|
||||
{ itemId: 'nightwalk_jerkin', chance: 0.33, rollGroup: 'korzul_guaranteed_uncommon' },
|
||||
{ itemId: 'cultist_flayer', chance: 0.1, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'wyrmfang_greatblade', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'staff_of_the_gravewyrm', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'fang_of_korzul', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'deathlord_warplate', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'necromancers_starshroud', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'wyrmshadow_harness', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'boundstone_girdle', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'gravewyrm_gauntlets', chance: 0.05, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'deathlords_dread_visage', chance: 0.04, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'necromancers_soulspire_mantle', chance: 0.04, rollGroup: 'korzul_bonus' },
|
||||
{ itemId: 'wyrmshadow_talongrips', chance: 0.04, rollGroup: 'korzul_bonus' },
|
||||
], { boss: true, scale: 1.8, color: 0x3d5c45 }),
|
||||
nythraxis_scourge_of_thornpeak: mob('nythraxis_scourge_of_thornpeak', 'Nythraxis, Scourge of Thornpeak', 20, 'undead', [
|
||||
{ copper: 150000, chance: 1 },
|
||||
{ itemId: 'deathless_heartwood', chance: 0.03, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'crownforged_dreadhelm', chance: 0.17, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'nighttalon_crown', chance: 0.16, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'soulflame_cowl', chance: 0.16, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'stormcallers_crown', chance: 0.16, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'nighttalon_shoulderguards', chance: 0.16, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'soulflame_mantle', chance: 0.16, rollGroup: 'nythraxis_drop_1' },
|
||||
{ itemId: 'kingsbane_last_oath', chance: 0.03, rollGroup: 'nythraxis_drop_2' },
|
||||
{ itemId: 'crownforged_warspaulders', chance: 0.17, rollGroup: 'nythraxis_drop_2' },
|
||||
{ itemId: 'nighttalon_shoulderguards', chance: 0.16, rollGroup: 'nythraxis_drop_2' },
|
||||
{ itemId: 'soulflame_mantle', chance: 0.16, rollGroup: 'nythraxis_drop_2' },
|
||||
{ itemId: 'stormcallers_spaulders', chance: 0.16, rollGroup: 'nythraxis_drop_2' },
|
||||
], { boss: true, scale: 3.1, color: 0x221b2d }),
|
||||
}
|
||||
|
||||
export const ACTION_DUNGEON_LOOT_MOBS: Record<string, string[]> = {
|
||||
bulldrome: ['crypt_shambler', 'sexton_marrow'],
|
||||
'yian-kut-ku': ['bonechill_widow', 'morthen'],
|
||||
'cyber-dragon': ['sanctum_drakonid', 'korgath_the_bound'],
|
||||
'hollow-crypt': ['crypt_shambler', 'hollow_acolyte', 'bonechill_widow', 'sexton_marrow', 'morthen'],
|
||||
'sunken-bastion': ['bastion_revenant', 'tidebound_acolyte', 'knight_commander_olen', 'vael_the_mistcaller'],
|
||||
'gravewyrm-sanctum': ['sanctum_boneguard', 'sanctum_drakonid', 'korgath_the_bound', 'grand_necromancer_velkhar', 'korzul_the_gravewyrm'],
|
||||
'abandoned-crypt': ['crypt_shambler', 'hollow_acolyte', 'morthen'],
|
||||
'nythraxis-raid': ['nythraxis_scourge_of_thornpeak'],
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ItemDef, PlayerClass } from './claudeCraftTypes'
|
||||
|
||||
export type ArmorType = 'cloth' | 'leather' | 'mail'
|
||||
type WeaponArchetype = 'warrior' | 'caster' | 'rogue'
|
||||
|
||||
const MAIL_CLASSES = new Set<PlayerClass>(['warrior', 'paladin', 'shaman'])
|
||||
const LEATHER_CLASSES = new Set<PlayerClass>(['druid', 'rogue', 'hunter'])
|
||||
const CLOTH_CLASSES = new Set<PlayerClass>(['mage', 'priest', 'warlock'])
|
||||
const CASTER_ARCHETYPE_CLASSES = new Set<PlayerClass>(['mage', 'priest', 'warlock', 'druid'])
|
||||
const WARRIOR_WEAPON_CLASSES = new Set<PlayerClass>(['warrior', 'rogue', 'hunter', 'shaman', 'paladin'])
|
||||
const CASTER_WEAPON_CLASSES = new Set<PlayerClass>(['mage', 'priest', 'warlock', 'shaman', 'paladin', 'druid'])
|
||||
const ROGUE_WEAPON_CLASSES = new Set<PlayerClass>(['rogue', 'hunter'])
|
||||
const OLD_WARRIOR_WEAPON_ARCHETYPE = new Set<PlayerClass>(['warrior', 'paladin', 'shaman'])
|
||||
const OLD_CASTER_WEAPON_ARCHETYPE = new Set<PlayerClass>(['mage', 'priest', 'warlock', 'druid'])
|
||||
|
||||
const ARMOR_RANK: Record<ArmorType, number> = {
|
||||
cloth: 0,
|
||||
leather: 1,
|
||||
mail: 2,
|
||||
}
|
||||
|
||||
function subsetOf(classes: readonly PlayerClass[], allowed: ReadonlySet<PlayerClass>): boolean {
|
||||
return classes.length > 0 && classes.every((cls) => allowed.has(cls))
|
||||
}
|
||||
|
||||
export function armorTypeForItem(item: ItemDef): ArmorType | null {
|
||||
if (item.kind !== 'armor') return null
|
||||
if (item.armorType) return item.armorType
|
||||
if (!item.requiredClass) return null
|
||||
if (subsetOf(item.requiredClass, MAIL_CLASSES)) return 'mail'
|
||||
if (subsetOf(item.requiredClass, LEATHER_CLASSES)) return 'leather'
|
||||
if (subsetOf(item.requiredClass, CLOTH_CLASSES) || subsetOf(item.requiredClass, CASTER_ARCHETYPE_CLASSES)) return 'cloth'
|
||||
return null
|
||||
}
|
||||
|
||||
export function maxArmorTypeForClass(cls: PlayerClass): ArmorType {
|
||||
if (MAIL_CLASSES.has(cls)) return 'mail'
|
||||
if (LEATHER_CLASSES.has(cls)) return 'leather'
|
||||
return 'cloth'
|
||||
}
|
||||
|
||||
export function weaponArchetypeForItem(item: ItemDef): WeaponArchetype | null {
|
||||
if (item.kind !== 'weapon' || !item.requiredClass) return null
|
||||
if (subsetOf(item.requiredClass, OLD_WARRIOR_WEAPON_ARCHETYPE)) return 'warrior'
|
||||
if (subsetOf(item.requiredClass, OLD_CASTER_WEAPON_ARCHETYPE)) return 'caster'
|
||||
if (subsetOf(item.requiredClass, ROGUE_WEAPON_CLASSES)) return 'rogue'
|
||||
return null
|
||||
}
|
||||
|
||||
export function canEquipItem(cls: PlayerClass, item: ItemDef): boolean {
|
||||
const armorType = armorTypeForItem(item)
|
||||
if (armorType) return ARMOR_RANK[armorType] <= ARMOR_RANK[maxArmorTypeForClass(cls)]
|
||||
const weaponArchetype = weaponArchetypeForItem(item)
|
||||
if (weaponArchetype === 'warrior') return WARRIOR_WEAPON_CLASSES.has(cls)
|
||||
if (weaponArchetype === 'caster') return CASTER_WEAPON_CLASSES.has(cls)
|
||||
if (weaponArchetype === 'rogue') return ROGUE_WEAPON_CLASSES.has(cls)
|
||||
if (item.requiredClass) return item.requiredClass.includes(cls)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { EquipSlot, ItemDef, PlayerClass, Stats, WeaponInfo } from './claudeCraftTypes'
|
||||
|
||||
const WAR: PlayerClass[] = ['warrior', 'paladin', 'shaman']
|
||||
const MAG: PlayerClass[] = ['mage', 'priest', 'warlock', 'druid']
|
||||
const ROG: PlayerClass[] = ['rogue', 'hunter']
|
||||
|
||||
type Quality = NonNullable<ItemDef['quality']>
|
||||
|
||||
function weapon(
|
||||
id: string,
|
||||
name: string,
|
||||
quality: Quality,
|
||||
weaponInfo: WeaponInfo,
|
||||
stats: Partial<Stats>,
|
||||
requiredClass: PlayerClass[] | undefined,
|
||||
sellValue: number,
|
||||
): ItemDef {
|
||||
return { id, kind: 'weapon', name, quality, requiredClass, sellValue, slot: 'mainhand', stats, weapon: weaponInfo }
|
||||
}
|
||||
|
||||
function armor(
|
||||
id: string,
|
||||
name: string,
|
||||
slot: Exclude<EquipSlot, 'mainhand'>,
|
||||
quality: Quality,
|
||||
stats: Partial<Stats>,
|
||||
requiredClass: PlayerClass[] | undefined,
|
||||
sellValue: number,
|
||||
): ItemDef {
|
||||
return { id, kind: 'armor', name, quality, requiredClass, sellValue, slot, stats }
|
||||
}
|
||||
|
||||
function junk(id: string, name: string, sellValue: number): ItemDef {
|
||||
return { id, kind: 'junk', name, quality: 'poor', sellValue }
|
||||
}
|
||||
|
||||
export const CLAUDECRAFT_ITEMS: Record<string, ItemDef> = {
|
||||
worn_sword: weapon('worn_sword', 'Worn Shortsword', 'common', { min: 2, max: 5, speed: 2 }, {}, undefined, 10),
|
||||
gnarled_staff: weapon('gnarled_staff', 'Gnarled Staff', 'common', { min: 3, max: 6, speed: 2.9 }, { int: 1 }, MAG, 12),
|
||||
rusty_dagger: weapon('rusty_dagger', 'Rusty Dagger', 'common', { min: 2, max: 4, speed: 1.8, dagger: true }, {}, ROG, 10),
|
||||
recruit_tunic: armor('recruit_tunic', "Recruit's Tunic", 'chest', 'common', { armor: 20 }, WAR, 5),
|
||||
apprentice_robe: armor('apprentice_robe', "Apprentice's Robe", 'chest', 'common', { armor: 8 }, MAG, 5),
|
||||
footpad_jerkin: armor('footpad_jerkin', "Footpad's Jerkin", 'chest', 'common', { armor: 14 }, ROG, 5),
|
||||
|
||||
bone_fragments: junk('bone_fragments', 'Bone Fragments', 16),
|
||||
linen_scrap: junk('linen_scrap', 'Linen Scrap', 12),
|
||||
spider_leg: junk('spider_leg', 'Spider Leg', 10),
|
||||
deepfen_pearl: junk('deepfen_pearl', 'Deepfen Pearl', 80),
|
||||
cracked_wyrm_scale: junk('cracked_wyrm_scale', 'Cracked Wyrm Scale', 90),
|
||||
|
||||
oiled_boots: armor('oiled_boots', 'Oiled Leather Boots', 'feet', 'uncommon', { armor: 25, agi: 1 }, undefined, 80),
|
||||
quilted_trousers: armor('quilted_trousers', 'Quilted Trousers', 'legs', 'uncommon', { armor: 30, sta: 2 }, undefined, 90),
|
||||
greyjaw_hide_boots: armor('greyjaw_hide_boots', 'Greyjaw Hide Boots', 'feet', 'uncommon', { armor: 28, agi: 1, sta: 1 }, undefined, 130),
|
||||
cryptbone_greaves: armor('cryptbone_greaves', 'Cryptbone Greaves', 'legs', 'uncommon', { armor: 75, sta: 3, str: 2 }, WAR, 220),
|
||||
cryptbone_helm: armor('cryptbone_helm', 'Cryptbone Helm', 'helmet', 'uncommon', { armor: 82, sta: 3, str: 2 }, WAR, 240),
|
||||
cryptbone_pauldrons: armor('cryptbone_pauldrons', 'Cryptbone Pauldrons', 'shoulder', 'uncommon', { armor: 70, sta: 2, str: 2 }, WAR, 230),
|
||||
|
||||
trollhide_leggings: armor('trollhide_leggings', 'Trollhide Leggings', 'legs', 'uncommon', { armor: 55, sta: 3, str: 2 }, undefined, 280),
|
||||
marshstrider_boots: armor('marshstrider_boots', 'Marshstrider Boots', 'feet', 'uncommon', { armor: 40, agi: 2, sta: 2 }, undefined, 250),
|
||||
fenmist_robe: armor('fenmist_robe', 'Fenmist Robe', 'chest', 'uncommon', { armor: 45, int: 5, spi: 3 }, MAG, 350),
|
||||
eelskin_tunic: armor('eelskin_tunic', 'Eelskin Tunic', 'chest', 'uncommon', { armor: 80, agi: 5 }, ROG, 350),
|
||||
tidescale_vest: armor('tidescale_vest', 'Tidescale Vest', 'chest', 'rare', { armor: 145, sta: 5, str: 3 }, WAR, 900),
|
||||
drowned_prayer_leggings: armor('drowned_prayer_leggings', 'Drowned Prayer Leggings', 'legs', 'rare', { armor: 58, int: 6, spi: 3 }, MAG, 850),
|
||||
drowned_prayer_sandals: armor('drowned_prayer_sandals', 'Drowned Prayer Sandals', 'feet', 'rare', { armor: 42, int: 5, spi: 3 }, MAG, 780),
|
||||
eelscale_treads: armor('eelscale_treads', 'Eelscale Treads', 'feet', 'rare', { armor: 62, agi: 6, sta: 2 }, ROG, 780),
|
||||
mistveil_cord: armor('mistveil_cord', 'Mistveil Cord', 'waist', 'uncommon', { armor: 28, int: 3, spi: 2 }, MAG, 280),
|
||||
mistveil_grips: armor('mistveil_grips', 'Mistveil Grips', 'gloves', 'uncommon', { armor: 32, int: 3, spi: 2 }, MAG, 300),
|
||||
tideguard_greaves: armor('tideguard_greaves', 'Tideguard Greaves', 'legs', 'rare', { armor: 130, sta: 5, str: 3 }, WAR, 840),
|
||||
tideguard_sabatons: armor('tideguard_sabatons', 'Tideguard Sabatons', 'feet', 'rare', { armor: 105, sta: 4, str: 2 }, WAR, 760),
|
||||
eelscale_leggings: armor('eelscale_leggings', 'Eelscale Leggings', 'legs', 'rare', { armor: 78, agi: 7, sta: 3 }, ROG, 840),
|
||||
|
||||
boundstone_helm: armor('boundstone_helm', 'Boundstone Helm', 'helmet', 'uncommon', { armor: 122, sta: 4, str: 3 }, WAR, 520),
|
||||
boundstone_girdle: armor('boundstone_girdle', 'Boundstone Girdle', 'waist', 'uncommon', { armor: 96, sta: 3, str: 2 }, WAR, 440),
|
||||
gravewyrm_mantle: armor('gravewyrm_mantle', 'Gravewyrm Mantle', 'shoulder', 'uncommon', { armor: 50, int: 4, spi: 3 }, MAG, 480),
|
||||
gravewyrm_gauntlets: armor('gravewyrm_gauntlets', 'Gravewyrm Gauntlets', 'gloves', 'uncommon', { armor: 76, agi: 4, sta: 2 }, ROG, 480),
|
||||
boneplate_vest: armor('boneplate_vest', 'Boneplate Vest', 'chest', 'uncommon', { armor: 170, sta: 5, str: 4 }, WAR, 700),
|
||||
revenant_silk_robe: armor('revenant_silk_robe', 'Revenant Silk Robe', 'chest', 'uncommon', { armor: 62, int: 7, spi: 4 }, MAG, 700),
|
||||
nightwalk_jerkin: armor('nightwalk_jerkin', 'Nightwalk Jerkin', 'chest', 'uncommon', { armor: 108, agi: 7, sta: 3 }, ROG, 700),
|
||||
zealotsbane_blade: weapon('zealotsbane_blade', "Zealotsbane Blade", 'rare', { min: 22, max: 36, speed: 2.4 }, { str: 7, sta: 3 }, WAR, 1600),
|
||||
korgaths_chainwraps: armor('korgaths_chainwraps', "Korgath's Chainwraps", 'gloves', 'rare', { armor: 126, sta: 5, str: 3 }, WAR, 1100),
|
||||
staff_of_velkhar: weapon('staff_of_velkhar', 'Staff of Velkhar', 'rare', { min: 24, max: 40, speed: 3 }, { int: 10, spi: 4 }, MAG, 1800),
|
||||
shadowmeld_tunic: armor('shadowmeld_tunic', 'Shadowmeld Tunic', 'chest', 'rare', { armor: 130, agi: 9, sta: 4 }, ROG, 1300),
|
||||
wyrmcult_grand_robe: armor('wyrmcult_grand_robe', 'Wyrmcult Grand Robe', 'chest', 'rare', { armor: 74, int: 11, spi: 5 }, MAG, 1300),
|
||||
gravewyrm_sabatons: armor('gravewyrm_sabatons', 'Gravewyrm Sabatons', 'feet', 'rare', { armor: 132, sta: 5, str: 4 }, WAR, 1200),
|
||||
wyrmcult_soulsteps: armor('wyrmcult_soulsteps', 'Wyrmcult Soulsteps', 'feet', 'rare', { armor: 48, int: 7, spi: 4 }, MAG, 1100),
|
||||
wyrmshadow_treads: armor('wyrmshadow_treads', 'Wyrmshadow Treads', 'feet', 'rare', { armor: 76, agi: 8, sta: 3 }, ROG, 1100),
|
||||
emberwood_staff: weapon('emberwood_staff', 'Emberwood Staff', 'rare', { min: 23, max: 39, speed: 3 }, { int: 9, spi: 5 }, MAG, 1600),
|
||||
boneguard_breastplate: armor('boneguard_breastplate', 'Boneguard Breastplate', 'chest', 'rare', { armor: 210, sta: 7, str: 4 }, WAR, 1600),
|
||||
gravewyrm_stalkers_treads: armor('gravewyrm_stalkers_treads', "Gravewyrm Stalker's Treads", 'feet', 'rare', { armor: 84, agi: 9, sta: 4 }, ROG, 1300),
|
||||
deathlord_legguards: armor('deathlord_legguards', 'Deathlord Legguards', 'legs', 'epic', { armor: 235, sta: 10, str: 7 }, WAR, 3000),
|
||||
necromancers_soulsteps: armor('necromancers_soulsteps', "Necromancer's Soulsteps", 'feet', 'epic', { armor: 64, int: 13, spi: 8 }, MAG, 3000),
|
||||
wyrmshadow_legguards: armor('wyrmshadow_legguards', 'Wyrmshadow Legguards', 'legs', 'epic', { armor: 118, agi: 13, sta: 6 }, ROG, 3000),
|
||||
cultist_flayer: weapon('cultist_flayer', 'Cultist Flayer', 'rare', { min: 18, max: 29, speed: 1.7, dagger: true }, { agi: 10, sta: 4 }, ROG, 2000),
|
||||
wyrmfang_greatblade: weapon('wyrmfang_greatblade', 'Wyrmfang Greatblade', 'epic', { min: 32, max: 52, speed: 2.8 }, { str: 12, sta: 7 }, WAR, 4200),
|
||||
staff_of_the_gravewyrm: weapon('staff_of_the_gravewyrm', 'Staff of the Gravewyrm', 'epic', { min: 34, max: 56, speed: 3 }, { int: 14, spi: 8 }, MAG, 4200),
|
||||
fang_of_korzul: weapon('fang_of_korzul', 'Fang of Korzul', 'epic', { min: 22, max: 35, speed: 1.7, dagger: true }, { agi: 14, sta: 6 }, ROG, 4200),
|
||||
deathlord_warplate: armor('deathlord_warplate', 'Deathlord Warplate', 'chest', 'epic', { armor: 290, sta: 12, str: 9 }, WAR, 4300),
|
||||
necromancers_starshroud: armor('necromancers_starshroud', "Necromancer's Starshroud", 'chest', 'epic', { armor: 92, int: 15, spi: 9 }, MAG, 4300),
|
||||
wyrmshadow_harness: armor('wyrmshadow_harness', 'Wyrmshadow Harness', 'chest', 'epic', { armor: 150, agi: 15, sta: 7 }, ROG, 4300),
|
||||
deathlords_dread_visage: armor('deathlords_dread_visage', "Deathlord's Dread Visage", 'helmet', 'epic', { armor: 240, sta: 10, str: 7 }, WAR, 3600),
|
||||
necromancers_soulspire_mantle: armor('necromancers_soulspire_mantle', "Necromancer's Soulspire Mantle", 'shoulder', 'epic', { armor: 78, int: 13, spi: 7 }, MAG, 3600),
|
||||
wyrmshadow_talongrips: armor('wyrmshadow_talongrips', 'Wyrmshadow Talongrips', 'gloves', 'epic', { armor: 112, agi: 12, sta: 6 }, ROG, 3600),
|
||||
|
||||
deathless_heartwood: weapon('deathless_heartwood', 'Deathless Heartwood', 'legendary', { min: 42, max: 68, speed: 3 }, { int: 20, spi: 12, sta: 8 }, MAG, 9000),
|
||||
kingsbane_last_oath: weapon("kingsbane_last_oath", "Kingsbane, Last Oath", 'legendary', { min: 40, max: 64, speed: 2.7 }, { str: 18, sta: 10 }, WAR, 9000),
|
||||
crownforged_dreadhelm: armor('crownforged_dreadhelm', 'Crownforged Dreadhelm', 'helmet', 'epic', { armor: 320, sta: 14, str: 10 }, WAR, 5200),
|
||||
crownforged_warspaulders: armor('crownforged_warspaulders', 'Crownforged Warspaulders', 'shoulder', 'epic', { armor: 280, sta: 12, str: 9 }, WAR, 5000),
|
||||
nighttalon_crown: armor('nighttalon_crown', 'Nighttalon Crown', 'helmet', 'epic', { armor: 165, agi: 16, sta: 8 }, ROG, 5200),
|
||||
nighttalon_shoulderguards: armor('nighttalon_shoulderguards', 'Nighttalon Shoulderguards', 'shoulder', 'epic', { armor: 145, agi: 14, sta: 7 }, ROG, 5000),
|
||||
soulflame_cowl: armor('soulflame_cowl', 'Soulflame Cowl', 'helmet', 'epic', { armor: 105, int: 17, spi: 9 }, MAG, 5200),
|
||||
soulflame_mantle: armor('soulflame_mantle', 'Soulflame Mantle', 'shoulder', 'epic', { armor: 94, int: 15, spi: 8 }, MAG, 5000),
|
||||
stormcallers_crown: armor('stormcallers_crown', "Stormcaller's Crown", 'helmet', 'epic', { armor: 250, int: 13, sta: 8 }, ['shaman'], 5200),
|
||||
stormcallers_spaulders: armor('stormcallers_spaulders', "Stormcaller's Spaulders", 'shoulder', 'epic', { armor: 225, int: 12, sta: 7 }, ['shaman'], 5000),
|
||||
}
|
||||
|
||||
export function getClaudeCraftItem(itemId: string) {
|
||||
return CLAUDECRAFT_ITEMS[itemId] ?? null
|
||||
}
|
||||
|
||||
export function getItemStatsText(item: ItemDef) {
|
||||
const stats = item.stats ?? {}
|
||||
const parts = [
|
||||
stats.armor ? `${stats.armor} Armor` : '',
|
||||
stats.str ? `+${stats.str} Str` : '',
|
||||
stats.agi ? `+${stats.agi} Agi` : '',
|
||||
stats.sta ? `+${stats.sta} Sta` : '',
|
||||
stats.int ? `+${stats.int} Int` : '',
|
||||
stats.spi ? `+${stats.spi} Spi` : '',
|
||||
].filter(Boolean)
|
||||
if (item.weapon) parts.unshift(`${item.weapon.min}-${item.weapon.max} Damage, ${item.weapon.speed.toFixed(1)} Speed`)
|
||||
return parts.join(' / ') || 'No combat stats'
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import type { PlayerClass } from './claudeCraftTypes'
|
||||
|
||||
export type TalentTree = 'class' | 'spec'
|
||||
export type TalentKind = 'passive' | 'active' | 'choice'
|
||||
export type TalentRole = 'tank' | 'healer' | 'dps'
|
||||
type Role = TalentRole
|
||||
type SpecDef = TalentSpec
|
||||
type Gate = Pick<TalentNode, 'requires' | 'pointsGate'>
|
||||
|
||||
export type AbilityModifier = {
|
||||
castPct: number
|
||||
cooldownPct: number
|
||||
costPct: number
|
||||
dmgPct: number
|
||||
flatDmg: number
|
||||
}
|
||||
|
||||
export type TalentEffect = {
|
||||
stats?: {
|
||||
str?: number
|
||||
agi?: number
|
||||
sta?: number
|
||||
int?: number
|
||||
spi?: number
|
||||
armor?: number
|
||||
ap?: number
|
||||
crit?: number
|
||||
dodge?: number
|
||||
apPct?: number
|
||||
staPct?: number
|
||||
armorPct?: number
|
||||
maxHpPct?: number
|
||||
}
|
||||
ability?: Array<{ ability: string } & Partial<AbilityModifier>>
|
||||
global?: Partial<TalentModifiers['global']>
|
||||
grant?: { ability: string, rank?: number }
|
||||
}
|
||||
|
||||
export type TalentChoiceOption = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
effect: TalentEffect
|
||||
}
|
||||
|
||||
export type TalentNode = {
|
||||
id: string
|
||||
tree: TalentTree
|
||||
specId?: string
|
||||
kind: TalentKind
|
||||
maxRank: number
|
||||
requires?: string[]
|
||||
pointsGate?: number
|
||||
choices?: TalentChoiceOption[]
|
||||
effect?: TalentEffect
|
||||
icon: string
|
||||
name: string
|
||||
description: string
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export type TalentSpec = {
|
||||
id: string
|
||||
class: PlayerClass
|
||||
name: string
|
||||
role: TalentRole
|
||||
icon: string
|
||||
description: string
|
||||
signature: string
|
||||
mastery: { name: string, description: string, effect: TalentEffect }
|
||||
}
|
||||
|
||||
export type ClassTalents = {
|
||||
class: PlayerClass
|
||||
nodes: TalentNode[]
|
||||
specs: TalentSpec[]
|
||||
}
|
||||
|
||||
export type TalentAllocation = {
|
||||
spec: string | null
|
||||
ranks: Record<string, number>
|
||||
choices: Record<string, string>
|
||||
}
|
||||
|
||||
export type TalentModifiers = {
|
||||
spec: string | null
|
||||
role: TalentRole | null
|
||||
abilities: Record<string, AbilityModifier>
|
||||
global: {
|
||||
healPct: number
|
||||
meleeDmgPct: number
|
||||
spellDmgPct: number
|
||||
threatPct: number
|
||||
}
|
||||
grants: Array<{ ability: string, rank: number }>
|
||||
}
|
||||
|
||||
export const FIRST_TALENT_LEVEL = 10
|
||||
export const MAX_TALENT_LEVEL = 20
|
||||
|
||||
const EMPTY_ABILITY_MOD: AbilityModifier = {
|
||||
castPct: 0,
|
||||
cooldownPct: 0,
|
||||
costPct: 0,
|
||||
dmgPct: 0,
|
||||
flatDmg: 0,
|
||||
}
|
||||
|
||||
export function emptyTalentAllocation(): TalentAllocation {
|
||||
return { spec: null, ranks: {}, choices: {} }
|
||||
}
|
||||
|
||||
export function normalizeTalentAllocation(raw: unknown, classId: PlayerClass): TalentAllocation {
|
||||
const parsed = raw && typeof raw === 'object' ? raw as Partial<TalentAllocation> : {}
|
||||
const talents = talentsFor(classId)
|
||||
const spec = typeof parsed.spec === 'string' && talents?.specs.some((candidate) => candidate.id === parsed.spec)
|
||||
? parsed.spec
|
||||
: null
|
||||
const nodeIds = new Set(talents?.nodes.map((node) => node.id) ?? [])
|
||||
const ranks: Record<string, number> = {}
|
||||
if (parsed.ranks && typeof parsed.ranks === 'object') {
|
||||
for (const [id, rank] of Object.entries(parsed.ranks)) {
|
||||
if (!nodeIds.has(id)) continue
|
||||
ranks[id] = Math.max(0, Math.floor(Number(rank)))
|
||||
}
|
||||
}
|
||||
const choices: Record<string, string> = {}
|
||||
if (parsed.choices && typeof parsed.choices === 'object') {
|
||||
for (const [id, choiceId] of Object.entries(parsed.choices)) {
|
||||
if (nodeIds.has(id) && typeof choiceId === 'string') choices[id] = choiceId
|
||||
}
|
||||
}
|
||||
return { spec, ranks, choices }
|
||||
}
|
||||
|
||||
export function talentPointsAtLevel(level: number): number {
|
||||
return Math.max(0, Math.min(level, MAX_TALENT_LEVEL) - (FIRST_TALENT_LEVEL - 1))
|
||||
}
|
||||
|
||||
export function pointsSpent(alloc: TalentAllocation): number {
|
||||
return Object.values(alloc.ranks).reduce((sum, rank) => sum + Math.max(0, rank), 0)
|
||||
}
|
||||
|
||||
export function talentsFor(classId: PlayerClass | string | undefined): ClassTalents | null {
|
||||
if (classId === 'paladin' || classId === 'priest' || classId === 'shaman' || classId === 'druid') return TALENTS[classId]
|
||||
return null
|
||||
}
|
||||
|
||||
export function getTalentSpec(classId: PlayerClass | string | undefined, specId: string | null | undefined) {
|
||||
return talentsFor(classId)?.specs.find((spec) => spec.id === specId) ?? null
|
||||
}
|
||||
|
||||
export function validateTalentAllocation(classId: PlayerClass, alloc: TalentAllocation, totalPoints: number): { ok: boolean, reason?: string } {
|
||||
const talents = talentsFor(classId)
|
||||
if (!talents) return { ok: false, reason: 'No talents for class.' }
|
||||
if (pointsSpent(alloc) > totalPoints) return { ok: false, reason: 'Not enough talent points.' }
|
||||
const nodes = nodeIndex(talents)
|
||||
for (const [nodeId, rank] of Object.entries(alloc.ranks)) {
|
||||
const node = nodes.get(nodeId)
|
||||
if (!node) return { ok: false, reason: 'Unknown talent.' }
|
||||
if (rank < 0 || rank > node.maxRank) return { ok: false, reason: 'Invalid talent rank.' }
|
||||
if (node.tree === 'spec' && node.specId !== alloc.spec) return { ok: false, reason: 'Wrong specialization tree.' }
|
||||
if ((node.pointsGate ?? 0) > pointsAboveRow(talents, alloc, node)) return { ok: false, reason: 'Spend more points above this row.' }
|
||||
for (const required of node.requires ?? []) {
|
||||
if ((alloc.ranks[required] ?? 0) <= 0) return { ok: false, reason: 'Missing prerequisite.' }
|
||||
}
|
||||
if (node.kind === 'choice') {
|
||||
const chosen = alloc.choices[node.id]
|
||||
if (rank > 0 && !node.choices?.some((choice) => choice.id === chosen)) return { ok: false, reason: 'Choose a talent option.' }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function allocateTalentPoint(classId: PlayerClass, alloc: TalentAllocation, nodeId: string, totalPoints: number, choiceId?: string): { allocation: TalentAllocation, ok: boolean, reason?: string } {
|
||||
const talents = talentsFor(classId)
|
||||
const node = talents?.nodes.find((candidate) => candidate.id === nodeId)
|
||||
if (!talents || !node) return { allocation: alloc, ok: false, reason: 'Unknown talent.' }
|
||||
if (!alloc.spec) return { allocation: alloc, ok: false, reason: 'Pick a specialization first.' }
|
||||
if (pointsSpent(alloc) >= totalPoints) return { allocation: alloc, ok: false, reason: 'No talent points available.' }
|
||||
const next: TalentAllocation = {
|
||||
spec: alloc.spec,
|
||||
ranks: { ...alloc.ranks, [nodeId]: (alloc.ranks[nodeId] ?? 0) + 1 },
|
||||
choices: { ...alloc.choices },
|
||||
}
|
||||
if (node.kind === 'choice') {
|
||||
const selected = choiceId ?? node.choices?.[0]?.id
|
||||
if (selected) next.choices[nodeId] = selected
|
||||
}
|
||||
const validation = validateTalentAllocation(classId, next, totalPoints)
|
||||
return validation.ok ? { allocation: next, ok: true } : { allocation: alloc, ok: false, reason: validation.reason }
|
||||
}
|
||||
|
||||
export function setTalentSpec(classId: PlayerClass, alloc: TalentAllocation, specId: string): TalentAllocation {
|
||||
const talents = talentsFor(classId)
|
||||
if (!talents?.specs.some((spec) => spec.id === specId)) return alloc
|
||||
return { spec: specId, ranks: {}, choices: {} }
|
||||
}
|
||||
|
||||
export function computeTalentModifiers(classId: PlayerClass | string | undefined, alloc: TalentAllocation): TalentModifiers {
|
||||
const talents = talentsFor(classId)
|
||||
const mods = emptyTalentModifiers()
|
||||
if (!talents) return mods
|
||||
const spec = alloc.spec ? talents.specs.find((candidate) => candidate.id === alloc.spec) ?? null : null
|
||||
if (spec) {
|
||||
mods.spec = spec.id
|
||||
mods.role = spec.role
|
||||
mods.grants.push({ ability: spec.signature, rank: 1 })
|
||||
accumulate(mods, spec.mastery.effect, 1)
|
||||
}
|
||||
const nodes = nodeIndex(talents)
|
||||
for (const [nodeId, rank] of Object.entries(alloc.ranks)) {
|
||||
const node = nodes.get(nodeId)
|
||||
if (!node || rank <= 0) continue
|
||||
if (node.tree === 'spec' && node.specId !== alloc.spec) continue
|
||||
if (node.kind === 'choice') {
|
||||
const choice = node.choices?.find((candidate) => candidate.id === alloc.choices[node.id])
|
||||
if (choice) accumulate(mods, choice.effect, 1)
|
||||
} else {
|
||||
accumulate(mods, node.effect, rank)
|
||||
}
|
||||
}
|
||||
return mods
|
||||
}
|
||||
|
||||
export function getEmptyClaudeCraftAbilityModifier(): AbilityModifier {
|
||||
return EMPTY_ABILITY_MOD
|
||||
}
|
||||
|
||||
function emptyTalentModifiers(): TalentModifiers {
|
||||
return {
|
||||
spec: null,
|
||||
role: null,
|
||||
abilities: {},
|
||||
global: { healPct: 0, meleeDmgPct: 0, spellDmgPct: 0, threatPct: 0 },
|
||||
grants: [],
|
||||
}
|
||||
}
|
||||
|
||||
function accumulate(mods: TalentModifiers, effect: TalentEffect | undefined, rank: number) {
|
||||
if (!effect) return
|
||||
if (effect.global) {
|
||||
mods.global.healPct += (effect.global.healPct ?? 0) * rank
|
||||
mods.global.meleeDmgPct += (effect.global.meleeDmgPct ?? 0) * rank
|
||||
mods.global.spellDmgPct += (effect.global.spellDmgPct ?? 0) * rank
|
||||
mods.global.threatPct += (effect.global.threatPct ?? 0) * rank
|
||||
}
|
||||
for (const ability of effect.ability ?? []) {
|
||||
const current = mods.abilities[ability.ability] ?? { ...EMPTY_ABILITY_MOD }
|
||||
current.castPct += (ability.castPct ?? 0) * rank
|
||||
current.cooldownPct += (ability.cooldownPct ?? 0) * rank
|
||||
current.costPct += (ability.costPct ?? 0) * rank
|
||||
current.dmgPct += (ability.dmgPct ?? 0) * rank
|
||||
current.flatDmg += (ability.flatDmg ?? 0) * rank
|
||||
mods.abilities[ability.ability] = current
|
||||
}
|
||||
if (effect.grant) mods.grants.push({ ability: effect.grant.ability, rank: effect.grant.rank ?? 1 })
|
||||
}
|
||||
|
||||
function pointsAboveRow(talents: ClassTalents, alloc: TalentAllocation, node: TalentNode) {
|
||||
return talents.nodes.reduce((sum, candidate) => {
|
||||
if (candidate.tree !== node.tree || candidate.row >= node.row) return sum
|
||||
if (candidate.tree === 'spec' && candidate.specId !== alloc.spec) return sum
|
||||
return sum + (alloc.ranks[candidate.id] ?? 0)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function nodeIndex(talents: ClassTalents) {
|
||||
return new Map(talents.nodes.map((node) => [node.id, node]))
|
||||
}
|
||||
|
||||
function passive(id: string, tree: TalentTree, specId: string | undefined, maxRank: number, effect: TalentEffect, icon: string, name: string, description: string, row: number, col: number, gate: Gate = {}): TalentNode {
|
||||
return { id, tree, ...(specId ? { specId } : {}), kind: 'passive', maxRank, effect, icon, name, description, row, col, ...gate }
|
||||
}
|
||||
|
||||
function active(id: string, tree: TalentTree, specId: string | undefined, effect: TalentEffect, icon: string, name: string, description: string, row: number, col: number, gate: Gate = {}): TalentNode {
|
||||
return { id, tree, ...(specId ? { specId } : {}), kind: 'active', maxRank: 1, effect, icon, name, description, row, col, ...gate }
|
||||
}
|
||||
|
||||
function choice(id: string, tree: TalentTree, specId: string | undefined, icon: string, name: string, description: string, row: number, col: number, choices: TalentChoiceOption[], gate: Gate = {}): TalentNode {
|
||||
return { id, tree, ...(specId ? { specId } : {}), kind: 'choice', maxRank: 1, icon, name, description, row, col, choices, ...gate }
|
||||
}
|
||||
|
||||
function spec(id: string, cls: PlayerClass, name: string, role: Role, icon: string, description: string, signature: string, masteryName: string, masteryDescription: string, effect: TalentEffect): SpecDef {
|
||||
return { id, class: cls, name, role, icon, description, signature, mastery: { name: masteryName, description: masteryDescription, effect } }
|
||||
}
|
||||
|
||||
const PALADIN_CLASS: TalentNode[] = [
|
||||
passive('pal_divine_strength', 'class', undefined, 3, { stats: { str: 2 } }, '+', 'Divine Strength', 'Increases your Strength by 2 per rank.', 0, 0),
|
||||
passive('pal_spiritual_focus', 'class', undefined, 3, { stats: { spi: 2 }, global: { healPct: 0.02 } }, '*', 'Spiritual Focus', 'Increases Spirit by 2 and healing done by 2% per rank.', 0, 2),
|
||||
passive('pal_imp_devotion_aura', 'class', undefined, 2, { ability: [{ ability: 'devotion_aura', dmgPct: 0.20 }], stats: { armorPct: 0.03 } }, '#', 'Improved Devotion Aura', 'Increases your armor by 3% per rank and strengthens Devotion Aura.', 1, 0, { requires: ['pal_divine_strength'] }),
|
||||
passive('pal_benediction', 'class', undefined, 2, { ability: [{ ability: 'seal_of_righteousness', costPct: -0.08 }, { ability: 'judgement', costPct: -0.08 }] }, 'v', 'Benediction', 'Reduces the mana cost of Seal of Righteousness and Judgement by 8% per rank.', 1, 1, { pointsGate: 2 }),
|
||||
passive('pal_precision', 'class', undefined, 3, { stats: { crit: 0.01 } }, 'x', 'Conviction', 'Increases your critical strike chance by 1% per rank.', 1, 2, { requires: ['pal_spiritual_focus'] }),
|
||||
choice('pal_holy_calling', 'class', undefined, '@', 'Holy Calling', 'Choose one paladin emphasis.', 2, 1, [
|
||||
{ id: 'pal_calling_light', name: 'Healing Light', icon: '+', description: 'Increases healing done by 6%.', effect: { global: { healPct: 0.06 } } },
|
||||
{ id: 'pal_calling_guardian', name: 'Guardian Favor', icon: '#', description: 'Increases armor by 8% and dodge by 2%.', effect: { stats: { armorPct: 0.08, dodge: 0.02 } } },
|
||||
{ id: 'pal_calling_crusader', name: 'Crusader Zeal', icon: 'x', description: 'Increases melee ability damage by 6%.', effect: { global: { meleeDmgPct: 0.06 } } },
|
||||
], { pointsGate: 5 }),
|
||||
active('pal_divine_favor', 'class', undefined, { grant: { ability: 'divine_protection' } }, 'O', 'Divine Favor', 'Grants early access to Divine Protection.', 3, 0, { pointsGate: 8, requires: ['pal_imp_devotion_aura'] }),
|
||||
passive('pal_sanctified_light', 'class', undefined, 2, { stats: { int: 3, maxHpPct: 0.04 } }, '*', 'Sanctified Light', 'Increases Intellect by 3 and maximum health by 4% per rank.', 3, 2, { pointsGate: 8, requires: ['pal_holy_calling'] }),
|
||||
]
|
||||
|
||||
const PALADIN_SPECS: SpecDef[] = [
|
||||
spec('holy', 'paladin', 'Holy', 'healer', '+', 'A devoted healer who turns the Light into steady single-target recovery.', 'flash_of_light', 'Illumination', 'Increases all healing done by 12%.', { global: { healPct: 0.12 } }),
|
||||
spec('protection', 'paladin', 'Protection', 'tank', '#', 'A shield-bearing defender who converts Holy power into threat and mitigation.', 'righteous_fury', 'Holy Shielding', 'Increases threat by 25% and armor by 10%.', { global: { threatPct: 0.25 }, stats: { armorPct: 0.10 } }),
|
||||
spec('retribution', 'paladin', 'Retribution', 'dps', 'x', 'A holy warrior who judges enemies with weapon strikes and radiant burst.', 'judgement', 'Vengeance', 'Increases melee and spell ability damage by 6%.', { global: { meleeDmgPct: 0.06, spellDmgPct: 0.06 } }),
|
||||
]
|
||||
|
||||
const PALADIN_SPEC_NODES: TalentNode[] = [
|
||||
passive('holy_imp_holy_light', 'spec', 'holy', 3, { ability: [{ ability: 'holy_light', dmgPct: 0.08 }] }, '+', 'Improved Holy Light', 'Increases Holy Light healing by 8% per rank.', 0, 0),
|
||||
passive('holy_divine_intellect', 'spec', 'holy', 3, { stats: { int: 3 } }, '*', 'Divine Intellect', 'Increases Intellect by 3 per rank.', 0, 2),
|
||||
passive('holy_flash_focus', 'spec', 'holy', 2, { ability: [{ ability: 'flash_of_light', castPct: -0.10, costPct: -0.08 }] }, '>', 'Flash Focus', 'Makes Flash of Light faster and cheaper by 10%/8% per rank.', 1, 0, { pointsGate: 2, requires: ['holy_imp_holy_light'] }),
|
||||
passive('holy_lay_blessing', 'spec', 'holy', 2, { ability: [{ ability: 'lay_on_hands', cooldownPct: -0.20 }] }, 'O', 'Improved Lay on Hands', 'Reduces Lay on Hands cooldown by 20% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('holy_choice', 'spec', 'holy', '@', 'Beacon Discipline', 'Choose one Holy refinement.', 2, 1, [
|
||||
{ id: 'holy_choice_grace', name: 'Holy Grace', icon: '+', description: 'Increases healing by 8%.', effect: { global: { healPct: 0.08 } } },
|
||||
{ id: 'holy_choice_judgement', name: 'Judgement of Light', icon: 'x', description: 'Judgement deals 20% more damage.', effect: { ability: [{ ability: 'judgement', dmgPct: 0.20 }] } },
|
||||
{ id: 'holy_choice_devotion', name: 'Devoted Soul', icon: '#', description: 'Increases stamina by 8%.', effect: { stats: { staPct: 0.08 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('holy_light_mastery', 'spec', 'holy', 2, { global: { healPct: 0.06 }, stats: { crit: 0.01 } }, '*', 'Light Mastery', 'Increases healing by 6% and critical strike by 1% per rank.', 3, 1, { pointsGate: 8, requires: ['holy_choice'] }),
|
||||
|
||||
passive('prot_redoubt', 'spec', 'protection', 3, { stats: { armorPct: 0.05 } }, '#', 'Redoubt', 'Increases armor by 5% per rank.', 0, 0),
|
||||
passive('prot_anticipation', 'spec', 'protection', 3, { stats: { dodge: 0.01 } }, 'o', 'Anticipation', 'Increases dodge chance by 1% per rank.', 0, 2),
|
||||
passive('prot_imp_righteous_fury', 'spec', 'protection', 2, { global: { threatPct: 0.10 }, ability: [{ ability: 'righteous_fury', costPct: -0.25 }] }, '!', 'Improved Righteous Fury', 'Increases threat by 10% and reduces Righteous Fury cost by 25% per rank.', 1, 0, { pointsGate: 2, requires: ['prot_redoubt'] }),
|
||||
passive('prot_guardians_favor', 'spec', 'protection', 2, { ability: [{ ability: 'divine_protection', cooldownPct: -0.15 }, { ability: 'hammer_of_justice', cooldownPct: -0.10 }] }, 'O', 'Guardian Favor', 'Reduces defensive cooldowns by 10-15% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('prot_choice', 'spec', 'protection', '@', 'Sanctuary', 'Choose one Protection refinement.', 2, 1, [
|
||||
{ id: 'prot_choice_sanctuary', name: 'Blessing of Sanctuary', icon: '#', description: 'Increases armor by 12%.', effect: { stats: { armorPct: 0.12 } } },
|
||||
{ id: 'prot_choice_reckoning', name: 'Reckoning', icon: 'x', description: 'Increases melee damage by 8%.', effect: { global: { meleeDmgPct: 0.08 } } },
|
||||
{ id: 'prot_choice_ardent', name: 'Ardent Defender', icon: '+', description: 'Increases maximum health by 12%.', effect: { stats: { maxHpPct: 0.12 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('prot_holy_shield', 'spec', 'protection', 2, { ability: [{ ability: 'consecration', dmgPct: 0.12 }], global: { threatPct: 0.08 } }, '#', 'Holy Shield', 'Increases Consecration damage by 12% and threat by 8% per rank.', 3, 1, { pointsGate: 8, requires: ['prot_choice'] }),
|
||||
|
||||
passive('ret_benediction', 'spec', 'retribution', 3, { ability: [{ ability: 'seal_of_righteousness', costPct: -0.08 }, { ability: 'judgement', costPct: -0.08 }] }, 'v', 'Benediction', 'Reduces core offensive costs by 8% per rank.', 0, 0),
|
||||
passive('ret_conviction', 'spec', 'retribution', 3, { stats: { crit: 0.01 } }, 'x', 'Conviction', 'Increases critical strike chance by 1% per rank.', 0, 2),
|
||||
passive('ret_imp_judgement', 'spec', 'retribution', 2, { ability: [{ ability: 'judgement', cooldownPct: -0.15, dmgPct: 0.10 }] }, '!', 'Improved Judgement', 'Reduces Judgement cooldown by 15% and increases damage by 10% per rank.', 1, 0, { pointsGate: 2, requires: ['ret_benediction'] }),
|
||||
passive('ret_seal_command', 'spec', 'retribution', 2, { ability: [{ ability: 'seal_of_righteousness', dmgPct: 0.20 }] }, 'x', 'Seal Command', 'Increases Seal of Righteousness damage by 20% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('ret_choice', 'spec', 'retribution', '@', 'Crusader Path', 'Choose one Retribution refinement.', 2, 1, [
|
||||
{ id: 'ret_choice_sanctity', name: 'Sanctity Aura', icon: '*', description: 'Increases spell damage by 8%.', effect: { global: { spellDmgPct: 0.08 } } },
|
||||
{ id: 'ret_choice_pursuit', name: 'Pursuit of Justice', icon: '>', description: 'Increases dodge by 3% and attack power by 10%.', effect: { stats: { dodge: 0.03, apPct: 0.10 } } },
|
||||
{ id: 'ret_choice_vengeance', name: 'Vengeance', icon: 'x', description: 'Increases critical strike chance by 4%.', effect: { stats: { crit: 0.04 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('ret_crusader_strikes', 'spec', 'retribution', 2, { global: { meleeDmgPct: 0.06, spellDmgPct: 0.04 }, ability: [{ ability: 'exorcism', cooldownPct: -0.10 }] }, 'x', 'Crusader Strikes', 'Increases offensive damage and reduces Exorcism cooldown per rank.', 3, 1, { pointsGate: 8, requires: ['ret_choice'] }),
|
||||
]
|
||||
|
||||
const PRIEST_CLASS: TalentNode[] = [
|
||||
passive('pri_wand_specialization', 'class', undefined, 3, { stats: { int: 1, spi: 1 } }, '/', 'Wand Specialization', 'Increases Intellect and Spirit by 1 per rank.', 0, 0),
|
||||
passive('pri_spirit_tap', 'class', undefined, 3, { stats: { spi: 3 } }, '*', 'Spirit Tap', 'Increases Spirit by 3 per rank.', 0, 2),
|
||||
passive('pri_imp_fortitude', 'class', undefined, 2, { ability: [{ ability: 'power_word_fortitude', dmgPct: 0.20 }], stats: { sta: 2 } }, '+', 'Improved Fortitude', 'Increases Stamina by 2 and strengthens Fortitude per rank.', 1, 0, { requires: ['pri_wand_specialization'] }),
|
||||
passive('pri_meditation', 'class', undefined, 2, { ability: [{ ability: 'lesser_heal', costPct: -0.08 }, { ability: 'heal', costPct: -0.08 }, { ability: 'flash_heal', costPct: -0.08 }] }, 'v', 'Meditation', 'Reduces healing spell costs by 8% per rank.', 1, 1, { pointsGate: 2 }),
|
||||
passive('pri_shadow_affinity', 'class', undefined, 3, { ability: [{ ability: 'shadow_word_pain', costPct: -0.05 }, { ability: 'mind_blast', costPct: -0.05 }] }, '*', 'Shadow Affinity', 'Reduces Shadow spell costs by 5% per rank.', 1, 2, { requires: ['pri_spirit_tap'] }),
|
||||
choice('pri_inner_calling', 'class', undefined, '@', 'Inner Calling', 'Choose one priest emphasis.', 2, 1, [
|
||||
{ id: 'pri_calling_disc', name: 'Inner Focus', icon: '#', description: 'Power Word: Shield absorbs 18% more.', effect: { ability: [{ ability: 'power_word_shield', dmgPct: 0.18 }] } },
|
||||
{ id: 'pri_calling_holy', name: 'Divine Fury', icon: '+', description: 'Increases healing by 8%.', effect: { global: { healPct: 0.08 } } },
|
||||
{ id: 'pri_calling_shadow', name: 'Darkness', icon: '*', description: 'Increases spell damage by 8%.', effect: { global: { spellDmgPct: 0.08 } } },
|
||||
], { pointsGate: 5 }),
|
||||
active('pri_desperate_prayer', 'class', undefined, { grant: { ability: 'flash_heal' } }, '+', 'Desperate Prayer', 'Grants early access to Flash Heal.', 3, 0, { pointsGate: 8, requires: ['pri_imp_fortitude'] }),
|
||||
passive('pri_enlightenment', 'class', undefined, 2, { stats: { int: 3, spi: 3 } }, '*', 'Enlightenment', 'Increases Intellect and Spirit by 3 per rank.', 3, 2, { pointsGate: 8, requires: ['pri_inner_calling'] }),
|
||||
]
|
||||
|
||||
const PRIEST_SPECS: SpecDef[] = [
|
||||
spec('discipline', 'priest', 'Discipline', 'healer', '#', 'A mitigator who shields allies and heals through controlled efficiency.', 'power_word_shield', 'Focused Will', 'Increases healing and maximum health.', { global: { healPct: 0.08 }, stats: { maxHpPct: 0.08 } }),
|
||||
spec('holy', 'priest', 'Holy', 'healer', '+', 'A direct healer with strong throughput and restorative prayers.', 'flash_heal', 'Spiritual Healing', 'Increases all healing done by 14%.', { global: { healPct: 0.14 } }),
|
||||
spec('shadow', 'priest', 'Shadow', 'dps', '*', 'A damage caster built around Shadow damage over time and mind spells.', 'mind_flay', 'Shadowform', 'Increases spell damage by 12% and armor by 8%.', { global: { spellDmgPct: 0.12 }, stats: { armorPct: 0.08 } }),
|
||||
]
|
||||
|
||||
const PRIEST_SPEC_NODES: TalentNode[] = [
|
||||
passive('disc_unbreakable_will', 'spec', 'discipline', 3, { stats: { sta: 2, spi: 1 } }, '#', 'Unbreakable Will', 'Increases Stamina by 2 and Spirit by 1 per rank.', 0, 0),
|
||||
passive('disc_twin_disciplines', 'spec', 'discipline', 3, { ability: [{ ability: 'power_word_shield', dmgPct: 0.08 }] }, '#', 'Twin Disciplines', 'Increases Power Word: Shield absorption by 8% per rank.', 0, 2),
|
||||
passive('disc_imp_shield', 'spec', 'discipline', 2, { ability: [{ ability: 'power_word_shield', cooldownPct: -0.15, costPct: -0.08 }] }, '#', 'Improved Power Word: Shield', 'Improves shield cooldown and cost per rank.', 1, 0, { pointsGate: 2, requires: ['disc_unbreakable_will'] }),
|
||||
passive('disc_mental_agility', 'spec', 'discipline', 2, { ability: [{ ability: 'renew', costPct: -0.10 }, { ability: 'power_word_shield', costPct: -0.10 }] }, 'v', 'Mental Agility', 'Reduces instant support spell costs by 10% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('disc_choice', 'spec', 'discipline', '@', 'Discipline Focus', 'Choose one Discipline refinement.', 2, 1, [
|
||||
{ id: 'disc_choice_barrier', name: 'Borrowed Time', icon: '#', description: 'Shielding spells are stronger.', effect: { ability: [{ ability: 'power_word_shield', dmgPct: 0.25 }] } },
|
||||
{ id: 'disc_choice_focus', name: 'Inner Focus', icon: '*', description: 'Healing spells cost 15% less.', effect: { ability: [{ ability: 'lesser_heal', costPct: -0.15 }, { ability: 'heal', costPct: -0.15 }, { ability: 'flash_heal', costPct: -0.15 }] } },
|
||||
{ id: 'disc_choice_power', name: 'Power Infusion', icon: '+', description: 'Increases healing and spell damage by 6%.', effect: { global: { healPct: 0.06, spellDmgPct: 0.06 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('disc_penance', 'spec', 'discipline', 2, { global: { healPct: 0.05 }, ability: [{ ability: 'smite', dmgPct: 0.08 }] }, '+', 'Penance', 'Improves healing and Smite pressure per rank.', 3, 1, { pointsGate: 8, requires: ['disc_choice'] }),
|
||||
|
||||
passive('holy_healing_focus', 'spec', 'holy', 3, { ability: [{ ability: 'lesser_heal', dmgPct: 0.06 }, { ability: 'heal', dmgPct: 0.06 }] }, '+', 'Healing Focus', 'Increases direct healing by 6% per rank.', 0, 0),
|
||||
passive('holy_renewal', 'spec', 'holy', 3, { ability: [{ ability: 'renew', dmgPct: 0.08 }] }, '+', 'Improved Renew', 'Increases Renew healing by 8% per rank.', 0, 2),
|
||||
passive('holy_divine_fury', 'spec', 'holy', 2, { ability: [{ ability: 'heal', castPct: -0.10 }, { ability: 'smite', castPct: -0.05 }] }, '>', 'Divine Fury', 'Makes Heal and Smite faster per rank.', 1, 0, { pointsGate: 2, requires: ['holy_healing_focus'] }),
|
||||
passive('holy_inspiration', 'spec', 'holy', 2, { stats: { armorPct: 0.05 }, global: { healPct: 0.03 } }, '#', 'Inspiration', 'Increases armor and healing per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('holy_priest_choice', 'spec', 'holy', '@', 'Holy Word', 'Choose one Holy refinement.', 2, 1, [
|
||||
{ id: 'holy_priest_choice_spirit', name: 'Spiritual Guidance', icon: '*', description: 'Increases Spirit by 10.', effect: { stats: { spi: 10 } } },
|
||||
{ id: 'holy_priest_choice_nova', name: 'Holy Reach', icon: '+', description: 'Smite and Holy heals are 8% stronger.', effect: { global: { healPct: 0.08 }, ability: [{ ability: 'smite', dmgPct: 0.08 }] } },
|
||||
{ id: 'holy_priest_choice_prayer', name: 'Healing Prayers', icon: 'v', description: 'Healing spells cost 12% less.', effect: { ability: [{ ability: 'lesser_heal', costPct: -0.12 }, { ability: 'heal', costPct: -0.12 }, { ability: 'flash_heal', costPct: -0.12 }] } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('holy_spiritual_healing', 'spec', 'holy', 2, { global: { healPct: 0.07 }, stats: { crit: 0.01 } }, '+', 'Spiritual Healing', 'Increases healing and crit per rank.', 3, 1, { pointsGate: 8, requires: ['holy_priest_choice'] }),
|
||||
|
||||
passive('shadow_blackout', 'spec', 'shadow', 3, { ability: [{ ability: 'mind_blast', dmgPct: 0.06 }] }, '*', 'Blackout', 'Increases Mind Blast damage by 6% per rank.', 0, 0),
|
||||
passive('shadow_word_pain', 'spec', 'shadow', 3, { ability: [{ ability: 'shadow_word_pain', dmgPct: 0.08 }] }, '*', 'Improved Shadow Word: Pain', 'Increases Shadow Word: Pain damage by 8% per rank.', 0, 2),
|
||||
passive('shadow_mind_flay', 'spec', 'shadow', 2, { ability: [{ ability: 'mind_flay', dmgPct: 0.12, costPct: -0.08 }] }, '*', 'Improved Mind Flay', 'Improves Mind Flay damage and cost per rank.', 1, 0, { pointsGate: 2, requires: ['shadow_blackout'] }),
|
||||
passive('shadow_focus', 'spec', 'shadow', 2, { global: { spellDmgPct: 0.04 } }, 'x', 'Shadow Focus', 'Increases spell damage by 4% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('shadow_choice', 'spec', 'shadow', '@', 'Dark Arts', 'Choose one Shadow refinement.', 2, 1, [
|
||||
{ id: 'shadow_choice_vampiric', name: 'Vampiric Embrace', icon: '+', description: 'Increases maximum health and spell damage.', effect: { stats: { maxHpPct: 0.08 }, global: { spellDmgPct: 0.04 } } },
|
||||
{ id: 'shadow_choice_silence', name: 'Silence', icon: 'O', description: 'Mind Blast cooldown is reduced by 20%.', effect: { ability: [{ ability: 'mind_blast', cooldownPct: -0.20 }] } },
|
||||
{ id: 'shadow_choice_darkness', name: 'Darkness', icon: '*', description: 'Increases spell damage by 10%.', effect: { global: { spellDmgPct: 0.10 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('shadow_shadowform', 'spec', 'shadow', 2, { global: { spellDmgPct: 0.07 }, stats: { armorPct: 0.05 } }, '*', 'Shadowform', 'Increases spell damage and armor per rank.', 3, 1, { pointsGate: 8, requires: ['shadow_choice'] }),
|
||||
]
|
||||
|
||||
const SHAMAN_CLASS: TalentNode[] = [
|
||||
passive('sha_convection', 'class', undefined, 3, { ability: [{ ability: 'lightning_bolt', costPct: -0.05 }, { ability: 'earth_shock', costPct: -0.05 }] }, 'v', 'Convection', 'Reduces Lightning Bolt and Earth Shock costs by 5% per rank.', 0, 0),
|
||||
passive('sha_ancestral_knowledge', 'class', undefined, 3, { stats: { int: 2 } }, '*', 'Ancestral Knowledge', 'Increases Intellect by 2 per rank.', 0, 2),
|
||||
passive('sha_shielding', 'class', undefined, 2, { ability: [{ ability: 'lightning_shield', dmgPct: 0.15 }], stats: { armorPct: 0.03 } }, '#', 'Improved Lightning Shield', 'Strengthens Lightning Shield and armor per rank.', 1, 0, { requires: ['sha_convection'] }),
|
||||
passive('sha_thundering_strikes', 'class', undefined, 2, { stats: { crit: 0.015 } }, 'x', 'Thundering Strikes', 'Increases critical strike chance by 1.5% per rank.', 1, 1, { pointsGate: 2 }),
|
||||
passive('sha_tidal_focus', 'class', undefined, 3, { ability: [{ ability: 'healing_wave', costPct: -0.05 }] }, '+', 'Tidal Focus', 'Reduces Healing Wave cost by 5% per rank.', 1, 2, { requires: ['sha_ancestral_knowledge'] }),
|
||||
choice('sha_elemental_calling', 'class', undefined, '@', 'Elemental Calling', 'Choose one shaman emphasis.', 2, 1, [
|
||||
{ id: 'sha_calling_elemental', name: 'Elemental Fury', icon: '*', description: 'Increases spell damage by 8%.', effect: { global: { spellDmgPct: 0.08 } } },
|
||||
{ id: 'sha_calling_enhance', name: 'Flurry', icon: 'x', description: 'Increases melee ability damage by 8%.', effect: { global: { meleeDmgPct: 0.08 } } },
|
||||
{ id: 'sha_calling_restoration', name: 'Healing Grace', icon: '+', description: 'Increases healing by 8%.', effect: { global: { healPct: 0.08 } } },
|
||||
], { pointsGate: 5 }),
|
||||
active('sha_ghost_wolf', 'class', undefined, { grant: { ability: 'ghost_wolf' } }, '>', 'Improved Ghost Wolf', 'Grants early access to Ghost Wolf.', 3, 0, { pointsGate: 8, requires: ['sha_shielding'] }),
|
||||
passive('sha_natures_guidance', 'class', undefined, 2, { stats: { int: 3, sta: 3 } }, '+', 'Nature Guidance', 'Increases Intellect and Stamina by 3 per rank.', 3, 2, { pointsGate: 8, requires: ['sha_elemental_calling'] }),
|
||||
]
|
||||
|
||||
const SHAMAN_SPECS: SpecDef[] = [
|
||||
spec('elemental', 'shaman', 'Elemental', 'dps', '*', 'A ranged caster who calls lightning, flame, and frost.', 'lightning_bolt', 'Elemental Fury', 'Increases spell damage and critical strike chance.', { global: { spellDmgPct: 0.10 }, stats: { crit: 0.02 } }),
|
||||
spec('enhancement', 'shaman', 'Enhancement', 'dps', 'x', 'A weapon fighter who channels the storm through melee swings.', 'stormstrike', 'Stormcaller', 'Increases melee ability damage and attack power.', { global: { meleeDmgPct: 0.10 }, stats: { ap: 10 } }),
|
||||
spec('restoration', 'shaman', 'Restoration', 'healer', '+', 'A healer using ancestral waves and efficient nature magic.', 'healing_wave', 'Purification', 'Increases healing done by 14%.', { global: { healPct: 0.14 } }),
|
||||
]
|
||||
|
||||
const SHAMAN_SPEC_NODES: TalentNode[] = [
|
||||
passive('ele_concussion', 'spec', 'elemental', 3, { ability: [{ ability: 'lightning_bolt', dmgPct: 0.06 }, { ability: 'earth_shock', dmgPct: 0.06 }] }, '*', 'Concussion', 'Increases Lightning Bolt and Earth Shock damage by 6% per rank.', 0, 0),
|
||||
passive('ele_call_flame', 'spec', 'elemental', 3, { ability: [{ ability: 'flame_shock', dmgPct: 0.08 }] }, 'x', 'Call of Flame', 'Increases Flame Shock damage by 8% per rank.', 0, 2),
|
||||
passive('ele_reverberation', 'spec', 'elemental', 2, { ability: [{ ability: 'earth_shock', cooldownPct: -0.12 }, { ability: 'frost_shock', cooldownPct: -0.12 }] }, '>', 'Reverberation', 'Reduces Shock cooldowns by 12% per rank.', 1, 0, { pointsGate: 2, requires: ['ele_concussion'] }),
|
||||
passive('ele_elemental_focus', 'spec', 'elemental', 2, { ability: [{ ability: 'lightning_bolt', costPct: -0.10 }, { ability: 'flame_shock', costPct: -0.10 }] }, 'v', 'Elemental Focus', 'Reduces offensive spell costs by 10% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('ele_choice', 'spec', 'elemental', '@', 'Elemental Mastery', 'Choose one Elemental refinement.', 2, 1, [
|
||||
{ id: 'ele_choice_mastery', name: 'Elemental Mastery', icon: '*', description: 'Increases spell damage by 10%.', effect: { global: { spellDmgPct: 0.10 } } },
|
||||
{ id: 'ele_choice_devastation', name: 'Elemental Devastation', icon: 'x', description: 'Increases critical strike by 5%.', effect: { stats: { crit: 0.05 } } },
|
||||
{ id: 'ele_choice_storm', name: 'Storm Reach', icon: '>', description: 'Lightning Bolt casts 20% faster.', effect: { ability: [{ ability: 'lightning_bolt', castPct: -0.20 }] } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('ele_lightning_mastery', 'spec', 'elemental', 2, { ability: [{ ability: 'lightning_bolt', castPct: -0.08, dmgPct: 0.10 }] }, '*', 'Lightning Mastery', 'Improves Lightning Bolt cast and damage per rank.', 3, 1, { pointsGate: 8, requires: ['ele_choice'] }),
|
||||
|
||||
passive('enh_ancestral_weapons', 'spec', 'enhancement', 3, { stats: { ap: 8 } }, 'x', 'Ancestral Weapons', 'Increases attack power by 8 per rank.', 0, 0),
|
||||
passive('enh_shield_spec', 'spec', 'enhancement', 3, { stats: { armorPct: 0.04, dodge: 0.005 } }, '#', 'Shield Specialization', 'Increases armor and dodge per rank.', 0, 2),
|
||||
passive('enh_imp_rockbiter', 'spec', 'enhancement', 2, { ability: [{ ability: 'rockbiter_weapon', dmgPct: 0.20 }] }, 'x', 'Improved Rockbiter', 'Increases Rockbiter Weapon damage by 20% per rank.', 1, 0, { pointsGate: 2, requires: ['enh_ancestral_weapons'] }),
|
||||
passive('enh_flurry', 'spec', 'enhancement', 2, { stats: { crit: 0.02 } }, '>', 'Flurry', 'Increases critical strike chance by 2% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('enh_choice', 'spec', 'enhancement', '@', 'Storm Path', 'Choose one Enhancement refinement.', 2, 1, [
|
||||
{ id: 'enh_choice_stormstrike', name: 'Stormstrike', icon: 'x', description: 'Stormstrike deals 25% more damage.', effect: { ability: [{ ability: 'stormstrike', dmgPct: 0.25 }] } },
|
||||
{ id: 'enh_choice_toughness', name: 'Toughness', icon: '#', description: 'Increases armor and stamina.', effect: { stats: { armorPct: 0.12, sta: 4 } } },
|
||||
{ id: 'enh_choice_weapon', name: 'Weapon Mastery', icon: 'x', description: 'Increases melee damage by 10%.', effect: { global: { meleeDmgPct: 0.10 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('enh_spirit_weapons', 'spec', 'enhancement', 2, { ability: [{ ability: 'stormstrike', cooldownPct: -0.12, costPct: -0.08 }], global: { meleeDmgPct: 0.04 } }, 'x', 'Spirit Weapons', 'Improves Stormstrike and melee damage per rank.', 3, 1, { pointsGate: 8, requires: ['enh_choice'] }),
|
||||
|
||||
passive('rest_tidal_focus', 'spec', 'restoration', 3, { ability: [{ ability: 'healing_wave', costPct: -0.06 }] }, 'v', 'Tidal Focus', 'Reduces Healing Wave cost by 6% per rank.', 0, 0),
|
||||
passive('rest_imp_healing_wave', 'spec', 'restoration', 3, { ability: [{ ability: 'healing_wave', castPct: -0.05, dmgPct: 0.05 }] }, '+', 'Improved Healing Wave', 'Makes Healing Wave faster and stronger per rank.', 0, 2),
|
||||
passive('rest_ancestral_healing', 'spec', 'restoration', 2, { stats: { armorPct: 0.05 }, global: { healPct: 0.03 } }, '#', 'Ancestral Healing', 'Increases armor and healing per rank.', 1, 0, { pointsGate: 2, requires: ['rest_tidal_focus'] }),
|
||||
passive('rest_healing_grace', 'spec', 'restoration', 2, { global: { healPct: 0.04 }, stats: { spi: 2 } }, '+', 'Healing Grace', 'Increases healing and Spirit per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('rest_choice', 'spec', 'restoration', '@', 'Nature Blessing', 'Choose one Restoration refinement.', 2, 1, [
|
||||
{ id: 'rest_choice_swiftness', name: 'Nature Swiftness', icon: '>', description: 'Healing Wave casts 25% faster.', effect: { ability: [{ ability: 'healing_wave', castPct: -0.25 }] } },
|
||||
{ id: 'rest_choice_mana', name: 'Mana Tide', icon: 'v', description: 'Healing Wave costs 18% less.', effect: { ability: [{ ability: 'healing_wave', costPct: -0.18 }] } },
|
||||
{ id: 'rest_choice_purification', name: 'Purification', icon: '+', description: 'Increases healing by 10%.', effect: { global: { healPct: 0.10 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('rest_chain_focus', 'spec', 'restoration', 2, { global: { healPct: 0.07 }, stats: { int: 2 } }, '+', 'Ancestral Guidance', 'Increases healing and Intellect per rank.', 3, 1, { pointsGate: 8, requires: ['rest_choice'] }),
|
||||
]
|
||||
|
||||
const DRUID_CLASS: TalentNode[] = [
|
||||
passive('dru_natures_grasp', 'class', undefined, 3, { ability: [{ ability: 'entangling_roots', costPct: -0.06 }], stats: { spi: 1 } }, 'v', 'Nature Grasp', 'Reduces Entangling Roots cost and increases Spirit per rank.', 0, 0),
|
||||
passive('dru_feral_aggression', 'class', undefined, 3, { ability: [{ ability: 'maul', dmgPct: 0.05 }, { ability: 'claw', dmgPct: 0.05 }] }, 'x', 'Feral Aggression', 'Increases Maul and Claw damage by 5% per rank.', 0, 2),
|
||||
passive('dru_imp_mark', 'class', undefined, 2, { ability: [{ ability: 'mark_of_the_wild', dmgPct: 0.20 }], stats: { armorPct: 0.03 } }, '+', 'Improved Mark of the Wild', 'Strengthens Mark of the Wild and armor per rank.', 1, 0, { requires: ['dru_natures_grasp'] }),
|
||||
passive('dru_naturalist', 'class', undefined, 2, { ability: [{ ability: 'healing_touch', castPct: -0.08 }, { ability: 'wrath', castPct: -0.04 }] }, '>', 'Naturalist', 'Makes Healing Touch and Wrath faster per rank.', 1, 1, { pointsGate: 2 }),
|
||||
passive('dru_thick_hide', 'class', undefined, 3, { stats: { armorPct: 0.04 } }, '#', 'Thick Hide', 'Increases armor by 4% per rank.', 1, 2, { requires: ['dru_feral_aggression'] }),
|
||||
choice('dru_natures_path', 'class', undefined, '@', 'Nature Path', 'Choose one druid emphasis.', 2, 1, [
|
||||
{ id: 'dru_path_balance', name: 'Moonglow', icon: '*', description: 'Increases spell damage by 8%.', effect: { global: { spellDmgPct: 0.08 } } },
|
||||
{ id: 'dru_path_feral', name: 'Heart of the Wild', icon: 'x', description: 'Increases Stamina and attack power.', effect: { stats: { staPct: 0.08, apPct: 0.08 } } },
|
||||
{ id: 'dru_path_resto', name: 'Gift of Nature', icon: '+', description: 'Increases healing by 8%.', effect: { global: { healPct: 0.08 } } },
|
||||
], { pointsGate: 5 }),
|
||||
active('dru_barkskin', 'class', undefined, { grant: { ability: 'barkskin' } }, '#', 'Barkskin', 'Grants early access to Barkskin.', 3, 0, { pointsGate: 8, requires: ['dru_imp_mark'] }),
|
||||
passive('dru_furor', 'class', undefined, 2, { stats: { int: 3, sta: 3 } }, '+', 'Furor', 'Increases Intellect and Stamina by 3 per rank.', 3, 2, { pointsGate: 8, requires: ['dru_natures_path'] }),
|
||||
]
|
||||
|
||||
const DRUID_SPECS: SpecDef[] = [
|
||||
spec('balance', 'druid', 'Balance', 'dps', '*', 'A caster who uses lunar and nature magic from range.', 'starfire', 'Moonfury', 'Increases spell damage and Intellect.', { global: { spellDmgPct: 0.10 }, stats: { int: 4 } }),
|
||||
spec('feral', 'druid', 'Feral', 'tank', 'x', 'A shapeshifter who tanks in bear form and fights up close.', 'bear_form', 'Heart of the Wild', 'Increases threat, armor, and attack power.', { global: { threatPct: 0.20, meleeDmgPct: 0.06 }, stats: { armorPct: 0.08 } }),
|
||||
spec('restoration', 'druid', 'Restoration', 'healer', '+', 'A healer using heal-over-time effects and efficient nature magic.', 'regrowth', 'Gift of Nature', 'Increases healing done by 14%.', { global: { healPct: 0.14 } }),
|
||||
]
|
||||
|
||||
const DRUID_SPEC_NODES: TalentNode[] = [
|
||||
passive('bal_imp_wrath', 'spec', 'balance', 3, { ability: [{ ability: 'wrath', castPct: -0.04, dmgPct: 0.05 }] }, '*', 'Improved Wrath', 'Makes Wrath faster and stronger per rank.', 0, 0),
|
||||
passive('bal_imp_moonfire', 'spec', 'balance', 3, { ability: [{ ability: 'moonfire', dmgPct: 0.08 }] }, '*', 'Improved Moonfire', 'Increases Moonfire damage by 8% per rank.', 0, 2),
|
||||
passive('bal_natures_reach', 'spec', 'balance', 2, { ability: [{ ability: 'entangling_roots', castPct: -0.12 }, { ability: 'starfire', castPct: -0.08 }] }, '>', 'Nature Reach', 'Improves root and Starfire cast flow per rank.', 1, 0, { pointsGate: 2, requires: ['bal_imp_wrath'] }),
|
||||
passive('bal_vengeance', 'spec', 'balance', 2, { stats: { crit: 0.02 } }, 'x', 'Vengeance', 'Increases critical strike chance by 2% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('bal_choice', 'spec', 'balance', '@', 'Moonkin Path', 'Choose one Balance refinement.', 2, 1, [
|
||||
{ id: 'bal_choice_moonkin', name: 'Moonkin Form', icon: '#', description: 'Increases armor and spell damage.', effect: { stats: { armorPct: 0.14 }, global: { spellDmgPct: 0.06 } } },
|
||||
{ id: 'bal_choice_grace', name: 'Nature Grace', icon: '>', description: 'Wrath and Starfire cast 15% faster.', effect: { ability: [{ ability: 'wrath', castPct: -0.15 }, { ability: 'starfire', castPct: -0.15 }] } },
|
||||
{ id: 'bal_choice_moonglow', name: 'Moonglow', icon: 'v', description: 'Balance spells cost 15% less.', effect: { ability: [{ ability: 'wrath', costPct: -0.15 }, { ability: 'moonfire', costPct: -0.15 }, { ability: 'starfire', costPct: -0.15 }] } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('bal_starfire_mastery', 'spec', 'balance', 2, { ability: [{ ability: 'starfire', dmgPct: 0.12, castPct: -0.08 }] }, '*', 'Starfire Mastery', 'Improves Starfire damage and cast speed per rank.', 3, 1, { pointsGate: 8, requires: ['bal_choice'] }),
|
||||
|
||||
passive('feral_thick_hide', 'spec', 'feral', 3, { stats: { armorPct: 0.05 } }, '#', 'Thick Hide', 'Increases armor by 5% per rank.', 0, 0),
|
||||
passive('feral_ferocity', 'spec', 'feral', 3, { ability: [{ ability: 'maul', costPct: -0.06 }, { ability: 'claw', costPct: -0.06 }] }, 'v', 'Ferocity', 'Reduces Maul and Claw cost by 6% per rank.', 0, 2),
|
||||
passive('feral_brutal_impact', 'spec', 'feral', 2, { ability: [{ ability: 'maul', dmgPct: 0.10 }, { ability: 'swipe', dmgPct: 0.10 }] }, 'x', 'Brutal Impact', 'Increases bear attack damage per rank.', 1, 0, { pointsGate: 2, requires: ['feral_thick_hide'] }),
|
||||
passive('feral_feline_swiftness', 'spec', 'feral', 2, { stats: { dodge: 0.02, agi: 2 } }, '>', 'Feline Swiftness', 'Increases dodge and Agility per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('feral_choice', 'spec', 'feral', '@', 'Feral Instinct', 'Choose one Feral refinement.', 2, 1, [
|
||||
{ id: 'feral_choice_bear', name: 'Dire Bear', icon: '#', description: 'Increases armor and threat.', effect: { stats: { armorPct: 0.12 }, global: { threatPct: 0.10 } } },
|
||||
{ id: 'feral_choice_cat', name: 'Predatory Strikes', icon: 'x', description: 'Increases melee damage by 10%.', effect: { global: { meleeDmgPct: 0.10 } } },
|
||||
{ id: 'feral_choice_survival', name: 'Survival Instincts', icon: '+', description: 'Increases maximum health by 14%.', effect: { stats: { maxHpPct: 0.14 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('feral_heart_wild', 'spec', 'feral', 2, { stats: { staPct: 0.05, apPct: 0.05 }, global: { threatPct: 0.05 } }, 'x', 'Heart of the Wild', 'Improves stamina, attack power, and threat per rank.', 3, 1, { pointsGate: 8, requires: ['feral_choice'] }),
|
||||
|
||||
passive('rest_imp_rejuv', 'spec', 'restoration', 3, { ability: [{ ability: 'rejuvenation', dmgPct: 0.08 }] }, '+', 'Improved Rejuvenation', 'Increases Rejuvenation healing by 8% per rank.', 0, 0),
|
||||
passive('rest_druid_naturalist', 'spec', 'restoration', 3, { ability: [{ ability: 'healing_touch', castPct: -0.05, dmgPct: 0.05 }] }, '+', 'Naturalist', 'Makes Healing Touch faster and stronger per rank.', 0, 2),
|
||||
passive('rest_reflection', 'spec', 'restoration', 2, { ability: [{ ability: 'healing_touch', costPct: -0.10 }, { ability: 'rejuvenation', costPct: -0.10 }] }, 'v', 'Reflection', 'Reduces core healing costs by 10% per rank.', 1, 0, { pointsGate: 2, requires: ['rest_imp_rejuv'] }),
|
||||
passive('rest_imp_regrowth', 'spec', 'restoration', 2, { ability: [{ ability: 'regrowth', dmgPct: 0.12 }] }, '+', 'Improved Regrowth', 'Increases Regrowth healing by 12% per rank.', 1, 2, { pointsGate: 2 }),
|
||||
choice('rest_druid_choice', 'spec', 'restoration', '@', 'Restoration Gift', 'Choose one Restoration refinement.', 2, 1, [
|
||||
{ id: 'rest_druid_choice_swift', name: 'Nature Swiftness', icon: '>', description: 'Healing Touch casts 25% faster.', effect: { ability: [{ ability: 'healing_touch', castPct: -0.25 }] } },
|
||||
{ id: 'rest_druid_choice_innervate', name: 'Innervate', icon: 'v', description: 'Healing spells cost 16% less.', effect: { ability: [{ ability: 'healing_touch', costPct: -0.16 }, { ability: 'regrowth', costPct: -0.16 }, { ability: 'rejuvenation', costPct: -0.16 }] } },
|
||||
{ id: 'rest_druid_choice_living', name: 'Living Spirit', icon: '*', description: 'Increases Spirit by 10.', effect: { stats: { spi: 10 } } },
|
||||
], { pointsGate: 5 }),
|
||||
passive('rest_tree_life', 'spec', 'restoration', 2, { global: { healPct: 0.07 }, stats: { spi: 3 } }, '+', 'Tree of Life', 'Increases healing and Spirit per rank.', 3, 1, { pointsGate: 8, requires: ['rest_druid_choice'] }),
|
||||
]
|
||||
|
||||
const TALENTS: Record<'priest' | 'paladin' | 'shaman' | 'druid', ClassTalents> = {
|
||||
paladin: { class: 'paladin', nodes: [...PALADIN_CLASS, ...PALADIN_SPEC_NODES], specs: PALADIN_SPECS },
|
||||
priest: { class: 'priest', nodes: [...PRIEST_CLASS, ...PRIEST_SPEC_NODES], specs: PRIEST_SPECS },
|
||||
shaman: { class: 'shaman', nodes: [...SHAMAN_CLASS, ...SHAMAN_SPEC_NODES], specs: SHAMAN_SPECS },
|
||||
druid: { class: 'druid', nodes: [...DRUID_CLASS, ...DRUID_SPEC_NODES], specs: DRUID_SPECS },
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export type PlayerClass =
|
||||
| 'warrior'
|
||||
| 'paladin'
|
||||
| 'hunter'
|
||||
| 'rogue'
|
||||
| 'priest'
|
||||
| 'shaman'
|
||||
| 'mage'
|
||||
| 'warlock'
|
||||
| 'druid'
|
||||
|
||||
export interface Stats {
|
||||
str: number
|
||||
agi: number
|
||||
sta: number
|
||||
int: number
|
||||
spi: number
|
||||
armor: number
|
||||
}
|
||||
|
||||
export interface WeaponInfo {
|
||||
min: number
|
||||
max: number
|
||||
speed: number
|
||||
dagger?: boolean
|
||||
}
|
||||
|
||||
export type EquipSlot =
|
||||
| 'mainhand'
|
||||
| 'helmet'
|
||||
| 'shoulder'
|
||||
| 'chest'
|
||||
| 'waist'
|
||||
| 'legs'
|
||||
| 'gloves'
|
||||
| 'feet'
|
||||
|
||||
export const EQUIP_SLOTS: readonly EquipSlot[] = [
|
||||
'mainhand',
|
||||
'helmet',
|
||||
'shoulder',
|
||||
'chest',
|
||||
'waist',
|
||||
'legs',
|
||||
'gloves',
|
||||
'feet',
|
||||
]
|
||||
|
||||
export interface ItemDef {
|
||||
id: string
|
||||
name: string
|
||||
kind: 'weapon' | 'armor' | 'quest' | 'junk' | 'food' | 'drink' | 'tool' | 'potion' | 'elixir'
|
||||
slot?: EquipSlot
|
||||
weapon?: WeaponInfo
|
||||
stats?: Partial<Stats>
|
||||
sellValue: number
|
||||
buyValue?: number
|
||||
armorType?: 'cloth' | 'leather' | 'mail'
|
||||
questId?: string
|
||||
quality?: 'poor' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
requiredClass?: PlayerClass[]
|
||||
}
|
||||
|
||||
export interface InvSlot {
|
||||
itemId: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface LootEntry {
|
||||
itemId?: string
|
||||
copper?: number
|
||||
chance: number
|
||||
questId?: string
|
||||
rollGroup?: string
|
||||
}
|
||||
|
||||
export type MobFamily =
|
||||
| 'beast'
|
||||
| 'humanoid'
|
||||
| 'murloc'
|
||||
| 'spider'
|
||||
| 'kobold'
|
||||
| 'undead'
|
||||
| 'troll'
|
||||
| 'ogre'
|
||||
| 'elemental'
|
||||
| 'dragonkin'
|
||||
| 'demon'
|
||||
|
||||
export interface MobTemplate {
|
||||
id: string
|
||||
name: string
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
family: MobFamily
|
||||
hpPerLevel: number
|
||||
hpBase: number
|
||||
dmgBase: number
|
||||
dmgPerLevel: number
|
||||
attackSpeed: number
|
||||
armorPerLevel: number
|
||||
moveSpeed: number
|
||||
aggroRadius: number
|
||||
loot: LootEntry[]
|
||||
scale: number
|
||||
color: number
|
||||
boss?: boolean
|
||||
rare?: boolean
|
||||
elite?: boolean
|
||||
ccImmune?: boolean
|
||||
aoePulse?: {
|
||||
min: number
|
||||
max: number
|
||||
radius: number
|
||||
every: number
|
||||
name: string
|
||||
school?: string
|
||||
fx?: 'nova' | 'projectile'
|
||||
}
|
||||
summonAdds?: { mobId: string; count: number; atHpPct: number[] }
|
||||
enrage?: { belowHpPct: number; dmgMult: number; hasteMult?: number }
|
||||
stomp?: { radius: number; every: number; duration: number; min: number; max: number; name: string }
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||
import { getArenaClassKit } from '../actionClassKits'
|
||||
import type { ArenaClassId } from '../actionClassKits'
|
||||
import { ACTION_RENDER_QUALITY } from '../action3dSceneKit'
|
||||
import {
|
||||
ACTION_CLASS_SKIN_COUNTS,
|
||||
HEALER_ACTION_CLASSES,
|
||||
type ActionCharacter,
|
||||
type ActionCharacterCreateInput,
|
||||
type ActionSkinCatalog,
|
||||
} from '../actionMode'
|
||||
import type { PlayerClass } from '../claudeCraftTypes'
|
||||
|
||||
type CharacterPreviewRuntime = {
|
||||
camera: THREE.PerspectiveCamera
|
||||
controls: {
|
||||
dragging: boolean
|
||||
lastX: number
|
||||
yaw: number
|
||||
}
|
||||
mixer: THREE.AnimationMixer | null
|
||||
renderer: THREE.WebGLRenderer
|
||||
root: THREE.Group
|
||||
scene: THREE.Scene
|
||||
}
|
||||
|
||||
const CLASS_META: Record<PlayerClass, { label: string, role: string, detail: string, glyph: string }> = {
|
||||
warrior: { label: 'Warrior', role: 'Tank', detail: 'Locked', glyph: 'W' },
|
||||
paladin: { label: 'Paladin', role: 'Healer', detail: 'Mail holy support', glyph: 'P' },
|
||||
hunter: { label: 'Hunter', role: 'Damage', detail: 'Locked', glyph: 'H' },
|
||||
rogue: { label: 'Rogue', role: 'Damage', detail: 'Locked', glyph: 'R' },
|
||||
priest: { label: 'Priest', role: 'Healer', detail: 'Cloth direct heals', glyph: '+' },
|
||||
shaman: { label: 'Shaman', role: 'Healer', detail: 'Mail ritual caster', glyph: 'S' },
|
||||
mage: { label: 'Mage', role: 'Damage', detail: 'Locked', glyph: 'M' },
|
||||
warlock: { label: 'Warlock', role: 'Damage', detail: 'Locked', glyph: 'W' },
|
||||
druid: { label: 'Druid', role: 'Healer', detail: 'Leather nature magic', glyph: 'D' },
|
||||
}
|
||||
|
||||
const previewLoader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder)
|
||||
const previewTextureLoader = new THREE.TextureLoader()
|
||||
const SKIN_FILES = ['base.png', 'alt_a.png', 'alt_b.png', 'alt_c.png'] as const
|
||||
|
||||
export function ActionCharacterCreator({
|
||||
existingNames = [],
|
||||
onCancel,
|
||||
onCreate,
|
||||
}: {
|
||||
existingNames?: string[]
|
||||
onCancel?: () => void
|
||||
onCreate: (input: ActionCharacterCreateInput) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [classId, setClassId] = useState<PlayerClass>('priest')
|
||||
const [skin, setSkin] = useState(0)
|
||||
const [skinCatalog] = useState<ActionSkinCatalog>('class')
|
||||
const skinOptions = useMemo(
|
||||
() => Array.from({ length: ACTION_CLASS_SKIN_COUNTS[classId] }, (_, index) => index),
|
||||
[classId],
|
||||
)
|
||||
const trimmedName = name.trim()
|
||||
const nameTaken = existingNames.some((existing) => existing.toLowerCase() === trimmedName.toLowerCase())
|
||||
const canCreate = trimmedName.length >= 2 && !nameTaken
|
||||
|
||||
return (
|
||||
<section className="character-creator-panel">
|
||||
<div className="character-creator-preview">
|
||||
<ActionCharacterModelPreview classId={classId} skin={skin} />
|
||||
<div>
|
||||
<p className="eyebrow">Appearance</p>
|
||||
<h2>{trimmedName || 'New Character'}</h2>
|
||||
<span>{CLASS_META[classId].label} · Skin {skin + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="character-creator-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!canCreate) return
|
||||
onCreate({
|
||||
appearance: { skin, skinCatalog },
|
||||
classId,
|
||||
name: trimmedName,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={20}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Character name"
|
||||
value={name}
|
||||
/>
|
||||
</label>
|
||||
{nameTaken && <p className="character-creator-error">Name already exists.</p>}
|
||||
|
||||
<div className="character-creator-group">
|
||||
<div>
|
||||
<p className="eyebrow">Class</p>
|
||||
<h3>Healer</h3>
|
||||
</div>
|
||||
<div className="character-class-grid">
|
||||
{HEALER_ACTION_CLASSES.map((healerClass) => (
|
||||
<button
|
||||
aria-pressed={classId === healerClass}
|
||||
key={healerClass}
|
||||
onClick={() => {
|
||||
setClassId(healerClass)
|
||||
setSkin(0)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{CLASS_META[healerClass].glyph}</span>
|
||||
<strong>{CLASS_META[healerClass].label}</strong>
|
||||
<small>{CLASS_META[healerClass].detail}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-creator-group">
|
||||
<div>
|
||||
<p className="eyebrow">Look</p>
|
||||
<h3>Skin</h3>
|
||||
</div>
|
||||
<div className="character-skin-grid" aria-label="Appearance skins">
|
||||
{skinOptions.map((skinIndex) => (
|
||||
<button
|
||||
aria-pressed={skin === skinIndex}
|
||||
key={skinIndex}
|
||||
onClick={() => setSkin(skinIndex)}
|
||||
type="button"
|
||||
>
|
||||
<span>Skin {skinIndex + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-creator-actions">
|
||||
{onCancel && <button className="back-button" onClick={onCancel} type="button">Cancel</button>}
|
||||
<button className="primary-button" disabled={!canCreate} type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCharacterModelPreview({ classId, skin }: { classId: PlayerClass, skin: number }) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null)
|
||||
const runtimeRef = useRef<CharacterPreviewRuntime | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current
|
||||
if (!mount) return
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0x101319)
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 80)
|
||||
camera.position.set(0, 1.55, 4.2)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, alpha: true, powerPreference: 'high-performance' })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ACTION_RENDER_QUALITY.maxPixelRatio))
|
||||
renderer.shadowMap.enabled = ACTION_RENDER_QUALITY.shadows
|
||||
mount.appendChild(renderer.domElement)
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xf6f0de, 0x202937, 1.65))
|
||||
const key = new THREE.DirectionalLight(0xffedbd, 2.8)
|
||||
key.position.set(-4, 6, 5)
|
||||
scene.add(key)
|
||||
const rim = new THREE.DirectionalLight(0x8ec7ff, 1)
|
||||
rim.position.set(4, 3, -5)
|
||||
scene.add(rim)
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(1.35, 32),
|
||||
new THREE.MeshStandardMaterial({ color: 0x232832, roughness: 0.86 }),
|
||||
)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
|
||||
const root = new THREE.Group()
|
||||
scene.add(root)
|
||||
|
||||
const runtime: CharacterPreviewRuntime = {
|
||||
camera,
|
||||
controls: { dragging: false, lastX: 0, yaw: 0 },
|
||||
mixer: null,
|
||||
renderer,
|
||||
root,
|
||||
scene,
|
||||
}
|
||||
runtimeRef.current = runtime
|
||||
|
||||
const resize = () => {
|
||||
const rect = mount.getBoundingClientRect()
|
||||
const width = Math.max(1, rect.width)
|
||||
const height = Math.max(1, rect.height)
|
||||
renderer.setSize(width, height, false)
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(mount)
|
||||
resize()
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
renderer.domElement.setPointerCapture(event.pointerId)
|
||||
runtime.controls.dragging = true
|
||||
runtime.controls.lastX = event.clientX
|
||||
}
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!runtime.controls.dragging) return
|
||||
runtime.controls.yaw += (event.clientX - runtime.controls.lastX) * 0.012
|
||||
runtime.controls.lastX = event.clientX
|
||||
}
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
renderer.domElement.releasePointerCapture(event.pointerId)
|
||||
runtime.controls.dragging = false
|
||||
}
|
||||
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
|
||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
||||
renderer.domElement.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
let frameId = 0
|
||||
const animate = () => {
|
||||
const delta = Math.min(clock.getDelta(), 0.05)
|
||||
if (!runtime.controls.dragging) runtime.controls.yaw += delta * 0.32
|
||||
root.rotation.y = runtime.controls.yaw
|
||||
runtime.mixer?.update(delta)
|
||||
renderer.render(scene, camera)
|
||||
frameId = requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
|
||||
renderer.domElement.removeEventListener('pointermove', handlePointerMove)
|
||||
renderer.domElement.removeEventListener('pointerup', handlePointerUp)
|
||||
disposeObject(root)
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
runtimeRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current
|
||||
if (!runtime) return
|
||||
const activeRuntime = runtime
|
||||
let cancelled = false
|
||||
clearGroup(activeRuntime.root)
|
||||
activeRuntime.mixer = null
|
||||
|
||||
async function loadPreview() {
|
||||
const kit = getArenaClassKit(classId as ArenaClassId)
|
||||
const gltf = await previewLoader.loadAsync(kit.model)
|
||||
if (cancelled) return
|
||||
|
||||
const model = gltf.scene
|
||||
applyPreviewSkin(model, classId, skin)
|
||||
model.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
child.frustumCulled = false
|
||||
})
|
||||
|
||||
for (const weapon of kit.weapons) {
|
||||
await attachPreviewWeapon(model, weapon.hand, weapon.model, () => cancelled)
|
||||
}
|
||||
if (cancelled) {
|
||||
disposeObject(model)
|
||||
return
|
||||
}
|
||||
|
||||
activeRuntime.root.add(model)
|
||||
fitPreviewModel(model, activeRuntime.camera)
|
||||
const idle = gltf.animations.find((clip) => clip.name === 'Idle')
|
||||
?? gltf.animations.find((clip) => clip.name.toLowerCase().includes('idle'))
|
||||
?? gltf.animations[0]
|
||||
if (idle) {
|
||||
activeRuntime.mixer = new THREE.AnimationMixer(model)
|
||||
activeRuntime.mixer.clipAction(idle).play()
|
||||
}
|
||||
}
|
||||
|
||||
loadPreview().catch(() => null)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [classId, skin])
|
||||
|
||||
return (
|
||||
<div className="character-model-preview" ref={mountRef} aria-label={`${CLASS_META[classId].label} model preview`} />
|
||||
)
|
||||
}
|
||||
|
||||
async function attachPreviewWeapon(model: THREE.Object3D, hand: 'handslot.l' | 'handslot.r', url: string, cancelled: () => boolean) {
|
||||
const handSlot = model.getObjectByName(hand)
|
||||
if (!handSlot) return
|
||||
const gltf = await previewLoader.loadAsync(url)
|
||||
if (cancelled()) return
|
||||
const weapon = gltf.scene
|
||||
weapon.name = 'creatorPreviewWeapon'
|
||||
weapon.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
child.frustumCulled = false
|
||||
})
|
||||
handSlot.add(weapon)
|
||||
}
|
||||
|
||||
function applyPreviewSkin(model: THREE.Object3D, classId: PlayerClass, skin: number) {
|
||||
const folder = getSkinFolder(classId)
|
||||
const file = SKIN_FILES[Math.max(0, Math.min(SKIN_FILES.length - 1, skin))] ?? SKIN_FILES[0]
|
||||
const texture = previewTextureLoader.load(`/textures/skins/${folder}/${file}`)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.flipY = false
|
||||
|
||||
model.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
for (const material of materials) {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial) && !(material instanceof THREE.MeshBasicMaterial)) continue
|
||||
material.map = texture
|
||||
material.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getSkinFolder(classId: PlayerClass) {
|
||||
if (classId === 'paladin') return 'paladin'
|
||||
if (classId === 'druid') return 'druid'
|
||||
if (classId === 'shaman') return 'barbarian'
|
||||
return 'mage'
|
||||
}
|
||||
|
||||
function fitPreviewModel(object: THREE.Object3D, camera: THREE.PerspectiveCamera) {
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const size = new THREE.Vector3()
|
||||
const center = new THREE.Vector3()
|
||||
box.getSize(size)
|
||||
box.getCenter(center)
|
||||
object.position.x -= center.x
|
||||
object.position.z -= center.z
|
||||
object.position.y -= box.min.y
|
||||
const height = Math.max(size.y, 1)
|
||||
const distance = Math.max(2.7, height * 1.55)
|
||||
camera.position.set(0, height * 0.55, distance)
|
||||
camera.lookAt(0, height * 0.48, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group) {
|
||||
for (const child of [...group.children]) {
|
||||
group.remove(child)
|
||||
disposeObject(child)
|
||||
}
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.geometry.dispose()
|
||||
if (Array.isArray(child.material)) child.material.forEach(disposeMaterial)
|
||||
else disposeMaterial(child.material)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material) {
|
||||
const candidate = material as THREE.Material & { map?: THREE.Texture | null }
|
||||
candidate.map?.dispose()
|
||||
material.dispose()
|
||||
}
|
||||
|
||||
export function ActionCharacterRosterPanel({
|
||||
activeCharacterId,
|
||||
characters,
|
||||
onCreateNew,
|
||||
onSwitch,
|
||||
}: {
|
||||
activeCharacterId: string | null
|
||||
characters: ActionCharacter[]
|
||||
onCreateNew: () => void
|
||||
onSwitch: (characterId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="character-roster-panel">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Characters</p>
|
||||
<h2>Roster</h2>
|
||||
</div>
|
||||
<button className="primary-button" onClick={onCreateNew} type="button">New Character</button>
|
||||
</header>
|
||||
<div className="character-roster-list">
|
||||
{characters.map((character) => (
|
||||
<button
|
||||
aria-pressed={character.id === activeCharacterId}
|
||||
key={character.id}
|
||||
onClick={() => onSwitch(character.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className={`character-roster-avatar class-${character.classId} skin-${character.appearance.skin}`}>
|
||||
<span>{CLASS_META[character.classId].glyph}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
<small>{CLASS_META[character.classId].label} · Level {character.level}</small>
|
||||
<small>{character.inventory.length} bag slots · {character.copper} copper</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ActionCharacter } from '../actionMode'
|
||||
import type { PlayerClass } from '../claudeCraftTypes'
|
||||
|
||||
const CLASS_META: Record<PlayerClass, { glyph: string; label: string }> = {
|
||||
warrior: { glyph: 'W', label: 'Warrior' },
|
||||
paladin: { glyph: 'P', label: 'Paladin' },
|
||||
hunter: { glyph: 'H', label: 'Hunter' },
|
||||
rogue: { glyph: 'R', label: 'Rogue' },
|
||||
priest: { glyph: '+', label: 'Priest' },
|
||||
shaman: { glyph: 'S', label: 'Shaman' },
|
||||
mage: { glyph: 'M', label: 'Mage' },
|
||||
warlock: { glyph: 'W', label: 'Warlock' },
|
||||
druid: { glyph: 'D', label: 'Druid' },
|
||||
}
|
||||
|
||||
export function ActionCharacterRosterPanel({
|
||||
activeCharacterId,
|
||||
characters,
|
||||
onCreateNew,
|
||||
onSwitch,
|
||||
}: {
|
||||
activeCharacterId: string | null
|
||||
characters: ActionCharacter[]
|
||||
onCreateNew: () => void
|
||||
onSwitch: (characterId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="character-roster-panel">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Characters</p>
|
||||
<h2>Roster</h2>
|
||||
</div>
|
||||
<button className="primary-button" onClick={onCreateNew} type="button">New Character</button>
|
||||
</header>
|
||||
<div className="character-roster-list">
|
||||
{characters.map((character) => (
|
||||
<button
|
||||
aria-pressed={character.id === activeCharacterId}
|
||||
key={character.id}
|
||||
onClick={() => onSwitch(character.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className={`character-roster-avatar class-${character.classId} skin-${character.appearance.skin}`}>
|
||||
<span>{CLASS_META[character.classId].glyph}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
<small>{CLASS_META[character.classId].label} · Level {character.level}</small>
|
||||
<small>{character.inventory.length} bag slots · {character.copper} copper</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
ACTION_EQUIP_SLOTS,
|
||||
chooseActionTalentSpec,
|
||||
getEquippedActionItem,
|
||||
getActionTalentPoints,
|
||||
respecActionTalents,
|
||||
spendActionTalentPoint,
|
||||
type ActionCharacter,
|
||||
} from '../actionMode'
|
||||
import {
|
||||
getTalentSpec,
|
||||
talentsFor,
|
||||
type TalentChoiceOption,
|
||||
type TalentEffect,
|
||||
type TalentNode,
|
||||
} from '../claudeCraftTalents'
|
||||
import { canEquipItem } from '../claudeCraftEquipmentRules'
|
||||
import { getClaudeCraftItem, getItemStatsText } from '../claudeCraftItems'
|
||||
import type { EquipSlot, ItemDef, Stats } from '../claudeCraftTypes'
|
||||
|
||||
type ItemEntry = {
|
||||
count: number
|
||||
item: ItemDef
|
||||
}
|
||||
|
||||
type InventoryFilter = 'all' | 'weapon' | 'armor' | 'other'
|
||||
|
||||
export function ActionCharacterMenu({
|
||||
character,
|
||||
onOpenInventory,
|
||||
onOpenTalents,
|
||||
}: {
|
||||
character: ActionCharacter
|
||||
onOpenInventory?: () => void
|
||||
onOpenTalents?: () => void
|
||||
}) {
|
||||
const [selectedSlot, setSelectedSlot] = useState<EquipSlot>('mainhand')
|
||||
const selectedItem = getEquippedActionItem(character, selectedSlot)
|
||||
const stats = useMemo(() => getEquipmentStats(character), [character])
|
||||
const weapon = getEquippedActionItem(character, 'mainhand')
|
||||
|
||||
return (
|
||||
<section className="action-gear-panel claudecraft-menu">
|
||||
<article className="action-gear-summary">
|
||||
<div>
|
||||
<p className="eyebrow">ClaudeCraft</p>
|
||||
<h2>Character</h2>
|
||||
</div>
|
||||
<div className="claudecraft-character-meta">
|
||||
<span>{character.classId}</span>
|
||||
<span>Level {character.level}</span>
|
||||
<span>{character.copper} Copper</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="claudecraft-character-layout">
|
||||
<div className="action-slot-grid claudecraft-paperdoll" aria-label="Equipped gear">
|
||||
{ACTION_EQUIP_SLOTS.map((slot) => {
|
||||
const equipped = getEquippedActionItem(character, slot.slot)
|
||||
return (
|
||||
<button
|
||||
className={selectedSlot === slot.slot ? 'selected' : ''}
|
||||
key={slot.slot}
|
||||
onClick={() => setSelectedSlot(slot.slot)}
|
||||
type="button"
|
||||
>
|
||||
<span>{slot.glyph}</span>
|
||||
<strong>{slot.label}</strong>
|
||||
<small>{equipped?.name ?? 'Empty'}</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<aside className="action-inventory-panel claudecraft-character-sheet">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Stats</p>
|
||||
<h2>{character.name}</h2>
|
||||
</div>
|
||||
{onOpenInventory && (
|
||||
<button className="back-button" onClick={onOpenInventory} type="button">Bags</button>
|
||||
)}
|
||||
{onOpenTalents && (
|
||||
<button className="back-button" onClick={onOpenTalents} type="button">Talents</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<dl className="action-stat-compare claudecraft-stat-grid">
|
||||
<StatLine label="Armor" value={stats.armor} />
|
||||
<StatLine label="Stamina" value={stats.sta} />
|
||||
<StatLine label="Strength" value={stats.str} />
|
||||
<StatLine label="Agility" value={stats.agi} />
|
||||
<StatLine label="Intellect" value={stats.int} />
|
||||
<StatLine label="Spirit" value={stats.spi} />
|
||||
<StatLine label="Weapon" value={weapon?.weapon ? `${weapon.weapon.min}-${weapon.weapon.max}` : 'None'} />
|
||||
<StatLine label="Speed" value={weapon?.weapon ? weapon.weapon.speed.toFixed(1) : '-'} />
|
||||
</dl>
|
||||
|
||||
<GearDetail item={selectedItem} slot={selectedSlot} />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionTalentMenu({
|
||||
character,
|
||||
onBack,
|
||||
onCharacterChange,
|
||||
}: {
|
||||
character: ActionCharacter
|
||||
onBack?: () => void
|
||||
onCharacterChange?: (character: ActionCharacter) => void
|
||||
}) {
|
||||
const talents = talentsFor(character.classId)
|
||||
const points = getActionTalentPoints(character)
|
||||
const [selectedChoices, setSelectedChoices] = useState<Record<string, string>>({})
|
||||
const [talentTab, setTalentTab] = useState<'class' | string>('class')
|
||||
const [selectedTalentId, setSelectedTalentId] = useState<string | null>(null)
|
||||
const canEdit = Boolean(onCharacterChange)
|
||||
|
||||
if (!talents) {
|
||||
return (
|
||||
<section className="action-gear-panel claudecraft-menu">
|
||||
<article className="action-gear-summary action-talent-summary">
|
||||
<div className="claudecraft-character-meta">
|
||||
<span>No trees</span>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSpecId = talentTab === 'class' ? character.talents.spec : talentTab
|
||||
const selectedSpec = getTalentSpec(character.classId, activeSpecId)
|
||||
const classNodes = talents.nodes.filter((node) => node.tree === 'class')
|
||||
const specNodes = talents.nodes.filter((node) => node.tree === 'spec' && node.specId === activeSpecId)
|
||||
const visibleNodes = talentTab === 'class' ? classNodes : specNodes
|
||||
const selectedTalent = visibleNodes.find((node) => node.id === selectedTalentId) ?? visibleNodes[0] ?? null
|
||||
const treeSpent = (tree: 'class' | 'spec', specId = character.talents.spec) => talents.nodes
|
||||
.filter((node) => node.tree === tree && (tree === 'class' || node.specId === specId))
|
||||
.reduce((sum, node) => sum + (character.talents.ranks[node.id] ?? 0), 0)
|
||||
|
||||
const spendPoint = (node: TalentNode, choiceId?: string) => {
|
||||
if (!canEdit) return
|
||||
const result = spendActionTalentPoint(character, node.id, choiceId ?? selectedChoices[node.id])
|
||||
if (result.ok) onCharacterChange?.(result.character)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="action-gear-panel claudecraft-menu action-talents-menu">
|
||||
<article className="action-gear-summary action-talent-summary">
|
||||
<div className="claudecraft-character-meta">
|
||||
<span>{character.name}</span>
|
||||
<span>Level {character.level}</span>
|
||||
<span className="talent-available-pill">Available: {points.available} / {points.total}</span>
|
||||
<span>Spent: {points.spent}</span>
|
||||
</div>
|
||||
<div className="arena-pause-actions action-talent-actions">
|
||||
{onBack && (
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
)}
|
||||
<button
|
||||
className="back-button"
|
||||
disabled={!canEdit || points.spent <= 0}
|
||||
onClick={() => onCharacterChange?.(respecActionTalents(character))}
|
||||
type="button"
|
||||
>
|
||||
Respec
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="claudecraft-talent-tabs" role="tablist" aria-label="Talent trees">
|
||||
<button
|
||||
aria-selected={talentTab === 'class'}
|
||||
className={talentTab === 'class' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setTalentTab('class')
|
||||
setSelectedTalentId(null)
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span>Class</span>
|
||||
<b>{treeSpent('class')}</b>
|
||||
</button>
|
||||
{talents.specs.map((spec) => (
|
||||
<button
|
||||
aria-selected={talentTab === spec.id}
|
||||
className={talentTab === spec.id ? 'active' : ''}
|
||||
key={spec.id}
|
||||
onClick={() => {
|
||||
setTalentTab(spec.id)
|
||||
setSelectedTalentId(null)
|
||||
if (character.talents.spec !== spec.id) {
|
||||
onCharacterChange?.(chooseActionTalentSpec(character, spec.id))
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span>{spec.name}</span>
|
||||
<b>{treeSpent('spec', spec.id)}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{talentTab !== 'class' && selectedSpec && (
|
||||
<div className="claudecraft-talent-mastery">
|
||||
<strong>Mastery: {selectedSpec.mastery.name}</strong>
|
||||
<span>{selectedSpec.mastery.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="claudecraft-talent-body" role="tabpanel">
|
||||
<TalentTreeCanvas
|
||||
allocation={character.talents}
|
||||
canEdit={canEdit}
|
||||
classId={character.classId}
|
||||
choices={selectedChoices}
|
||||
nodes={visibleNodes}
|
||||
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
|
||||
onSpend={spendPoint}
|
||||
points={points}
|
||||
selectedId={selectedTalent?.id ?? null}
|
||||
selectedSpec={selectedSpec?.id ?? null}
|
||||
onSelect={(node) => setSelectedTalentId(node.id)}
|
||||
/>
|
||||
<TalentDetailPanel
|
||||
allocation={character.talents}
|
||||
canEdit={canEdit}
|
||||
classId={character.classId}
|
||||
choices={selectedChoices}
|
||||
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
|
||||
onSpend={spendPoint}
|
||||
node={selectedTalent}
|
||||
nodes={visibleNodes}
|
||||
points={points}
|
||||
selectedSpec={selectedSpec?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionInventoryMenu({
|
||||
character,
|
||||
onEquip,
|
||||
}: {
|
||||
character: ActionCharacter
|
||||
onEquip: (itemId: string) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<InventoryFilter>('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const [tooltipItemId, setTooltipItemId] = useState<string | null>(null)
|
||||
const entries = useMemo(() => getInventoryEntries(character), [character])
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
return entries
|
||||
.filter(({ item }) => {
|
||||
if (filter === 'weapon' && item.kind !== 'weapon') return false
|
||||
if (filter === 'armor' && item.kind !== 'armor') return false
|
||||
if (filter === 'other' && (item.kind === 'weapon' || item.kind === 'armor')) return false
|
||||
if (!normalizedQuery) return true
|
||||
return item.name.toLowerCase().includes(normalizedQuery)
|
||||
})
|
||||
.sort((a, b) => (getItemPower(b.item) - getItemPower(a.item)) || a.item.name.localeCompare(b.item.name))
|
||||
}, [entries, filter, query])
|
||||
const tooltipEntry = filteredEntries.find((entry) => entry.item.id === tooltipItemId) ?? null
|
||||
const tooltipItem = tooltipEntry?.item ?? null
|
||||
const tooltipEquippedItem = tooltipItem?.slot ? getEquippedActionItem(character, tooltipItem.slot) : null
|
||||
|
||||
return (
|
||||
<section className="action-gear-panel claudecraft-menu">
|
||||
<article className="action-gear-summary">
|
||||
<div>
|
||||
<p className="eyebrow">ClaudeCraft</p>
|
||||
<h2>Inventory</h2>
|
||||
</div>
|
||||
<div className="claudecraft-character-meta">
|
||||
<span>{entries.length} Slots</span>
|
||||
<span>{getInventoryCount(entries)} Items</span>
|
||||
<span>{character.copper} Copper</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="claudecraft-bag-layout">
|
||||
<section className="action-inventory-panel claudecraft-bag-panel">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Bags</p>
|
||||
<h2>{getFilterLabel(filter)}</h2>
|
||||
</div>
|
||||
<input
|
||||
aria-label="Search bags"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="claudecraft-bag-filters" aria-label="Bag filters">
|
||||
{(['all', 'weapon', 'armor', 'other'] as InventoryFilter[]).map((nextFilter) => (
|
||||
<button
|
||||
aria-pressed={filter === nextFilter}
|
||||
key={nextFilter}
|
||||
onClick={() => {
|
||||
setFilter(nextFilter)
|
||||
setTooltipItemId(null)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{getFilterLabel(nextFilter)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="action-inventory-list claudecraft-bag-list" onMouseLeave={() => setTooltipItemId(null)}>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<p>Bags empty.</p>
|
||||
) : (
|
||||
filteredEntries.map(({ item, count }) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onBlur={() => setTooltipItemId(null)}
|
||||
onClick={() => {
|
||||
if (canEquipInventoryItem(character, item)) onEquip(item.id)
|
||||
}}
|
||||
onFocus={() => setTooltipItemId(item.id)}
|
||||
onMouseEnter={() => setTooltipItemId(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{getItemTag(item)}{count > 1 ? ` x${count}` : ''}</small>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{tooltipItem && (
|
||||
<InventoryItemTooltip
|
||||
canEquip={canEquipInventoryItem(character, tooltipItem)}
|
||||
count={tooltipEntry?.count ?? 1}
|
||||
equippedItem={tooltipEquippedItem}
|
||||
item={tooltipItem}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionMenuOverlay({
|
||||
children,
|
||||
onBack,
|
||||
onClose,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onBack: () => void
|
||||
onClose: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="arena-pause-backdrop claudecraft-menu-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-menu-overlay-title">
|
||||
<section className="claudecraft-menu-overlay">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2 id="action-menu-overlay-title">{title}</h2>
|
||||
</div>
|
||||
<div className="arena-pause-actions">
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
<button className="primary-button" onClick={onClose} type="button">Resume</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TalentTreeCanvas({
|
||||
allocation,
|
||||
canEdit,
|
||||
classId,
|
||||
choices,
|
||||
nodes,
|
||||
onChoice,
|
||||
onSelect,
|
||||
onSpend,
|
||||
points,
|
||||
selectedId,
|
||||
selectedSpec,
|
||||
}: {
|
||||
allocation: ActionCharacter['talents']
|
||||
canEdit: boolean
|
||||
classId: ActionCharacter['classId']
|
||||
choices: Record<string, string>
|
||||
nodes: TalentNode[]
|
||||
onChoice: (nodeId: string, choiceId: string) => void
|
||||
onSelect: (node: TalentNode) => void
|
||||
onSpend: (node: TalentNode, choiceId?: string) => void
|
||||
points: { available: number, spent: number, total: number }
|
||||
selectedId: string | null
|
||||
selectedSpec: string | null
|
||||
}) {
|
||||
const cols = Math.max(1, ...nodes.map((node) => node.col + 1))
|
||||
const rows = Math.max(1, ...nodes.map((node) => node.row + 1))
|
||||
const cellWidth = 86
|
||||
const cellHeight = 70
|
||||
const nodeSize = 46
|
||||
const top = 8
|
||||
const width = cols * cellWidth
|
||||
const height = rows * cellHeight + top
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]))
|
||||
const centerX = (node: TalentNode) => node.col * cellWidth + cellWidth / 2
|
||||
const centerY = (node: TalentNode) => node.row * cellHeight + top + nodeSize / 2
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return <div className="claudecraft-talent-empty">Choose a specialization.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="claudecraft-talent-tree-scroll">
|
||||
<div className="claudecraft-talent-tree" style={{ height, width }}>
|
||||
<svg className="claudecraft-talent-arrows" height={height} width={width}>
|
||||
{nodes.flatMap((node) => (node.requires ?? []).map((required) => {
|
||||
const parent = byId.get(required)
|
||||
if (!parent) return null
|
||||
const filled = (allocation.ranks[required] ?? 0) > 0
|
||||
return (
|
||||
<line
|
||||
key={`${required}-${node.id}`}
|
||||
stroke={filled ? '#f5c843' : '#5a4a22'}
|
||||
strokeWidth="2"
|
||||
x1={centerX(parent)}
|
||||
x2={centerX(node)}
|
||||
y1={centerY(parent) + nodeSize / 2}
|
||||
y2={centerY(node) - nodeSize / 2}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
</svg>
|
||||
{nodes.map((node) => {
|
||||
const rank = allocation.ranks[node.id] ?? 0
|
||||
const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec)
|
||||
const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id
|
||||
const selectedChoice = node.choices?.find((choice) => choice.id === choiceId)
|
||||
const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0
|
||||
const maxed = rank >= node.maxRank
|
||||
const shape = node.kind === 'active' ? 'square' : node.kind === 'choice' ? 'octagon' : 'circle'
|
||||
const state = locked ? 'locked' : maxed ? 'maxed' : rank > 0 ? 'filled' : canSpend ? 'avail' : 'locked'
|
||||
return (
|
||||
<button
|
||||
aria-label={`${node.name}, rank ${rank}/${node.maxRank}`}
|
||||
aria-pressed={rank > 0}
|
||||
aria-disabled={!canSpend && rank <= 0}
|
||||
className={`claudecraft-talent-node ${shape} ${state} ${selectedId === node.id ? 'selected' : ''}`}
|
||||
key={node.id}
|
||||
onClick={() => {
|
||||
onSelect(node)
|
||||
if (node.kind === 'choice') {
|
||||
if (choiceId) onChoice(node.id, choiceId)
|
||||
if (rank <= 0 && canSpend) onSpend(node, choiceId)
|
||||
return
|
||||
}
|
||||
if (canSpend) onSpend(node)
|
||||
}}
|
||||
onMouseEnter={() => onSelect(node)}
|
||||
style={{
|
||||
left: node.col * cellWidth + (cellWidth - nodeSize) / 2,
|
||||
top: node.row * cellHeight + top,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="claudecraft-talent-icon"
|
||||
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
|
||||
/>
|
||||
{(rank > 0 || node.maxRank > 1) && (
|
||||
<span className="claudecraft-talent-rank">{rank}/{node.maxRank}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TalentDetailPanel({
|
||||
allocation,
|
||||
canEdit,
|
||||
classId,
|
||||
choices,
|
||||
node,
|
||||
nodes,
|
||||
onChoice,
|
||||
onSpend,
|
||||
points,
|
||||
selectedSpec,
|
||||
}: {
|
||||
allocation: ActionCharacter['talents']
|
||||
canEdit: boolean
|
||||
classId: ActionCharacter['classId']
|
||||
choices: Record<string, string>
|
||||
node: TalentNode | null
|
||||
nodes: TalentNode[]
|
||||
onChoice: (nodeId: string, choiceId: string) => void
|
||||
onSpend: (node: TalentNode, choiceId?: string) => void
|
||||
points: { available: number, spent: number, total: number }
|
||||
selectedSpec: string | null
|
||||
}) {
|
||||
if (!node) return <aside className="claudecraft-talent-detail"><span>Select a talent.</span></aside>
|
||||
|
||||
const rank = allocation.ranks[node.id] ?? 0
|
||||
const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec)
|
||||
const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id
|
||||
const selectedChoice = node.choices?.find((choice) => choice.id === choiceId)
|
||||
const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0
|
||||
|
||||
return (
|
||||
<aside className="claudecraft-talent-detail">
|
||||
<header>
|
||||
<span
|
||||
className="claudecraft-talent-detail-icon"
|
||||
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
|
||||
/>
|
||||
<div>
|
||||
<strong>{node.name}</strong>
|
||||
<small>Rank {rank}/{node.maxRank}</small>
|
||||
</div>
|
||||
</header>
|
||||
<p>{node.description}</p>
|
||||
{node.requires?.length ? <small>Requires: {node.requires.join(', ')}</small> : null}
|
||||
{node.pointsGate ? <small>{node.pointsGate} points required above.</small> : null}
|
||||
{locked && <small className="claudecraft-talent-warning">Locked</small>}
|
||||
{node.kind === 'choice' && node.choices && (
|
||||
<div className="claudecraft-talent-choice-list">
|
||||
{node.choices.map((choice) => {
|
||||
const selected = choiceId === choice.id
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'selected' : ''}
|
||||
disabled={!canEdit || (rank > 0 && !selected)}
|
||||
key={choice.id}
|
||||
onClick={() => {
|
||||
onChoice(node.id, choice.id)
|
||||
if (rank <= 0 && canSpend) onSpend(node, choice.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span style={{ backgroundImage: `url(${getTalentIconUrl(classId, choice)})` }} />
|
||||
<strong>{choice.name}</strong>
|
||||
<small>{choice.description}</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{node.kind !== 'choice' && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!canSpend}
|
||||
onClick={() => onSpend(node)}
|
||||
type="button"
|
||||
>
|
||||
Add Point
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function getTalentIconUrl(classId: ActionCharacter['classId'], source: TalentNode | TalentChoiceOption) {
|
||||
const abilityId = getTalentAbilityIconId(source.effect)
|
||||
if (abilityId) return `/ui/skills/${classId}/${abilityId}.png`
|
||||
return getTalentCrestIconUrl(source.effect, 'choices' in source ? 'choice' : 'node')
|
||||
}
|
||||
|
||||
function getTalentAbilityIconId(effect?: TalentEffect) {
|
||||
return effect?.grant?.ability ?? effect?.ability?.[0]?.ability ?? null
|
||||
}
|
||||
|
||||
function getTalentCrestIconUrl(effect: TalentEffect | undefined, kind: 'choice' | 'node') {
|
||||
const stat = effect?.stats ? Object.keys(effect.stats)[0] : ''
|
||||
const crest = stat.includes('armor')
|
||||
? { bg: '#2d3542', fg: '#d8e2ee', glyph: 'shield' }
|
||||
: stat.includes('dodge') || stat === 'agi'
|
||||
? { bg: '#163b4f', fg: '#9fe4ff', glyph: 'spark' }
|
||||
: stat.includes('hp') || stat === 'sta'
|
||||
? { bg: '#4f1418', fg: '#ffd6d6', glyph: 'heart' }
|
||||
: stat.includes('ap') || stat === 'str'
|
||||
? { bg: '#4d2f0c', fg: '#ffd36e', glyph: 'fist' }
|
||||
: effect?.global?.healPct
|
||||
? { bg: '#254525', fg: '#bcffb5', glyph: 'cross' }
|
||||
: effect?.global?.threatPct
|
||||
? { bg: '#313641', fg: '#d9e5ff', glyph: 'shield' }
|
||||
: effect?.global
|
||||
? { bg: '#4a3510', fg: '#ffe28a', glyph: 'burst' }
|
||||
: kind === 'choice'
|
||||
? { bg: '#2e1c4e', fg: '#f0d2ff', glyph: 'gem' }
|
||||
: { bg: '#252b35', fg: '#dde7ff', glyph: 'rune' }
|
||||
return svgDataUrl(renderTalentCrestSvg(crest.bg, crest.fg, crest.glyph))
|
||||
}
|
||||
|
||||
function svgDataUrl(svg: string) {
|
||||
return `data:image/svg+xml;base64,${window.btoa(svg)}`
|
||||
}
|
||||
|
||||
function renderTalentCrestSvg(bg: string, fg: string, glyph: string) {
|
||||
const common = `fill="${fg}" stroke="#120d04" stroke-width="3" stroke-linejoin="round"`
|
||||
const mark = glyph === 'shield'
|
||||
? `<path ${common} d="M64 20 L100 34 V62 C100 88 82 106 64 116 C46 106 28 88 28 62 V34 Z"/>`
|
||||
: glyph === 'heart'
|
||||
? `<path ${common} d="M64 108 C36 82 22 67 22 47 C22 32 32 22 46 22 C54 22 60 26 64 34 C68 26 74 22 82 22 C96 22 106 32 106 47 C106 67 92 82 64 108 Z"/>`
|
||||
: glyph === 'cross'
|
||||
? `<path ${common} d="M54 22 H74 V52 H104 V72 H74 V106 H54 V72 H24 V52 H54 Z"/>`
|
||||
: glyph === 'fist'
|
||||
? `<path ${common} d="M32 56 H44 V28 H58 V56 H66 V24 H80 V58 H88 V34 H101 V74 C101 94 86 108 64 108 C42 108 28 94 28 74 Z"/>`
|
||||
: glyph === 'spark'
|
||||
? `<path ${common} d="M70 12 L46 58 H66 L54 116 L86 50 H66 Z"/>`
|
||||
: glyph === 'gem'
|
||||
? `<path ${common} d="M64 18 L104 48 L64 112 L24 48 Z M24 48 H104 M46 48 L64 112 L82 48"/>`
|
||||
: glyph === 'burst'
|
||||
? `<path ${common} d="M64 14 L74 48 L108 38 L84 64 L108 90 L74 80 L64 114 L54 80 L20 90 L44 64 L20 38 L54 48 Z"/>`
|
||||
: `<path ${common} d="M32 28 H96 V100 H32 Z M46 44 H82 M46 64 H82 M46 84 H72"/>`
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><defs><radialGradient id="g" cx="35%" cy="25%" r="80%"><stop stop-color="#ffffff" stop-opacity=".25"/><stop offset=".45" stop-color="${bg}"/><stop offset="1" stop-color="#07090d"/></radialGradient></defs><rect width="128" height="128" rx="18" fill="url(#g)"/><rect x="8" y="8" width="112" height="112" rx="16" fill="none" stroke="#f5c843" stroke-opacity=".35" stroke-width="4"/>${mark}</svg>`
|
||||
}
|
||||
|
||||
function isTalentLocked(
|
||||
node: TalentNode,
|
||||
allocation: ActionCharacter['talents'],
|
||||
siblingNodes: TalentNode[],
|
||||
points: { available: number },
|
||||
selectedSpec: string | null,
|
||||
) {
|
||||
if (!allocation.spec) return true
|
||||
if (node.tree === 'spec' && node.specId !== selectedSpec) return true
|
||||
for (const required of node.requires ?? []) {
|
||||
if ((allocation.ranks[required] ?? 0) <= 0) return true
|
||||
}
|
||||
const gate = node.pointsGate ?? 0
|
||||
if (gate > 0) {
|
||||
const spentAbove = siblingNodes
|
||||
.filter((candidate) => candidate.tree === node.tree && candidate.row < node.row)
|
||||
.reduce((sum, candidate) => sum + (allocation.ranks[candidate.id] ?? 0), 0)
|
||||
if (spentAbove < gate) return true
|
||||
}
|
||||
return points.available <= 0 && (allocation.ranks[node.id] ?? 0) < node.maxRank
|
||||
}
|
||||
|
||||
function StatLine({ label, value }: { label: string, value: number | string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GearDetail({
|
||||
equippedItem,
|
||||
item,
|
||||
onEquip,
|
||||
slot,
|
||||
}: {
|
||||
equippedItem?: ItemDef | null
|
||||
item: ItemDef | null
|
||||
onEquip?: () => void
|
||||
slot?: EquipSlot
|
||||
}) {
|
||||
if (!item) {
|
||||
return (
|
||||
<section className="action-gear-detail empty">
|
||||
<p>{slot ? `No item equipped in ${slot}.` : 'Select an item.'}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="action-gear-detail">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Selected</p>
|
||||
<h2>{item.name}</h2>
|
||||
</div>
|
||||
<span>{item.quality ?? 'common'}</span>
|
||||
</header>
|
||||
<dl className="action-stat-compare">
|
||||
<div>
|
||||
<dt>Stats</dt>
|
||||
<dd>{getItemStatsText(item)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Equipped</dt>
|
||||
<dd>{equippedItem ? getItemStatsText(equippedItem) : 'Empty'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Slot</dt>
|
||||
<dd>{item.slot ?? 'Bag'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Class</dt>
|
||||
<dd>{item.requiredClass?.join(', ') ?? 'Any'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{onEquip && (
|
||||
<button disabled={equippedItem?.id === item.id} onClick={onEquip} type="button">
|
||||
{equippedItem?.id === item.id ? 'Equipped' : 'Equip'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryItemTooltip({
|
||||
canEquip,
|
||||
count,
|
||||
equippedItem,
|
||||
item,
|
||||
}: {
|
||||
canEquip: boolean
|
||||
count: number
|
||||
equippedItem?: ItemDef | null
|
||||
item: ItemDef
|
||||
}) {
|
||||
const rows = getItemTooltipRows(item, equippedItem)
|
||||
return (
|
||||
<aside className={`claudecraft-action-tooltip claudecraft-item-tooltip quality-${item.quality ?? 'common'}`} role="tooltip">
|
||||
<div className="tooltip-heading">
|
||||
<span className="item-tooltip-icon">{getItemIconGlyph(item)}</span>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{formatItemQuality(item)}{count > 1 ? ` x${count}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Slot</dt><dd>{item.slot ? formatEquipSlot(item.slot) : 'Bag'}</dd></div>
|
||||
<div><dt>Type</dt><dd>{getItemKindLabel(item)}</dd></div>
|
||||
{item.requiredClass?.length ? <div><dt>Class</dt><dd>{item.requiredClass.join(', ')}</dd></div> : null}
|
||||
{item.sellValue > 0 ? <div><dt>Sell</dt><dd>{item.sellValue} copper</dd></div> : null}
|
||||
</dl>
|
||||
<dl className="item-tooltip-stats">
|
||||
{rows.length === 0 ? (
|
||||
<div><dt>Stats</dt><dd>No combat stats</dd></div>
|
||||
) : rows.map((row) => (
|
||||
<div className={row.delta === undefined ? '' : row.delta > 0 ? 'better' : row.delta < 0 ? 'worse' : 'same'} key={row.label}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>
|
||||
<span>{row.value}</span>
|
||||
{row.delta !== undefined && <em>{formatStatDelta(row.delta)}</em>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{item.slot && (
|
||||
<p>
|
||||
{equippedItem
|
||||
? `Equipped: ${equippedItem.name}`
|
||||
: `Equipped: empty ${formatEquipSlot(item.slot)}`}
|
||||
</p>
|
||||
)}
|
||||
{item.slot && <em>{canEquip ? 'Click to equip.' : 'Cannot equip.'}</em>}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function getInventoryEntries(character: ActionCharacter): ItemEntry[] {
|
||||
return character.inventory
|
||||
.map((slot) => ({ item: getClaudeCraftItem(slot.itemId), count: slot.count }))
|
||||
.filter((entry): entry is ItemEntry => Boolean(entry.item))
|
||||
}
|
||||
|
||||
function getInventoryCount(entries: ItemEntry[]) {
|
||||
return entries.reduce((total, entry) => total + entry.count, 0)
|
||||
}
|
||||
|
||||
type ItemTooltipRow = {
|
||||
delta?: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const STAT_ORDER: Array<keyof Stats> = ['armor', 'str', 'agi', 'sta', 'int', 'spi']
|
||||
const STAT_LABELS: Record<keyof Stats, string> = {
|
||||
agi: 'Agility',
|
||||
armor: 'Armor',
|
||||
int: 'Intellect',
|
||||
spi: 'Spirit',
|
||||
sta: 'Stamina',
|
||||
str: 'Strength',
|
||||
}
|
||||
|
||||
function getItemTooltipRows(item: ItemDef, equippedItem?: ItemDef | null): ItemTooltipRow[] {
|
||||
const rows: ItemTooltipRow[] = []
|
||||
if (item.weapon) {
|
||||
const equippedWeapon = equippedItem?.weapon
|
||||
const itemAverage = (item.weapon.min + item.weapon.max) / 2
|
||||
const equippedAverage = equippedWeapon ? (equippedWeapon.min + equippedWeapon.max) / 2 : 0
|
||||
rows.push({
|
||||
delta: equippedItem ? itemAverage - equippedAverage : undefined,
|
||||
label: 'Damage',
|
||||
value: `${item.weapon.min}-${item.weapon.max}`,
|
||||
})
|
||||
rows.push({
|
||||
delta: equippedWeapon ? equippedWeapon.speed - item.weapon.speed : undefined,
|
||||
label: 'Speed',
|
||||
value: item.weapon.speed.toFixed(1),
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of STAT_ORDER) {
|
||||
const value = item.stats?.[key] ?? 0
|
||||
if (!value) continue
|
||||
const equippedValue = equippedItem?.stats?.[key] ?? 0
|
||||
rows.push({
|
||||
delta: equippedItem ? value - equippedValue : undefined,
|
||||
label: STAT_LABELS[key],
|
||||
value: key === 'armor' ? String(value) : `+${value}`,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function formatStatDelta(delta: number) {
|
||||
if (delta === 0) return '+0'
|
||||
return delta > 0 ? `+${formatDeltaNumber(delta)}` : formatDeltaNumber(delta)
|
||||
}
|
||||
|
||||
function formatDeltaNumber(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
function getItemIconGlyph(item: ItemDef) {
|
||||
if (item.kind === 'weapon') return '/'
|
||||
if (item.kind === 'armor') return 'H'
|
||||
if (item.kind === 'potion' || item.kind === 'elixir') return '+'
|
||||
return '*'
|
||||
}
|
||||
|
||||
function formatItemQuality(item: ItemDef) {
|
||||
const quality = item.quality ?? 'common'
|
||||
return quality[0].toUpperCase() + quality.slice(1)
|
||||
}
|
||||
|
||||
function formatEquipSlot(slot: EquipSlot) {
|
||||
return ACTION_EQUIP_SLOTS.find((entry) => entry.slot === slot)?.label ?? slot
|
||||
}
|
||||
|
||||
function getItemKindLabel(item: ItemDef) {
|
||||
if (item.kind === 'weapon' && item.weapon?.dagger) return 'Dagger'
|
||||
if (item.kind === 'weapon') return 'Weapon'
|
||||
if (item.kind === 'armor') return item.armorType ? `${item.armorType} armor` : 'Armor'
|
||||
return item.kind
|
||||
}
|
||||
|
||||
function getEquipmentStats(character: ActionCharacter): Stats {
|
||||
const stats: Stats = { str: 0, agi: 0, sta: 0, int: 0, spi: 0, armor: 0 }
|
||||
for (const slot of ACTION_EQUIP_SLOTS) {
|
||||
const item = getEquippedActionItem(character, slot.slot)
|
||||
if (!item?.stats) continue
|
||||
stats.str += item.stats.str ?? 0
|
||||
stats.agi += item.stats.agi ?? 0
|
||||
stats.sta += item.stats.sta ?? 0
|
||||
stats.int += item.stats.int ?? 0
|
||||
stats.spi += item.stats.spi ?? 0
|
||||
stats.armor += item.stats.armor ?? 0
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
function canEquipInventoryItem(character: ActionCharacter, item: ItemDef) {
|
||||
return Boolean(item.slot && (item.kind === 'weapon' || item.kind === 'armor') && canEquipItem(character.classId, item))
|
||||
}
|
||||
|
||||
function getFilterLabel(filter: InventoryFilter) {
|
||||
if (filter === 'weapon') return 'Weapons'
|
||||
if (filter === 'armor') return 'Armor'
|
||||
if (filter === 'other') return 'Other'
|
||||
return 'All'
|
||||
}
|
||||
|
||||
function getItemTag(item: ItemDef) {
|
||||
const quality = item.quality ?? 'common'
|
||||
const slot = item.slot ?? item.kind
|
||||
const stats = getItemStatsText(item)
|
||||
return stats ? `${quality} - ${slot} - ${stats}` : `${quality} - ${slot}`
|
||||
}
|
||||
|
||||
function getItemPower(item: ItemDef) {
|
||||
const stats = item.stats ?? {}
|
||||
const statScore = (stats.str ?? 0) + (stats.agi ?? 0) + (stats.sta ?? 0) + (stats.int ?? 0) + (stats.spi ?? 0)
|
||||
const armorScore = (stats.armor ?? 0) / 12
|
||||
const weaponScore = item.weapon ? item.weapon.min + item.weapon.max : 0
|
||||
return statScore + armorScore + weaponScore
|
||||
}
|
||||
+722
-237
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
|
||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||
import {
|
||||
ACTION_MODEL_ASSETS,
|
||||
ACTION_MODEL_CATEGORIES,
|
||||
ACTION_WEAPON_ASSETS,
|
||||
type ActionModelAsset,
|
||||
type ActionModelAssetCategory,
|
||||
type ActionWeaponAsset,
|
||||
} from '../actionModelAssets'
|
||||
import {
|
||||
addBirdMesh,
|
||||
addBoarMesh,
|
||||
addCyberDragonMesh,
|
||||
} from '../action3dCombatRender'
|
||||
|
||||
type ViewerRuntime = {
|
||||
camera: THREE.PerspectiveCamera
|
||||
clips: THREE.AnimationClip[]
|
||||
controls: {
|
||||
dragging: boolean
|
||||
lastX: number
|
||||
yaw: number
|
||||
}
|
||||
currentAction: THREE.AnimationAction | null
|
||||
grid: THREE.GridHelper
|
||||
mixer: THREE.AnimationMixer | null
|
||||
renderer: THREE.WebGLRenderer
|
||||
root: THREE.Group
|
||||
scene: THREE.Scene
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder)
|
||||
const colladaLoader = new ColladaLoader()
|
||||
const mtlLoader = new MTLLoader()
|
||||
|
||||
export function ActionModelViewer() {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null)
|
||||
const runtimeRef = useRef<ViewerRuntime | null>(null)
|
||||
const autoRotateRef = useRef(true)
|
||||
const speedRef = useRef(1)
|
||||
const [category, setCategory] = useState<ActionModelAssetCategory>('claudecraft-players')
|
||||
const filteredAssets = useMemo(
|
||||
() => ACTION_MODEL_ASSETS.filter((asset) => asset.category === category),
|
||||
[category],
|
||||
)
|
||||
const [assetId, setAssetId] = useState(() => filteredAssets[0]?.id ?? ACTION_MODEL_ASSETS[0].id)
|
||||
const selectedAssetId = filteredAssets.some((asset) => asset.id === assetId)
|
||||
? assetId
|
||||
: filteredAssets[0]?.id ?? ACTION_MODEL_ASSETS[0].id
|
||||
const [animationName, setAnimationName] = useState('')
|
||||
const [animationNames, setAnimationNames] = useState<string[]>([])
|
||||
const [autoRotate, setAutoRotate] = useState(true)
|
||||
const [speed, setSpeed] = useState(1)
|
||||
const [status, setStatus] = useState('Ready')
|
||||
const [weaponId, setWeaponId] = useState('none')
|
||||
const selectedAsset = ACTION_MODEL_ASSETS.find((asset) => asset.id === selectedAssetId) ?? ACTION_MODEL_ASSETS[0]
|
||||
const selectedWeapon = ACTION_WEAPON_ASSETS.find((weapon) => weapon.id === weaponId) ?? ACTION_WEAPON_ASSETS[0]
|
||||
|
||||
useEffect(() => {
|
||||
autoRotateRef.current = autoRotate
|
||||
}, [autoRotate])
|
||||
|
||||
useEffect(() => {
|
||||
speedRef.current = speed
|
||||
}, [speed])
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current
|
||||
if (!mount) return
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0x10151a)
|
||||
scene.fog = new THREE.Fog(0x10151a, 12, 28)
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 80)
|
||||
camera.position.set(0, 2.1, 4.2)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8))
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
mount.appendChild(renderer.domElement)
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0xf6f0de, 0x202937, 1.6)
|
||||
scene.add(ambient)
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffedbd, 3.1)
|
||||
keyLight.position.set(-4, 7, 5)
|
||||
keyLight.castShadow = true
|
||||
scene.add(keyLight)
|
||||
|
||||
const rimLight = new THREE.DirectionalLight(0x8ec7ff, 1.1)
|
||||
rimLight.position.set(4, 3, -5)
|
||||
scene.add(rimLight)
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(3.4, 48),
|
||||
new THREE.MeshStandardMaterial({ color: 0x243126, roughness: 0.9 }),
|
||||
)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
|
||||
const grid = new THREE.GridHelper(7, 14, 0x566044, 0x3b4436)
|
||||
grid.position.y = 0.012
|
||||
scene.add(grid)
|
||||
|
||||
const root = new THREE.Group()
|
||||
scene.add(root)
|
||||
|
||||
const runtime: ViewerRuntime = {
|
||||
camera,
|
||||
clips: [],
|
||||
controls: { dragging: false, lastX: 0, yaw: 0 },
|
||||
currentAction: null,
|
||||
grid,
|
||||
mixer: null,
|
||||
renderer,
|
||||
root,
|
||||
scene,
|
||||
}
|
||||
runtimeRef.current = runtime
|
||||
|
||||
const resize = () => {
|
||||
const rect = mount.getBoundingClientRect()
|
||||
const width = Math.max(1, rect.width)
|
||||
const height = Math.max(1, rect.height)
|
||||
renderer.setSize(width, height, false)
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(mount)
|
||||
resize()
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
renderer.domElement.setPointerCapture(event.pointerId)
|
||||
runtime.controls.dragging = true
|
||||
runtime.controls.lastX = event.clientX
|
||||
}
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!runtime.controls.dragging) return
|
||||
runtime.controls.yaw += (event.clientX - runtime.controls.lastX) * 0.012
|
||||
runtime.controls.lastX = event.clientX
|
||||
}
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
renderer.domElement.releasePointerCapture(event.pointerId)
|
||||
runtime.controls.dragging = false
|
||||
}
|
||||
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
|
||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
||||
renderer.domElement.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
let frameId = 0
|
||||
const animate = () => {
|
||||
const delta = Math.min(clock.getDelta(), 0.05)
|
||||
if (autoRotateRef.current && !runtime.controls.dragging) runtime.controls.yaw += delta * 0.55
|
||||
root.rotation.y = runtime.controls.yaw
|
||||
if (runtime.mixer) runtime.mixer.update(delta * speedRef.current)
|
||||
renderer.render(scene, camera)
|
||||
frameId = requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
|
||||
renderer.domElement.removeEventListener('pointermove', handlePointerMove)
|
||||
renderer.domElement.removeEventListener('pointerup', handlePointerUp)
|
||||
disposeObject(root)
|
||||
scene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return
|
||||
object.geometry.dispose()
|
||||
disposeMaterial(object.material)
|
||||
})
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
runtimeRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const runtime = runtimeRef.current
|
||||
if (!runtime) return
|
||||
const activeRuntime = runtime
|
||||
|
||||
setStatus('Loading model')
|
||||
setAnimationNames([])
|
||||
setAnimationName('')
|
||||
activeRuntime.currentAction = null
|
||||
activeRuntime.clips = []
|
||||
activeRuntime.mixer = null
|
||||
clearGroup(activeRuntime.root)
|
||||
|
||||
async function load() {
|
||||
const model = await createViewerModel(selectedAsset, selectedWeapon)
|
||||
if (cancelled) {
|
||||
disposeObject(model.object)
|
||||
return
|
||||
}
|
||||
|
||||
activeRuntime.root.add(model.object)
|
||||
fitObjectToViewer(model.object, activeRuntime.camera)
|
||||
activeRuntime.controls.yaw = 0
|
||||
|
||||
if (model.animations.length > 0) {
|
||||
activeRuntime.clips = model.animations
|
||||
activeRuntime.mixer = new THREE.AnimationMixer(model.object)
|
||||
const defaultName = selectedAsset.type === 'file'
|
||||
? selectedAsset.defaultAnimation
|
||||
: undefined
|
||||
const nextAnimation = pickAnimation(model.animations, defaultName)
|
||||
const action = activeRuntime.mixer.clipAction(nextAnimation)
|
||||
action.play()
|
||||
activeRuntime.currentAction = action
|
||||
setAnimationNames(model.animations.map((clip) => clip.name))
|
||||
setAnimationName(nextAnimation.name)
|
||||
}
|
||||
|
||||
setStatus(model.animations.length > 0 ? `${model.animations.length} animations` : 'Static model')
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) setStatus(error instanceof Error ? error.message : 'Model failed to load')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedAsset, selectedWeapon])
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current
|
||||
if (!runtime?.mixer || !animationName) return
|
||||
const clip = runtime.clips.find((candidate) => candidate.name === animationName)
|
||||
if (!clip) return
|
||||
|
||||
const nextAction = runtime.mixer.clipAction(clip)
|
||||
if (runtime.currentAction === nextAction) return
|
||||
nextAction.reset().play()
|
||||
if (runtime.currentAction) runtime.currentAction.crossFadeTo(nextAction, 0.14, false)
|
||||
runtime.currentAction = nextAction
|
||||
}, [animationName])
|
||||
|
||||
return (
|
||||
<section className="model-viewer-panel">
|
||||
<div className="model-viewer-toolbar">
|
||||
<label>
|
||||
<span>Set</span>
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as ActionModelAssetCategory)}>
|
||||
{ACTION_MODEL_CATEGORIES.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Model</span>
|
||||
<select value={selectedAssetId} onChange={(event) => setAssetId(event.target.value)}>
|
||||
{filteredAssets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>{asset.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Animation</span>
|
||||
<select
|
||||
disabled={animationNames.length === 0}
|
||||
value={animationName}
|
||||
onChange={(event) => setAnimationName(event.target.value)}
|
||||
>
|
||||
{animationNames.length === 0 && <option value="">None</option>}
|
||||
{animationNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Weapon</span>
|
||||
<select value={weaponId} onChange={(event) => setWeaponId(event.target.value)}>
|
||||
{ACTION_WEAPON_ASSETS.map((weapon) => (
|
||||
<option key={weapon.id} value={weapon.id}>{weapon.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Speed</span>
|
||||
<input
|
||||
max="1.8"
|
||||
min="0"
|
||||
step="0.1"
|
||||
type="range"
|
||||
value={speed}
|
||||
onChange={(event) => setSpeed(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
aria-pressed={autoRotate}
|
||||
className={autoRotate ? 'active' : ''}
|
||||
onClick={() => setAutoRotate((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
Spin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="model-viewer-stage" ref={mountRef} />
|
||||
|
||||
<div className="model-viewer-meta">
|
||||
<strong>{selectedAsset.label}</strong>
|
||||
<span>{selectedAsset.source}</span>
|
||||
<span>{selectedAsset.type === 'file' ? selectedAsset.url : selectedAsset.enemyKind}</span>
|
||||
<span>{selectedWeapon.id === 'none' ? status : `${status} / ${selectedWeapon.label}`}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
async function createViewerModel(
|
||||
asset: ActionModelAsset,
|
||||
weapon: ActionWeaponAsset,
|
||||
): Promise<{ animations: THREE.AnimationClip[]; object: THREE.Object3D }> {
|
||||
if (asset.type === 'procedural') {
|
||||
const group = new THREE.Group()
|
||||
if (asset.enemyKind === 'bulldrome') addBoarMesh(group, { kind: 'bulldrome' })
|
||||
else if (asset.enemyKind === 'bullfango') addBoarMesh(group, { kind: 'bullfango' })
|
||||
else if (asset.enemyKind === 'yian-kut-ku') addBirdMesh(group, 'yian-kut-ku')
|
||||
else if (asset.enemyKind === 'cyber-dragon') addCyberDragonMesh(group)
|
||||
else addBirdMesh(group, 'bird')
|
||||
if (weapon.id !== 'none') await addPreviewWeapon(group, weapon, false)
|
||||
return { animations: [], object: group }
|
||||
}
|
||||
|
||||
const loadedModel = await loadFileModel(asset)
|
||||
const object = new THREE.Group()
|
||||
object.add(loadedModel.scene)
|
||||
if (asset.materialVariant) applyMaterialVariant(loadedModel.scene, asset.materialVariant)
|
||||
object.animations = loadedModel.animations
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.frustumCulled = false
|
||||
child.receiveShadow = true
|
||||
})
|
||||
if (weapon.id !== 'none') await addPreviewWeapon(object, weapon, true)
|
||||
return { animations: loadedModel.animations, object }
|
||||
}
|
||||
|
||||
async function loadFileModel(asset: Extract<ActionModelAsset, { type: 'file' }>) {
|
||||
if (asset.format === 'dae') {
|
||||
const collada = await colladaLoader.loadAsync(asset.url)
|
||||
if (!collada) throw new Error('Collada model failed to load')
|
||||
return { animations: collada.scene.animations, scene: collada.scene }
|
||||
}
|
||||
|
||||
if (asset.format === 'obj') {
|
||||
const activeObjLoader = new OBJLoader()
|
||||
if (asset.mtlUrl) {
|
||||
const materials = await mtlLoader.loadAsync(asset.mtlUrl)
|
||||
materials.preload()
|
||||
activeObjLoader.setMaterials(materials)
|
||||
}
|
||||
const scene = await activeObjLoader.loadAsync(asset.url)
|
||||
return { animations: [], scene }
|
||||
}
|
||||
|
||||
const gltf = await loader.loadAsync(asset.url)
|
||||
return { animations: gltf.animations, scene: gltf.scene }
|
||||
}
|
||||
|
||||
function applyMaterialVariant(model: THREE.Object3D, variant: NonNullable<Extract<ActionModelAsset, { type: 'file' }>['materialVariant']>) {
|
||||
const variantColor = {
|
||||
black: 0x221a1c,
|
||||
emerald: 0x2f8f5f,
|
||||
frost: 0x9ed8ff,
|
||||
red: 0xa73524,
|
||||
}[variant]
|
||||
const emissive = {
|
||||
black: 0x090202,
|
||||
emerald: 0x06391e,
|
||||
frost: 0x17445f,
|
||||
red: 0x3f0b05,
|
||||
}[variant]
|
||||
model.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
const sourceMaterial = Array.isArray(child.material) ? child.material[0] : child.material
|
||||
const sourceMap = sourceMaterial instanceof THREE.MeshStandardMaterial ? sourceMaterial.map : null
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color: variantColor,
|
||||
emissive,
|
||||
emissiveIntensity: variant === 'frost' ? 0.12 : 0.07,
|
||||
map: sourceMap,
|
||||
metalness: 0,
|
||||
roughness: variant === 'frost' ? 0.52 : 0.68,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function addPreviewWeapon(model: THREE.Object3D, weapon: ActionWeaponAsset, allowHandAttach: boolean) {
|
||||
const gltf = await loader.loadAsync(weapon.url)
|
||||
const weaponModel = gltf.scene
|
||||
weaponModel.name = 'previewWeapon'
|
||||
weaponModel.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.castShadow = true
|
||||
child.frustumCulled = false
|
||||
child.receiveShadow = true
|
||||
})
|
||||
|
||||
const handSlot = allowHandAttach ? findPreferredHandSlot(model) : null
|
||||
if (handSlot) {
|
||||
handSlot.add(weaponModel)
|
||||
return
|
||||
}
|
||||
|
||||
const modelBox = new THREE.Box3().setFromObject(model)
|
||||
const modelSize = new THREE.Vector3()
|
||||
modelBox.getSize(modelSize)
|
||||
const modelCenter = new THREE.Vector3()
|
||||
modelBox.getCenter(modelCenter)
|
||||
|
||||
normalizeLooseWeapon(weaponModel, Math.max(0.8, Math.min(2.1, modelSize.y * 0.45)))
|
||||
weaponModel.rotation.z = -0.55
|
||||
|
||||
const weaponBox = new THREE.Box3().setFromObject(weaponModel)
|
||||
weaponModel.position.x -= weaponBox.min.x
|
||||
weaponModel.position.y -= weaponBox.min.y
|
||||
weaponModel.position.z -= (weaponBox.min.z + weaponBox.max.z) * 0.5
|
||||
|
||||
const holder = new THREE.Group()
|
||||
holder.add(weaponModel)
|
||||
holder.position.set(
|
||||
modelBox.max.x + Math.max(1.4, modelSize.x * 0.55),
|
||||
modelBox.min.y,
|
||||
modelBox.max.z + Math.max(0.7, modelSize.z * 0.1),
|
||||
)
|
||||
model.add(holder)
|
||||
}
|
||||
|
||||
function findPreferredHandSlot(model: THREE.Object3D) {
|
||||
const candidates: THREE.Object3D[] = []
|
||||
model.traverse((object) => {
|
||||
const name = normalizeNodeName(object.name)
|
||||
if (
|
||||
name === 'handslotr'
|
||||
|| name === 'handr'
|
||||
|| name === 'righthand'
|
||||
|| name.endsWith('handslotr')
|
||||
|| name.endsWith('handr')
|
||||
) {
|
||||
candidates.unshift(object)
|
||||
return
|
||||
}
|
||||
if (
|
||||
name === 'handslotl'
|
||||
|| name === 'handl'
|
||||
|| name === 'lefthand'
|
||||
|| name.endsWith('handslotl')
|
||||
|| name.endsWith('handl')
|
||||
) {
|
||||
candidates.push(object)
|
||||
}
|
||||
})
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
function normalizeNodeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
function normalizeLooseWeapon(weapon: THREE.Object3D, targetLength: number) {
|
||||
const box = new THREE.Box3().setFromObject(weapon)
|
||||
const size = new THREE.Vector3()
|
||||
const center = new THREE.Vector3()
|
||||
box.getSize(size)
|
||||
box.getCenter(center)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.001)
|
||||
weapon.scale.multiplyScalar(targetLength / maxDimension)
|
||||
weapon.position.sub(center.multiplyScalar(targetLength / maxDimension))
|
||||
}
|
||||
|
||||
function pickAnimation(clips: THREE.AnimationClip[], preferred?: string) {
|
||||
return clips.find((clip) => clip.name === preferred)
|
||||
?? clips.find((clip) => clip.name.toLowerCase().includes('idle'))
|
||||
?? clips[0]
|
||||
}
|
||||
|
||||
function fitObjectToViewer(object: THREE.Object3D, camera: THREE.PerspectiveCamera) {
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const size = new THREE.Vector3()
|
||||
const center = new THREE.Vector3()
|
||||
box.getSize(size)
|
||||
box.getCenter(center)
|
||||
const footprint = Math.max(size.x, size.z, 0.001)
|
||||
const scale = Math.min(2.05 / Math.max(size.y, 0.001), 3.85 / footprint)
|
||||
object.scale.multiplyScalar(scale)
|
||||
object.position.sub(center.multiplyScalar(scale))
|
||||
|
||||
const scaledBox = new THREE.Box3().setFromObject(object)
|
||||
const scaledCenter = new THREE.Vector3()
|
||||
scaledBox.getCenter(scaledCenter)
|
||||
const scaledSize = new THREE.Vector3()
|
||||
scaledBox.getSize(scaledSize)
|
||||
object.position.y -= scaledBox.min.y
|
||||
camera.position.set(
|
||||
0,
|
||||
Math.max(1.35, scaledSize.y * 0.58),
|
||||
Math.max(4.8, scaledSize.z * 1.25 + 3.2, scaledSize.x * 0.7 + 3.2),
|
||||
)
|
||||
camera.lookAt(scaledCenter.x, Math.max(0.8, scaledSize.y * 0.5), scaledCenter.z)
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group) {
|
||||
for (const child of [...group.children]) {
|
||||
group.remove(child)
|
||||
disposeObject(child)
|
||||
}
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.geometry.dispose()
|
||||
disposeMaterial(child.material)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
|
||||
const materials = Array.isArray(material) ? material : [material]
|
||||
for (const item of materials) item.dispose()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,11 @@ import {
|
||||
|
||||
type AuthScreenProps = {
|
||||
onAuthenticated: (session: AuthSession) => void
|
||||
onPlayOffline: () => void
|
||||
serverMessage?: string
|
||||
}
|
||||
|
||||
export function AuthScreen({ onAuthenticated, serverMessage = '' }: AuthScreenProps) {
|
||||
export function AuthScreen({ onAuthenticated, onPlayOffline, serverMessage = '' }: AuthScreenProps) {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -117,6 +118,15 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: AuthScreenPr
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
className="secondary-button auth-offline-button"
|
||||
disabled={busy}
|
||||
onClick={onPlayOffline}
|
||||
type="button"
|
||||
>
|
||||
Play Offline
|
||||
</button>
|
||||
|
||||
<p className={`auth-message ${message ? 'error' : ''}`}>
|
||||
{message || serverMessage || (
|
||||
mode === 'register'
|
||||
|
||||
@@ -1,123 +1,142 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Phaser from 'phaser'
|
||||
import { BulldromeScene } from '../actionBoss/BulldromeScene'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ThreeActionSceneHandle } from './ThreeActionScene'
|
||||
import {
|
||||
getActionDifficultyTier,
|
||||
type ActionCharacter,
|
||||
type ActionDifficulty,
|
||||
type ActionRunMode,
|
||||
} from '../actionMode'
|
||||
import { createActionCombatProfile } from '../actionCombatProfile'
|
||||
import {
|
||||
getActionSpellbook,
|
||||
getActionSpellDefinition,
|
||||
getActionSpellIconUrl,
|
||||
getActionSpellManaCost,
|
||||
getActionSpellTarget,
|
||||
getActionSpellTooltip,
|
||||
} from '../actionCombatCore'
|
||||
import {
|
||||
createBulldromeState,
|
||||
getEncounterHp,
|
||||
getEncounterTitle,
|
||||
getEnemyFrames,
|
||||
getRaidFrames,
|
||||
SPELLS,
|
||||
type BulldromeState,
|
||||
type ActionDungeonId,
|
||||
type EnemyFrame,
|
||||
type RaidFrame,
|
||||
type SpellDefinition,
|
||||
type SpellSlot,
|
||||
} from '../actionBoss/bulldromeSimulation'
|
||||
} from '../actionBoss/actionCombatSimulation'
|
||||
import { CombatActionBar, CombatPartyFrames, CombatTargetFrame, type CombatActionSlot } from './CombatHud'
|
||||
import { playCombatSound } from './CombatAudio'
|
||||
import { PauseSettingsMenu } from './CombatPauseMenu'
|
||||
import { ActionCharacterMenu, ActionInventoryMenu, ActionMenuOverlay, ActionTalentMenu } from './ActionEquipmentMenus'
|
||||
|
||||
type BulldromeBossSliceProps = {
|
||||
character?: ActionCharacter
|
||||
dungeonId?: ActionDungeonId
|
||||
difficulty?: ActionDifficulty
|
||||
runMode?: ActionRunMode
|
||||
onEquipItem?: (itemId: string) => void
|
||||
onExit: () => void
|
||||
onCharacterChange?: (character: ActionCharacter) => void
|
||||
onRunComplete?: () => void
|
||||
}
|
||||
|
||||
function getRunTitle(dungeonId: ActionDungeonId, difficulty: ActionDifficulty, runMode: ActionRunMode) {
|
||||
const suffix = runMode === 'marathon' ? 'Marathon' : 'Hunt'
|
||||
const tier = getActionDifficultyTier(difficulty).label
|
||||
if (dungeonId === 'yian-kut-ku') return `${tier} Yian Kut-Ku ${suffix}`
|
||||
return `${tier} Bulldrome ${suffix}`
|
||||
const ThreeActionScene = lazy(() => import('./ThreeActionScene'))
|
||||
|
||||
const DUNGEON_SFX = {
|
||||
cast: '/audio/sfx/cast_holy.mp3',
|
||||
heal: '/audio/sfx/heal_impact.mp3',
|
||||
buff: '/audio/sfx/buff_apply.mp3',
|
||||
hit: '/audio/sfx/impact_flesh.mp3',
|
||||
hurt: '/audio/sfx/player_hurt.mp3',
|
||||
swing: '/audio/sfx/melee_swing_blade.mp3',
|
||||
}
|
||||
|
||||
export function BulldromeBossSlice({
|
||||
character,
|
||||
dungeonId = 'bulldrome',
|
||||
difficulty = 'ilvl-1',
|
||||
onEquipItem,
|
||||
onCharacterChange,
|
||||
runMode = 'hunt',
|
||||
onExit,
|
||||
onRunComplete,
|
||||
}: BulldromeBossSliceProps) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null)
|
||||
const gameRef = useRef<Phaser.Game | null>(null)
|
||||
const sceneRef = useRef<BulldromeScene | null>(null)
|
||||
const combatProfile = useMemo(() => createActionCombatProfile(character), [character])
|
||||
const threeSceneRef = useRef<ThreeActionSceneHandle | null>(null)
|
||||
const completionSentRef = useRef(false)
|
||||
const rewardedBossKillsRef = useRef(0)
|
||||
const [state, setState] = useState<BulldromeState>(() => createBulldromeState(difficulty, dungeonId, runMode))
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [pausePanel, setPausePanel] = useState<'settings' | 'character' | 'inventory' | 'talents'>('settings')
|
||||
const [renderUnavailable, setRenderUnavailable] = useState(false)
|
||||
const [state, setState] = useState<BulldromeState>(() => createBulldromeState(difficulty, dungeonId, runMode, combatProfile.classId, combatProfile.talentModifiers))
|
||||
const audioUnlockedRef = useRef(false)
|
||||
const seenHealEventIdsRef = useRef(new Set<string>())
|
||||
const previousLastHitRef = useRef<BulldromeState['lastHit']>(null)
|
||||
const handleRenderUnavailable = useCallback(() => {
|
||||
setRenderUnavailable(true)
|
||||
}, [])
|
||||
const raidFrames = useMemo(() => getRaidFrames(state), [state])
|
||||
const enemyFrames = useMemo(() => getEnemyFrames(state), [state])
|
||||
const encounterHp = useMemo(() => getEncounterHp(state), [state])
|
||||
const encounterTitle = useMemo(() => getEncounterTitle(state), [state])
|
||||
|
||||
const resultLabel = useMemo(() => {
|
||||
if (state.result === 'win') return 'Hunt Complete'
|
||||
if (state.result === 'loss') return 'Carted'
|
||||
if (state.encounterStep === 'trash') return 'Bullfangos'
|
||||
return state.boss.phase === 'slamWindup'
|
||||
? 'Slam'
|
||||
: state.boss.phase === 'mauling'
|
||||
? 'Tank'
|
||||
: state.boss.phase === 'windup'
|
||||
? 'Dodge'
|
||||
: state.boss.phase === 'recovering'
|
||||
? 'Punish'
|
||||
: 'Fight'
|
||||
}, [state.boss.phase, state.encounterStep, state.result])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRef.current || gameRef.current) return
|
||||
|
||||
const scene = new BulldromeScene({ difficulty, dungeonId, runMode, onStateChange: setState })
|
||||
sceneRef.current = scene
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.CANVAS,
|
||||
parent: mountRef.current,
|
||||
width: 960,
|
||||
height: 540,
|
||||
backgroundColor: '#11151c',
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
scene: [scene],
|
||||
})
|
||||
|
||||
gameRef.current = game
|
||||
|
||||
return () => {
|
||||
game.destroy(true)
|
||||
gameRef.current = null
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [difficulty, dungeonId, runMode])
|
||||
const selectedTarget = useMemo(() => getDungeonTargetFrame(state), [state])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setPausePanel('settings')
|
||||
setPaused((current) => !current)
|
||||
return
|
||||
}
|
||||
|
||||
if (paused) return
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
const selectedIndex = Math.max(0, raidFrames.findIndex((frame) => frame.selected))
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
const nextFrame = raidFrames[(selectedIndex + delta + raidFrames.length) % raidFrames.length]
|
||||
if (nextFrame) sceneRef.current?.selectTarget(nextFrame.id)
|
||||
if (nextFrame) {
|
||||
playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
|
||||
threeSceneRef.current?.selectTarget(nextFrame.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (['1', '2', '3', '4', '5'].includes(event.key)) {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
sceneRef.current?.castSpell(Number(event.key) as SpellSlot)
|
||||
const target = [state.boss, ...state.adds].find((enemy) => enemy.hp > 0)
|
||||
if (target) {
|
||||
playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
|
||||
threeSceneRef.current?.selectTarget(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
const spellKey = event.key === '0' ? 10 : Number(event.key)
|
||||
if (getActionSpellbook(state.player.classId).bar.includes(spellKey as SpellSlot)) {
|
||||
event.preventDefault()
|
||||
playCombatSound(DUNGEON_SFX.cast, { unlocked: audioUnlockedRef })
|
||||
threeSceneRef.current?.castSpell(spellKey as SpellSlot)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [raidFrames])
|
||||
}, [paused, raidFrames, state.adds, state.boss, state.player.classId])
|
||||
|
||||
useEffect(() => {
|
||||
for (const event of state.healEvents) {
|
||||
if (seenHealEventIdsRef.current.has(event.id)) continue
|
||||
seenHealEventIdsRef.current.add(event.id)
|
||||
playCombatSound(DUNGEON_SFX.heal, { unlocked: audioUnlockedRef, volume: 0.42 })
|
||||
}
|
||||
if (state.result !== 'playing') return
|
||||
if (state.lastHit && state.lastHit !== previousLastHitRef.current) {
|
||||
if (state.lastHit === 'player') playCombatSound(DUNGEON_SFX.hurt, { unlocked: audioUnlockedRef, volume: 0.38 })
|
||||
if (state.lastHit === 'boss') playCombatSound(DUNGEON_SFX.hit, { unlocked: audioUnlockedRef, volume: 0.35 })
|
||||
}
|
||||
previousLastHitRef.current = state.lastHit
|
||||
}, [state.healEvents, state.lastHit, state.result])
|
||||
|
||||
useEffect(() => {
|
||||
if (runMode === 'marathon') {
|
||||
@@ -133,28 +152,22 @@ export function BulldromeBossSlice({
|
||||
}, [onRunComplete, runMode, state.bossKills, state.result])
|
||||
|
||||
return (
|
||||
<main className="boss-slice-shell">
|
||||
<main className="boss-slice-shell dungeon-combat-shell">
|
||||
<section className="boss-slice-stage">
|
||||
<div className="boss-slice-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Action Boss Prototype</p>
|
||||
<h1>{getRunTitle(dungeonId, difficulty, runMode)}</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onExit} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
<div className="boss-slice-layout">
|
||||
<aside className="boss-party-frames" aria-label="Party frames">
|
||||
{raidFrames.map((frame) => (
|
||||
<PartyFrame
|
||||
frame={frame}
|
||||
key={frame.id}
|
||||
onSelect={() => sceneRef.current?.selectTarget(frame.id)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
<div className="boss-slice-layout dungeon-combat-layout">
|
||||
<div className="boss-playfield-panel">
|
||||
<div className="boss-window-bossbar">
|
||||
<div className="boss-playfield-frame">
|
||||
<div className="arena-boss-window-party dungeon-window-party" aria-label="Party frames">
|
||||
<CombatPartyFrames
|
||||
compact
|
||||
frames={raidFrames}
|
||||
onSelect={(id) => {
|
||||
playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
|
||||
threeSceneRef.current?.selectTarget(id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="boss-window-bossbar dungeon-encounter-frame">
|
||||
<strong>{encounterTitle}</strong>
|
||||
<span>{Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}</span>
|
||||
<i>
|
||||
@@ -164,7 +177,7 @@ export function BulldromeBossSlice({
|
||||
{state.player.currentCast && (
|
||||
<div className="boss-castbar boss-field-castbar">
|
||||
<div>
|
||||
<strong>{SPELLS[state.player.currentCast.spell].name}</strong>
|
||||
<strong>{getActionSpellDefinition(state.player.classId, state.player.currentCast.spell, state.player.talentModifiers).name}</strong>
|
||||
<span>{state.player.currentCast.remaining.toFixed(1)}s</span>
|
||||
</div>
|
||||
<i>
|
||||
@@ -176,171 +189,154 @@ export function BulldromeBossSlice({
|
||||
</i>
|
||||
</div>
|
||||
)}
|
||||
<div className="boss-canvas-wrap" ref={mountRef} aria-label="Bulldrome boss fight canvas" />
|
||||
</div>
|
||||
<aside className="boss-hud">
|
||||
<div className="boss-hud-status">
|
||||
<p className="eyebrow">State</p>
|
||||
<h2>{resultLabel}</h2>
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
|
||||
<Meter label="Player" value={state.player.hp} max={state.player.maxHp} tone="player" />
|
||||
<div className="boss-enemy-list">
|
||||
{enemyFrames.map((enemy) => (
|
||||
<EnemyRow enemy={enemy} key={enemy.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="boss-spellbar">
|
||||
{(Object.values(SPELLS) as SpellDefinition[]).map((spell) => (
|
||||
<SpellButton
|
||||
cooldown={state.player.spellCooldowns[spell.slot]}
|
||||
key={spell.slot}
|
||||
onCast={() => sceneRef.current?.castSpell(spell.slot)}
|
||||
spell={spell}
|
||||
{selectedTarget && (
|
||||
<CombatTargetFrame
|
||||
className="dungeon-target-chip"
|
||||
detail={selectedTarget.detail}
|
||||
hp={selectedTarget.hp}
|
||||
kindLabel={selectedTarget.kindLabel}
|
||||
label={selectedTarget.label}
|
||||
maxHp={selectedTarget.maxHp}
|
||||
tone={selectedTarget.tone}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
<CombatActionBar
|
||||
className="dungeon-actionbar"
|
||||
columns={getActionSpellbook(state.player.classId).bar.length}
|
||||
globalCooldown={state.player.globalCooldown}
|
||||
onCast={(slot) => {
|
||||
playCombatSound(DUNGEON_SFX.cast, { unlocked: audioUnlockedRef })
|
||||
threeSceneRef.current?.castSpell(slot)
|
||||
}}
|
||||
slots={createDungeonActionSlots(state, selectedTarget?.kind ?? 'ally')}
|
||||
/>
|
||||
{renderUnavailable ? (
|
||||
<div className="boss-render-loading">3D renderer unavailable.</div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="boss-render-loading">Loading 3D hunt...</div>}>
|
||||
<ThreeActionScene
|
||||
difficulty={difficulty}
|
||||
dungeonId={dungeonId}
|
||||
onStateChange={setState}
|
||||
onRenderUnavailable={handleRenderUnavailable}
|
||||
paused={paused}
|
||||
playerClassId={combatProfile.classId}
|
||||
playerTalentModifiers={combatProfile.talentModifiers}
|
||||
ref={threeSceneRef}
|
||||
runMode={runMode}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{paused && (
|
||||
pausePanel === 'settings' ? (
|
||||
<PauseSettingsMenu
|
||||
exitLabel="Exit Run"
|
||||
onExit={onExit}
|
||||
onOpenCharacter={character ? () => setPausePanel('character') : undefined}
|
||||
onOpenInventory={character && onEquipItem ? () => setPausePanel('inventory') : undefined}
|
||||
onOpenTalents={character ? () => setPausePanel('talents') : undefined}
|
||||
onResume={() => setPaused(false)}
|
||||
/>
|
||||
) : character && pausePanel === 'character' ? (
|
||||
<ActionMenuOverlay
|
||||
onBack={() => setPausePanel('settings')}
|
||||
onClose={() => setPaused(false)}
|
||||
title="Character"
|
||||
>
|
||||
<ActionCharacterMenu
|
||||
character={character}
|
||||
onOpenInventory={onEquipItem ? () => setPausePanel('inventory') : undefined}
|
||||
onOpenTalents={() => setPausePanel('talents')}
|
||||
/>
|
||||
</ActionMenuOverlay>
|
||||
) : character && pausePanel === 'talents' ? (
|
||||
<ActionMenuOverlay
|
||||
onBack={() => setPausePanel('settings')}
|
||||
onClose={() => setPaused(false)}
|
||||
title="Talents"
|
||||
>
|
||||
<ActionTalentMenu character={character} onCharacterChange={onCharacterChange} />
|
||||
</ActionMenuOverlay>
|
||||
) : character && onEquipItem ? (
|
||||
<ActionMenuOverlay
|
||||
onBack={() => setPausePanel('settings')}
|
||||
onClose={() => setPaused(false)}
|
||||
title="Inventory"
|
||||
>
|
||||
<ActionInventoryMenu character={character} onEquip={onEquipItem} />
|
||||
</ActionMenuOverlay>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="boss-stat-grid">
|
||||
<div>
|
||||
<dt>Boss</dt>
|
||||
<dd>{state.boss.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Time</dt>
|
||||
<dd>{state.elapsed.toFixed(1)}s</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stun</dt>
|
||||
<dd>{state.player.stunTimer > 0 ? `${state.player.stunTimer.toFixed(1)}s` : 'Clear'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>{raidFrames.find((frame) => frame.selected)?.name ?? 'None'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="boss-controls">
|
||||
<strong>Controls</strong>
|
||||
<span>WASD: move</span>
|
||||
<span>Up / Down: target frame</span>
|
||||
<span>1-5: healing spells</span>
|
||||
<span>R: reset</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function EnemyRow({ enemy }: { enemy: EnemyFrame }) {
|
||||
const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100))
|
||||
|
||||
return (
|
||||
<div className={`boss-enemy-row ${enemy.kind}`}>
|
||||
<div>
|
||||
<strong>{enemy.name}</strong>
|
||||
<span>{Math.ceil(enemy.hp)} / {enemy.maxHp}</span>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${percent}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
function createDungeonActionSlots(state: BulldromeState, targetKind: 'ally' | 'enemy'): Array<CombatActionSlot<SpellSlot>> {
|
||||
return getActionSpellbook(state.player.classId).bar.map((slot) => {
|
||||
const spell = getActionSpellDefinition(state.player.classId, slot, state.player.talentModifiers)
|
||||
const manaCost = getActionSpellManaCost(state.player.classId, slot, { freeCast: state.player.storedMomentumReady, talentMods: state.player.talentModifiers })
|
||||
const canAfford = state.player.mana >= manaCost
|
||||
const spellTarget = getActionSpellTarget(state.player.classId, slot)
|
||||
const tooltip = getActionSpellTooltip(state.player.classId, slot)
|
||||
const needsEnemy = spellTarget === 'Enemy target'
|
||||
const hasTarget = needsEnemy ? targetKind === 'enemy' : targetKind === 'ally'
|
||||
return {
|
||||
canCast: canAfford && hasTarget,
|
||||
cooldown: state.player.spellCooldowns[slot],
|
||||
cooldownBase: spell.cooldown,
|
||||
globalClassName: `${canAfford ? '' : 'oom'} ${hasTarget ? '' : 'no-target'}`,
|
||||
iconUrl: getActionSpellIconUrl(state.player.classId, slot),
|
||||
id: slot,
|
||||
keybind: slot === 10 ? '0' : String(slot),
|
||||
name: spell.name,
|
||||
tooltip: {
|
||||
cast: spell.castTime > 0 ? `${spell.castTime}s` : 'Instant',
|
||||
cooldown: spell.cooldown > 0 ? `${spell.cooldown}s` : undefined,
|
||||
cost: `${manaCost} mana`,
|
||||
description: tooltip.description,
|
||||
rank: `Rank ${tooltip.rank} · highest at level 20`,
|
||||
school: tooltip.school,
|
||||
target: tooltip.target,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function PartyFrame({
|
||||
frame,
|
||||
onSelect,
|
||||
}: {
|
||||
frame: RaidFrame
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100))
|
||||
const shieldPercent = Math.max(0, Math.min(100 - percent, (frame.shield / frame.maxHp) * 100))
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`party-frame ${frame.selected ? 'selected' : ''} ${frame.hp <= 0 ? 'dead' : ''}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className={`role-chip ${frame.role}`}>{frame.role}</span>
|
||||
<strong>{frame.name}</strong>
|
||||
<small>{Math.ceil(frame.hp)} / {frame.maxHp}</small>
|
||||
<i>
|
||||
<span className="party-health-fill" style={{ width: `${percent}%` }} />
|
||||
{frame.shield > 0 && (
|
||||
<span
|
||||
className="party-shield-fill"
|
||||
style={{
|
||||
left: `${percent}%`,
|
||||
width: `${shieldPercent}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</i>
|
||||
{frame.shield > 0 && <em>Shield {Math.ceil(frame.shield)}</em>}
|
||||
{frame.renewTimer > 0 && <em>Renew {frame.renewTimer.toFixed(0)}s</em>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SpellButton({
|
||||
cooldown,
|
||||
onCast,
|
||||
spell,
|
||||
}: {
|
||||
cooldown: number
|
||||
onCast: () => void
|
||||
spell: SpellDefinition
|
||||
}) {
|
||||
const cooldownPercent = spell.cooldown > 0
|
||||
? Math.max(0, Math.min(100, (cooldown / spell.cooldown) * 100))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cooldown > 0 ? 'cooling' : ''}
|
||||
onClick={onCast}
|
||||
type="button"
|
||||
>
|
||||
<strong>{spell.slot}</strong>
|
||||
<span>{spell.name}</span>
|
||||
{cooldown > 0 && (
|
||||
<>
|
||||
<i style={{ height: `${cooldownPercent}%` }} />
|
||||
<em>{cooldown.toFixed(1)}s</em>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
max,
|
||||
tone,
|
||||
value,
|
||||
}: {
|
||||
function getDungeonTargetFrame(state: BulldromeState): {
|
||||
detail: string
|
||||
hp: number
|
||||
kind: 'ally' | 'enemy'
|
||||
kindLabel: string
|
||||
label: string
|
||||
max: number
|
||||
tone: 'player' | 'boss'
|
||||
value: number
|
||||
}) {
|
||||
const percent = Math.max(0, Math.min(100, (value / max) * 100))
|
||||
maxHp: number
|
||||
tone: 'ally' | 'enemy'
|
||||
} | null {
|
||||
const ally = [state.player, ...state.party].find((unit) => unit.id === state.targetId)
|
||||
if (ally) {
|
||||
return {
|
||||
detail: `${ally.role.toUpperCase()} · ${Math.ceil(ally.hp)} / ${ally.maxHp}`,
|
||||
hp: ally.hp,
|
||||
kind: 'ally',
|
||||
kindLabel: 'Target Ally',
|
||||
label: ally.name,
|
||||
maxHp: ally.maxHp,
|
||||
tone: 'ally',
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-meter ${tone}`}>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{Math.ceil(value)} / {max}</span>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${percent}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
const enemy = [state.boss, ...state.adds].find((unit) => unit.id === state.targetId)
|
||||
if (!enemy) return null
|
||||
return {
|
||||
detail: `${enemy.kind.toUpperCase()} · ${Math.ceil(enemy.hp)} / ${enemy.maxHp}`,
|
||||
hp: enemy.hp,
|
||||
kind: 'enemy',
|
||||
kindLabel: enemy.id === state.boss.id ? 'Target Boss' : 'Target Mob',
|
||||
label: enemy.name,
|
||||
maxHp: enemy.maxHp,
|
||||
tone: 'enemy',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const COMBAT_AUDIO_STORAGE_KEY = 'actionModeSoundEnabled'
|
||||
const COMBAT_AUDIO_EVENT = 'action-mode-sound-enabled-change'
|
||||
|
||||
let combatAudioEnabledFallback = false
|
||||
|
||||
export function getCombatAudioEnabled() {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
const value = window.localStorage.getItem(COMBAT_AUDIO_STORAGE_KEY)
|
||||
if (value === null) return combatAudioEnabledFallback
|
||||
return value === 'true'
|
||||
} catch {
|
||||
return combatAudioEnabledFallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setCombatAudioEnabled(enabled: boolean) {
|
||||
combatAudioEnabledFallback = enabled
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(COMBAT_AUDIO_STORAGE_KEY, enabled ? 'true' : 'false')
|
||||
} catch {
|
||||
// WebViews can deny localStorage; module fallback still controls playback.
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(COMBAT_AUDIO_EVENT, { detail: enabled }))
|
||||
}
|
||||
|
||||
export function useCombatAudioEnabled() {
|
||||
const [enabled, setEnabled] = useState(() => getCombatAudioEnabled())
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event?: Event) => {
|
||||
if (event instanceof CustomEvent && typeof event.detail === 'boolean') {
|
||||
setEnabled(event.detail)
|
||||
return
|
||||
}
|
||||
setEnabled(getCombatAudioEnabled())
|
||||
}
|
||||
window.addEventListener(COMBAT_AUDIO_EVENT, sync)
|
||||
window.addEventListener('storage', sync)
|
||||
return () => {
|
||||
window.removeEventListener(COMBAT_AUDIO_EVENT, sync)
|
||||
window.removeEventListener('storage', sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [enabled, setCombatAudioEnabled] as const
|
||||
}
|
||||
|
||||
export function playCombatSound(src: string, options: { unlocked?: { current: boolean }, volume?: number } = {}) {
|
||||
if (typeof window === 'undefined' || !getCombatAudioEnabled()) return
|
||||
if (options.unlocked) options.unlocked.current = true
|
||||
const audio = new Audio(src)
|
||||
audio.volume = options.volume ?? 0.32
|
||||
audio.play().catch(() => {
|
||||
// Browser blocked autoplay; direct user input will retry later.
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react'
|
||||
import { SPELL_GLOBAL_COOLDOWN_SECONDS, type HealOverTimeFrame } from '../actionCombatCore'
|
||||
|
||||
export type CombatPartyFrameData = {
|
||||
bleedTimer?: number
|
||||
burnTimer?: number
|
||||
damageDone?: number
|
||||
healOverTimes: HealOverTimeFrame[]
|
||||
hp: number
|
||||
id: string
|
||||
maxHp: number
|
||||
name: string
|
||||
renewTimer: number
|
||||
role: string
|
||||
selected: boolean
|
||||
shield: number
|
||||
specLabel?: string
|
||||
}
|
||||
|
||||
export type CombatActionSlot<Slot extends number> = {
|
||||
canCast: boolean
|
||||
cooldown: number
|
||||
cooldownBase?: number
|
||||
globalClassName?: string
|
||||
iconUrl: string
|
||||
id: Slot
|
||||
keybind?: string
|
||||
name: string
|
||||
tooltip: {
|
||||
cast: string
|
||||
cooldown?: string
|
||||
cost: string
|
||||
description: string
|
||||
rank: string
|
||||
school: string
|
||||
target: string
|
||||
}
|
||||
}
|
||||
|
||||
export function CombatPartyFrames({
|
||||
className = '',
|
||||
compact = false,
|
||||
frames,
|
||||
manaByFrameId,
|
||||
onSelect,
|
||||
}: {
|
||||
className?: string
|
||||
compact?: boolean
|
||||
frames: CombatPartyFrameData[]
|
||||
manaByFrameId?: Record<string, { value: number, max: number } | undefined>
|
||||
onSelect?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={className} aria-label="Party frames">
|
||||
{frames.map((frame) => (
|
||||
<CombatPartyFrame
|
||||
compact={compact}
|
||||
frame={frame}
|
||||
key={frame.id}
|
||||
mana={manaByFrameId?.[frame.id]}
|
||||
onSelect={onSelect ? () => onSelect(frame.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CombatPartyFrame({
|
||||
compact = false,
|
||||
frame,
|
||||
mana,
|
||||
onSelect,
|
||||
}: {
|
||||
compact?: boolean
|
||||
frame: CombatPartyFrameData
|
||||
mana?: { value: number, max: number }
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100))
|
||||
const rawShieldPercent = Math.max(0, Math.min(100, (frame.shield / frame.maxHp) * 100))
|
||||
const shieldLeft = percent >= 99.5 ? 0 : percent
|
||||
const shieldPercent = percent >= 99.5 ? rawShieldPercent : Math.min(100 - percent, rawShieldPercent)
|
||||
const manaPercent = mana ? Math.max(0, Math.min(100, (mana.value / mana.max) * 100)) : 0
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`party-frame ${frame.selected ? 'selected' : ''} ${frame.hp <= 0 ? 'dead' : ''} ${compact ? 'compact' : ''}`}
|
||||
disabled={!onSelect}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className={`role-chip ${frame.role}`}>{frame.specLabel ?? frame.role}</span>
|
||||
<strong>{frame.name}</strong>
|
||||
<small>{Math.ceil(frame.hp)} / {frame.maxHp}</small>
|
||||
<span className="party-damage-meter">Damage {Math.round(frame.damageDone ?? 0).toLocaleString()}</span>
|
||||
<i>
|
||||
<span className="party-health-fill" style={{ width: `${percent}%` }} />
|
||||
{frame.shield > 0 && (
|
||||
<span className="party-shield-fill" style={{ left: `${shieldLeft}%`, width: `${shieldPercent}%` }} />
|
||||
)}
|
||||
</i>
|
||||
{mana && (
|
||||
<i className="party-mana-track">
|
||||
<span className="party-mana-fill" style={{ width: `${manaPercent}%` }} />
|
||||
</i>
|
||||
)}
|
||||
{(frame.shield > 0 || frame.healOverTimes.length > 0) && (
|
||||
<span className="party-frame-effects">
|
||||
{frame.shield > 0 && <em>Shield {Math.ceil(frame.shield)}</em>}
|
||||
{frame.healOverTimes.map((effect) => (
|
||||
<em className={`hot ${effect.id}`} key={effect.id}>
|
||||
{effect.label} {effect.remaining.toFixed(0)}s
|
||||
</em>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{(frame.burnTimer ?? 0) > 0 && <em className="danger">Burn {(frame.burnTimer ?? 0).toFixed(0)}s</em>}
|
||||
{(frame.bleedTimer ?? 0) > 0 && <em className="bleed">Bleed {(frame.bleedTimer ?? 0).toFixed(0)}s</em>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function CombatTargetFrame({
|
||||
action,
|
||||
className = '',
|
||||
detail,
|
||||
hp,
|
||||
kindLabel,
|
||||
label,
|
||||
maxHp,
|
||||
tone = 'enemy',
|
||||
}: {
|
||||
action?: { label: string, onClick: () => void }
|
||||
className?: string
|
||||
detail: string
|
||||
hp: number
|
||||
kindLabel: string
|
||||
label: string
|
||||
maxHp: number
|
||||
tone?: 'ally' | 'enemy'
|
||||
}) {
|
||||
return (
|
||||
<div className={`combat-target-frame arena-target-chip ${className} ${tone === 'ally' ? 'ally-target' : ''}`}>
|
||||
<div>
|
||||
<span>{kindLabel}</span>
|
||||
<strong>{label}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${Math.max(0, Math.min(100, (hp / maxHp) * 100))}%` }} />
|
||||
</i>
|
||||
{action && <button type="button" onClick={action.onClick}>{action.label}</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CombatActionBar<Slot extends number>({
|
||||
className = '',
|
||||
columns,
|
||||
globalCooldown,
|
||||
onCast,
|
||||
slots,
|
||||
}: {
|
||||
className?: string
|
||||
columns: number
|
||||
globalCooldown: number
|
||||
onCast: (slot: Slot) => void
|
||||
slots: CombatActionSlot<Slot>[]
|
||||
}) {
|
||||
const [tooltipSlot, setTooltipSlot] = useState<Slot | null>(null)
|
||||
const tooltip = slots.find((slot) => slot.id === tooltipSlot)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`claudecraft-actionbar ${className}`}
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, 48px)` }}
|
||||
aria-label="ClaudeCraft action bar"
|
||||
>
|
||||
{slots.map((slot) => {
|
||||
const visibleCooldown = Math.max(slot.cooldown, globalCooldown)
|
||||
const cooldownBase = slot.cooldown > 0 ? (slot.cooldownBase ?? 1) : SPELL_GLOBAL_COOLDOWN_SECONDS
|
||||
const cooldownPercent = visibleCooldown > 0 && cooldownBase > 0
|
||||
? Math.max(0, Math.min(100, (visibleCooldown / cooldownBase) * 100))
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
aria-label={`${slot.name}, slot ${slot.id}`}
|
||||
className={`claudecraft-action-slot ${visibleCooldown > 0 ? 'cooling' : ''} ${globalCooldown > 0 && slot.cooldown <= 0 ? 'global-cooldown' : ''} ${slot.globalClassName ?? ''}`}
|
||||
disabled={visibleCooldown > 0 || !slot.canCast}
|
||||
key={slot.id}
|
||||
onBlur={() => setTooltipSlot(null)}
|
||||
onClick={() => onCast(slot.id)}
|
||||
onFocus={() => setTooltipSlot(slot.id)}
|
||||
onMouseEnter={() => setTooltipSlot(slot.id)}
|
||||
onMouseLeave={() => setTooltipSlot(null)}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon-label" style={{ backgroundImage: `url(${slot.iconUrl})` }} />
|
||||
<span className="keybind">{slot.keybind ?? slot.id}</span>
|
||||
{visibleCooldown > 0 && (
|
||||
<>
|
||||
<span className="cd-overlay" style={{ height: `${cooldownPercent}%` }} />
|
||||
{slot.cooldown > 0 && <span className="cdtext">{slot.cooldown > 1 ? Math.ceil(slot.cooldown) : slot.cooldown.toFixed(1)}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{tooltip && (
|
||||
<div className="claudecraft-action-tooltip" role="tooltip">
|
||||
<div className="tooltip-heading">
|
||||
<img alt="" draggable="false" src={tooltip.iconUrl} />
|
||||
<div>
|
||||
<strong>{tooltip.name}</strong>
|
||||
<span>{tooltip.tooltip.rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>School</dt><dd>{tooltip.tooltip.school}</dd></div>
|
||||
<div><dt>Target</dt><dd>{tooltip.tooltip.target}</dd></div>
|
||||
<div><dt>Cost</dt><dd>{tooltip.tooltip.cost}</dd></div>
|
||||
<div><dt>Cast</dt><dd>{tooltip.tooltip.cast}</dd></div>
|
||||
{tooltip.tooltip.cooldown && <div><dt>Cooldown</dt><dd>{tooltip.tooltip.cooldown}</dd></div>}
|
||||
</dl>
|
||||
<p>{tooltip.tooltip.description}</p>
|
||||
{tooltip.cooldown > 0 && <em>{tooltip.cooldown.toFixed(1)}s remaining</em>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCombatAudioEnabled } from './CombatAudio'
|
||||
|
||||
export function PauseSettingsMenu({
|
||||
exitLabel = 'Exit Round',
|
||||
onOpenCharacter,
|
||||
onOpenInventory,
|
||||
onOpenTalents,
|
||||
onExit,
|
||||
onResume,
|
||||
}: {
|
||||
exitLabel?: string
|
||||
onOpenCharacter?: () => void
|
||||
onOpenInventory?: () => void
|
||||
onOpenTalents?: () => void
|
||||
onExit: () => void
|
||||
onResume: () => void
|
||||
}) {
|
||||
const [combatAudioEnabled, setCombatAudioEnabled] = useCombatAudioEnabled()
|
||||
|
||||
return (
|
||||
<div className="arena-pause-backdrop" role="dialog" aria-modal="true" aria-labelledby="arena-pause-title">
|
||||
<section className="arena-pause-menu">
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2 id="arena-pause-title">Settings</h2>
|
||||
</div>
|
||||
<div className="arena-pause-options" aria-label="Pause settings">
|
||||
<label>
|
||||
<span>Camera Drag</span>
|
||||
<strong>Enabled</strong>
|
||||
</label>
|
||||
<label>
|
||||
<span>Action Bar</span>
|
||||
<strong>1-0</strong>
|
||||
</label>
|
||||
<label>
|
||||
<span>Party Targeting</span>
|
||||
<strong>Arrow Keys</strong>
|
||||
</label>
|
||||
<label>
|
||||
<span>Sound</span>
|
||||
<button
|
||||
aria-checked={combatAudioEnabled}
|
||||
className={`arena-sound-toggle ${combatAudioEnabled ? 'enabled' : ''}`}
|
||||
onClick={() => setCombatAudioEnabled(!combatAudioEnabled)}
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<strong>{combatAudioEnabled ? 'On' : 'Muted'}</strong>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div className="arena-pause-actions">
|
||||
{onOpenCharacter && <button className="back-button" onClick={onOpenCharacter} type="button">Character</button>}
|
||||
{onOpenInventory && <button className="back-button" onClick={onOpenInventory} type="button">Inventory</button>}
|
||||
{onOpenTalents && <button className="back-button" onClick={onOpenTalents} type="button">Talents</button>}
|
||||
<button className="primary-button" onClick={onResume} type="button">Resume</button>
|
||||
<button className="back-button" onClick={onExit} type="button">{exitLabel}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
addBirdMesh,
|
||||
addBoarMesh,
|
||||
cancelCombatRenderLoads,
|
||||
clearCombatRenderCaches,
|
||||
addClaudeCraftBossModel,
|
||||
addCombatUnitMesh,
|
||||
addCyberDragonMesh,
|
||||
animateCombatWeapon,
|
||||
getEnemyModelYawOffset,
|
||||
syncRangedCombatProjectiles,
|
||||
updateCombatUnitModel,
|
||||
updateBossModelAnimation,
|
||||
} from '../action3dCombatRender'
|
||||
import {
|
||||
ACTION_ARENA_SCALE,
|
||||
ACTION_PLAYER_KEYS,
|
||||
ACTION_RENDER_QUALITY,
|
||||
clearCombatActions,
|
||||
createActionCombatCamera,
|
||||
createActionCombatCameraController,
|
||||
createHealBurstEffect,
|
||||
getArcTelegraph,
|
||||
getCircleTelegraph,
|
||||
getLineTelegraph,
|
||||
getOrCreateStunEffect,
|
||||
pruneObjectMap,
|
||||
readCombatInput,
|
||||
resizeActionCombatCamera,
|
||||
setObjectOpacity,
|
||||
syncArcTelegraphGeometry,
|
||||
setupActionSceneEnvironment,
|
||||
syncLineTelegraphGeometry,
|
||||
toActionWorld,
|
||||
updateActionCombatCamera,
|
||||
} from '../action3dSceneKit'
|
||||
import {
|
||||
createBulldromeState,
|
||||
getAllEnemies,
|
||||
getTargetableUnits,
|
||||
updateBulldromeState,
|
||||
type ActionDungeonId,
|
||||
type BulldromeState,
|
||||
type EnemyKind,
|
||||
type SpellSlot,
|
||||
} from '../actionBoss/actionCombatSimulation'
|
||||
import type { ActionDifficulty, ActionRunMode } from '../actionMode'
|
||||
import type { ArenaClassId } from '../actionClassKits'
|
||||
import type { PlayerClass } from '../claudeCraftTypes'
|
||||
import type { TalentModifiers } from '../claudeCraftTalents'
|
||||
|
||||
export type ThreeActionSceneHandle = {
|
||||
castSpell: (slot: SpellSlot) => void
|
||||
selectTarget: (targetId: string) => void
|
||||
}
|
||||
|
||||
type ThreeActionSceneProps = {
|
||||
difficulty: ActionDifficulty
|
||||
dungeonId: ActionDungeonId
|
||||
runMode: ActionRunMode
|
||||
onStateChange: (state: BulldromeState) => void
|
||||
onRenderUnavailable?: () => void
|
||||
paused?: boolean
|
||||
playerClassId?: PlayerClass
|
||||
playerTalentModifiers?: TalentModifiers
|
||||
}
|
||||
|
||||
type UnitMesh = THREE.Group & {
|
||||
userData: {
|
||||
defeatVisualTimer?: number
|
||||
deathAnimationStarted?: boolean
|
||||
kind?: EnemyKind
|
||||
lastX?: number
|
||||
lastY?: number
|
||||
role?: string
|
||||
}
|
||||
}
|
||||
|
||||
const ENEMY_DEATH_VISUAL_SECONDS = 2.4
|
||||
|
||||
export const ThreeActionScene = forwardRef<ThreeActionSceneHandle, ThreeActionSceneProps>(function ThreeActionScene({
|
||||
difficulty,
|
||||
dungeonId,
|
||||
onRenderUnavailable,
|
||||
paused = false,
|
||||
playerClassId = 'priest',
|
||||
playerTalentModifiers,
|
||||
runMode,
|
||||
onStateChange,
|
||||
}, ref) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null)
|
||||
const stateRef = useRef(createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers))
|
||||
const queuedTargetRef = useRef<string | null>(null)
|
||||
const queuedSpellRef = useRef<SpellSlot | null>(null)
|
||||
const pausedRef = useRef(paused)
|
||||
const actionQueueRef = useRef({ reset: false })
|
||||
const keysRef = useRef(new Set<string>())
|
||||
const cameraControllerRef = useRef(createActionCombatCameraController())
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
castSpell: (slot) => {
|
||||
queuedSpellRef.current = slot
|
||||
},
|
||||
selectTarget: (targetId) => {
|
||||
queuedTargetRef.current = targetId
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused
|
||||
if (paused) {
|
||||
keysRef.current.clear()
|
||||
queuedTargetRef.current = null
|
||||
queuedSpellRef.current = null
|
||||
clearCombatActions(actionQueueRef.current)
|
||||
}
|
||||
}, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers)
|
||||
onStateChange(stateRef.current)
|
||||
}, [difficulty, dungeonId, onStateChange, playerClassId, playerTalentModifiers, runMode])
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current
|
||||
if (!mount) return
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
let renderer: THREE.WebGLRenderer
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, powerPreference: 'high-performance' })
|
||||
} catch {
|
||||
onRenderUnavailable?.()
|
||||
return
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ACTION_RENDER_QUALITY.maxPixelRatio))
|
||||
renderer.shadowMap.enabled = ACTION_RENDER_QUALITY.shadows
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
mount.appendChild(renderer.domElement)
|
||||
|
||||
const camera = createActionCombatCamera({ far: 120 })
|
||||
const initialArena = stateRef.current.arena
|
||||
setupActionSceneEnvironment(scene, {
|
||||
worldHeight: (initialArena.height - initialArena.padding * 2) / ACTION_ARENA_SCALE,
|
||||
worldWidth: (initialArena.width - initialArena.padding * 2) / ACTION_ARENA_SCALE,
|
||||
})
|
||||
void dungeonId
|
||||
|
||||
const units = new Map<string, UnitMesh>()
|
||||
const fireballs = new Map<string, THREE.Mesh>()
|
||||
const fireSpots = new Map<string, THREE.Mesh>()
|
||||
const spinningBlades = new Map<string, THREE.Group>()
|
||||
const telegraphs = new Map<string, THREE.Mesh>()
|
||||
const arcTelegraphs = new Map<string, THREE.Mesh>()
|
||||
const slamTelegraphs = new Map<string, THREE.Mesh>()
|
||||
const partyEffects = new Map<string, THREE.Group>()
|
||||
const healEffects = new Map<string, THREE.Group>()
|
||||
const stunEffects = new Map<string, THREE.Group>()
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
let frameId = 0
|
||||
let hudTimer = 0
|
||||
|
||||
const resize = () => {
|
||||
const rect = mount.getBoundingClientRect()
|
||||
const width = Math.max(1, rect.width)
|
||||
const height = Math.max(1, rect.height)
|
||||
renderer.setSize(width, height, false)
|
||||
resizeActionCombatCamera(camera, width, height)
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(mount)
|
||||
resize()
|
||||
|
||||
const animate = () => {
|
||||
const delta = Math.min(clock.getDelta(), 0.05)
|
||||
if (pausedRef.current) {
|
||||
renderer.render(scene, camera)
|
||||
frameId = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
const input = readCombatInput(keysRef.current, cameraControllerRef.current.yaw, queuedTargetRef.current, queuedSpellRef.current, actionQueueRef.current, {
|
||||
fallbackAim: { x: 0, y: -1 },
|
||||
})
|
||||
queuedTargetRef.current = null
|
||||
queuedSpellRef.current = null
|
||||
clearCombatActions(actionQueueRef.current)
|
||||
|
||||
stateRef.current = updateBulldromeState(stateRef.current, input, delta)
|
||||
syncWorld(scene, stateRef.current, units, fireballs, fireSpots, spinningBlades, telegraphs, arcTelegraphs, slamTelegraphs, partyEffects, healEffects, stunEffects, delta, playerClassId)
|
||||
updateActionCombatCamera(camera, cameraControllerRef.current.yaw, stateRef.current.player, (x, y) => toActionWorld(x, y, stateRef.current.arena))
|
||||
renderer.render(scene, camera)
|
||||
|
||||
hudTimer -= delta
|
||||
if (hudTimer <= 0 || stateRef.current.result !== 'playing' || stateRef.current.lastHit) {
|
||||
onStateChange(stateRef.current)
|
||||
hudTimer = 0.08
|
||||
}
|
||||
|
||||
frameId = requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (pausedRef.current) return
|
||||
const key = event.key.toLowerCase()
|
||||
if (ACTION_PLAYER_KEYS.has(key)) event.preventDefault()
|
||||
keysRef.current.add(key)
|
||||
if (!event.repeat && key === 'r') actionQueueRef.current.reset = true
|
||||
}
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
keysRef.current.delete(event.key.toLowerCase())
|
||||
}
|
||||
const unbindCameraDrag = cameraControllerRef.current.bindDrag(renderer.domElement, {
|
||||
isPaused: () => pausedRef.current,
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
unbindCameraDrag()
|
||||
cancelCombatRenderLoads(scene)
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
object.geometry.dispose()
|
||||
const material = object.material
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose())
|
||||
else material.dispose()
|
||||
}
|
||||
})
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
clearCombatRenderCaches()
|
||||
}
|
||||
}, [difficulty, dungeonId, onRenderUnavailable, onStateChange, playerClassId, playerTalentModifiers, runMode])
|
||||
|
||||
return (
|
||||
<div className="three-action-root">
|
||||
<div className="three-action-canvas" ref={mountRef} />
|
||||
<div className="three-action-overlay">
|
||||
<div className="three-action-hint">WASD move | drag camera</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ThreeActionScene
|
||||
|
||||
function syncWorld(
|
||||
scene: THREE.Scene,
|
||||
state: BulldromeState,
|
||||
units: Map<string, UnitMesh>,
|
||||
fireballs: Map<string, THREE.Mesh>,
|
||||
fireSpots: Map<string, THREE.Mesh>,
|
||||
spinningBlades: Map<string, THREE.Group>,
|
||||
telegraphs: Map<string, THREE.Mesh>,
|
||||
arcTelegraphs: Map<string, THREE.Mesh>,
|
||||
slamTelegraphs: Map<string, THREE.Mesh>,
|
||||
partyEffects: Map<string, THREE.Group>,
|
||||
healEffects: Map<string, THREE.Group>,
|
||||
stunEffects: Map<string, THREE.Group>,
|
||||
deltaSeconds: number,
|
||||
playerClassId: PlayerClass,
|
||||
) {
|
||||
const toWorld = (x: number, y: number) => toActionWorld(x, y, state.arena)
|
||||
const primaryEnemy = getAllEnemies(state).find((enemy) => enemy.hp > 0)
|
||||
const activeUnits = new Set<string>()
|
||||
for (const unit of getTargetableUnits(state)) {
|
||||
activeUnits.add(unit.id)
|
||||
const mesh = getOrCreateUnit(scene, units, unit.id, unit.id === 'player' ? 'player' : unit.role, unit.id, playerClassId)
|
||||
placeUnit(mesh, unit.x, unit.y, toWorld)
|
||||
mesh.visible = unit.hp > 0
|
||||
mesh.scale.setScalar(1)
|
||||
if (unit.id === 'player') {
|
||||
const wasMoving = hasUnitMoved(mesh, unit.x, unit.y)
|
||||
mesh.rotation.y = Math.atan2(state.player.facingX, state.player.facingY)
|
||||
const weapon = mesh.getObjectByName('weapon')
|
||||
if (weapon) weapon.rotation.x = -0.18
|
||||
updateCombatUnitModel(mesh, deltaSeconds, {
|
||||
action: state.player.currentCast ? 'spell' : wasMoving ? 'run' : 'idle',
|
||||
role: 'player',
|
||||
})
|
||||
} else {
|
||||
if (primaryEnemy) mesh.rotation.y = getFacingYaw(unit, primaryEnemy)
|
||||
const wasMoving = hasUnitMoved(mesh, unit.x, unit.y)
|
||||
const inMelee = Boolean(
|
||||
primaryEnemy
|
||||
&& (unit.role === 'tank' || unit.role === 'melee')
|
||||
&& Math.hypot(unit.x - primaryEnemy.x, unit.y - primaryEnemy.y) <= primaryEnemy.radius + (unit.role === 'tank' ? 82 : 96),
|
||||
)
|
||||
const inRangedCombat = Boolean(primaryEnemy && unit.role === 'ranged')
|
||||
updateCombatUnitModel(mesh, deltaSeconds, {
|
||||
action: inMelee || inRangedCombat ? 'attack' : wasMoving ? 'run' : 'idle',
|
||||
role: unit.role,
|
||||
})
|
||||
animatePartyWeapon(mesh, unit.id, unit.role, state, primaryEnemy)
|
||||
}
|
||||
}
|
||||
for (const enemy of getAllEnemies(state)) {
|
||||
activeUnits.add(enemy.id)
|
||||
const mesh = getOrCreateUnit(scene, units, enemy.id, enemy.kind)
|
||||
placeUnit(mesh, enemy.x, enemy.y, toWorld)
|
||||
const deathVisible = updateEnemyDeathVisibility(mesh, enemy.hp, deltaSeconds)
|
||||
mesh.visible = enemy.hp > 0 || deathVisible
|
||||
updateBossModelAnimation(
|
||||
mesh,
|
||||
deltaSeconds,
|
||||
enemy.hp <= 0
|
||||
? 'death'
|
||||
: enemy.phase === 'windup' || enemy.phase === 'slamWindup' || enemy.phase === 'mauling'
|
||||
? 'attack'
|
||||
: enemy.phase === 'tracking' || enemy.phase === 'charging' || enemy.phase === 'circling'
|
||||
? 'walk'
|
||||
: 'idle',
|
||||
)
|
||||
const facing = Math.atan2(enemy.chargeVector.x, enemy.chargeVector.y)
|
||||
const spin = enemy.kind === 'cyber-dragon' && enemy.phase === 'circling'
|
||||
? (state.elapsed * Math.PI * 2.8) % (Math.PI * 2)
|
||||
: 0
|
||||
mesh.rotation.y = facing + getEnemyModelYawOffset(enemy.kind) + spin
|
||||
}
|
||||
pruneObjectMap(units, activeUnits)
|
||||
|
||||
syncHazards(scene, state.fireballs, fireballs, 0xff7a37, 0.22, toWorld)
|
||||
syncHazards(scene, state.fireSpots, fireSpots, 0xe3402f, 0.08, toWorld)
|
||||
syncSpinningBlades(scene, state.spinningBlades, spinningBlades, toWorld)
|
||||
syncPartyEffects(scene, state, partyEffects, toWorld)
|
||||
syncHealBursts(scene, state, healEffects, deltaSeconds, toWorld)
|
||||
syncStunEffects(scene, state, stunEffects, toWorld)
|
||||
|
||||
const activeTelegraphs = new Set<string>()
|
||||
for (const enemy of getAllEnemies(state)) {
|
||||
if (enemy.telegraph.active) {
|
||||
activeTelegraphs.add(enemy.id)
|
||||
const mesh = getLineTelegraph(scene, telegraphs, enemy.id)
|
||||
syncLineTelegraphGeometry(mesh, enemy.telegraph.start, enemy.telegraph.end, enemy.telegraph.width, toWorld)
|
||||
}
|
||||
}
|
||||
pruneObjectMap(telegraphs, activeTelegraphs)
|
||||
|
||||
const activeArcs = new Set<string>()
|
||||
for (const enemy of getAllEnemies(state)) {
|
||||
if (enemy.arcTelegraph.active) {
|
||||
activeArcs.add(enemy.id)
|
||||
const mesh = getArcTelegraph(scene, arcTelegraphs, enemy.id)
|
||||
syncArcTelegraphGeometry(
|
||||
mesh,
|
||||
enemy.arcTelegraph.center,
|
||||
enemy.arcTelegraph.radius,
|
||||
enemy.arcTelegraph.startAngle,
|
||||
enemy.arcTelegraph.endAngle,
|
||||
enemy.arcTelegraph.width,
|
||||
toWorld,
|
||||
)
|
||||
}
|
||||
}
|
||||
pruneObjectMap(arcTelegraphs, activeArcs)
|
||||
|
||||
const activeSlams = new Set<string>()
|
||||
for (const enemy of getAllEnemies(state)) {
|
||||
if (enemy.slamTelegraph.active) {
|
||||
activeSlams.add(enemy.id)
|
||||
const mesh = getCircleTelegraph(scene, slamTelegraphs, enemy.id)
|
||||
const point = toWorld(enemy.x, enemy.y)
|
||||
mesh.position.set(point.x, 0.04, point.z)
|
||||
mesh.scale.setScalar(enemy.slamTelegraph.radius / ACTION_ARENA_SCALE)
|
||||
}
|
||||
}
|
||||
pruneObjectMap(slamTelegraphs, activeSlams)
|
||||
}
|
||||
|
||||
function updateEnemyDeathVisibility(mesh: UnitMesh, hp: number, deltaSeconds: number) {
|
||||
if (hp > 0) {
|
||||
mesh.userData.defeatVisualTimer = undefined
|
||||
mesh.userData.deathAnimationStarted = false
|
||||
return false
|
||||
}
|
||||
|
||||
if (!mesh.userData.deathAnimationStarted) {
|
||||
mesh.userData.deathAnimationStarted = true
|
||||
mesh.userData.defeatVisualTimer = ENEMY_DEATH_VISUAL_SECONDS
|
||||
return true
|
||||
}
|
||||
|
||||
const nextTimer = Math.max(0, (mesh.userData.defeatVisualTimer ?? 0) - deltaSeconds)
|
||||
mesh.userData.defeatVisualTimer = nextTimer
|
||||
return nextTimer > 0
|
||||
}
|
||||
|
||||
function getFacingYaw(from: { x: number; y: number }, to: { x: number; y: number }) {
|
||||
return Math.atan2(to.x - from.x, to.y - from.y)
|
||||
}
|
||||
|
||||
function hasUnitMoved(mesh: UnitMesh, x: number, y: number) {
|
||||
const previousX = mesh.userData.lastX ?? x
|
||||
const previousY = mesh.userData.lastY ?? y
|
||||
mesh.userData.lastX = x
|
||||
mesh.userData.lastY = y
|
||||
return Math.hypot(x - previousX, y - previousY) > 0.5
|
||||
}
|
||||
|
||||
function getOrCreateUnit(scene: THREE.Scene, units: Map<string, UnitMesh>, id: string, kind: string, unitId = id, playerClassId: PlayerClass = 'priest') {
|
||||
const existing = units.get(id)
|
||||
if (existing) return existing
|
||||
|
||||
const group = new THREE.Group() as UnitMesh
|
||||
group.userData.kind = kind as EnemyKind
|
||||
if (kind === 'player') addCombatUnitMesh(group, { classId: toRenderableArenaClass(playerClassId), id: unitId, role: 'player' })
|
||||
else if (kind === 'tank' || kind === 'melee' || kind === 'ranged' || kind === 'healer') addCombatUnitMesh(group, { id: unitId, role: kind })
|
||||
else if (kind === 'bulldrome') {
|
||||
const fallback = new THREE.Group()
|
||||
fallback.name = 'proceduralFallback'
|
||||
fallback.rotation.y = Math.PI
|
||||
addBoarMesh(fallback, { kind: 'bulldrome' })
|
||||
group.add(fallback)
|
||||
addClaudeCraftBossModel(group, 'bulldrome')
|
||||
}
|
||||
else if (kind === 'bullfango') addBoarMesh(group, { kind: 'bullfango' })
|
||||
else if (kind === 'yian-kut-ku' || kind === 'bird') addBirdMesh(group, kind)
|
||||
else if (kind === 'cyber-dragon') {
|
||||
const fallback = new THREE.Group()
|
||||
fallback.name = 'proceduralFallback'
|
||||
addCyberDragonMesh(fallback)
|
||||
group.add(fallback)
|
||||
addClaudeCraftBossModel(group, 'cyber-dragon')
|
||||
}
|
||||
else if (kind === 'claudecraft-mob') addCombatUnitMesh(group, { id: unitId, role: 'melee' })
|
||||
else if (kind === 'morthen-the-gravecaller') addClaudeCraftBossModel(group, 'grand-necromancer-velkhar')
|
||||
else if (kind === 'vael-the-mistcaller') addClaudeCraftBossModel(group, 'vael-the-mistcaller')
|
||||
else if (kind === 'korzul-the-gravewyrm') addClaudeCraftBossModel(group, 'korzul-the-gravewyrm')
|
||||
else addCombatUnitMesh(group, { id: unitId, role: 'ranged' })
|
||||
scene.add(group)
|
||||
units.set(id, group)
|
||||
return group
|
||||
}
|
||||
|
||||
function toRenderableArenaClass(classId: PlayerClass): ArenaClassId {
|
||||
if (classId === 'paladin' || classId === 'shaman' || classId === 'druid' || classId === 'priest') return classId
|
||||
if (classId === 'warrior' || classId === 'mage' || classId === 'rogue') return classId
|
||||
if (classId === 'hunter') return 'ranger'
|
||||
return 'priest'
|
||||
}
|
||||
|
||||
function placeUnit(mesh: THREE.Object3D, x: number, y: number, toWorld: (x: number, y: number) => { x: number, z: number }) {
|
||||
const point = toWorld(x, y)
|
||||
mesh.position.set(point.x, 0, point.z)
|
||||
}
|
||||
|
||||
function syncHazards(
|
||||
scene: THREE.Scene,
|
||||
hazards: Array<{ id: string; x: number; y: number; radius: number }>,
|
||||
meshes: Map<string, THREE.Mesh>,
|
||||
color: number,
|
||||
height: number,
|
||||
toWorld: (x: number, y: number) => { x: number, z: number },
|
||||
) {
|
||||
const active = new Set<string>()
|
||||
for (const hazard of hazards) {
|
||||
active.add(hazard.id)
|
||||
let mesh = meshes.get(hazard.id)
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(Math.max(0.08, hazard.radius / ACTION_ARENA_SCALE), 12, 8),
|
||||
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.74 }),
|
||||
)
|
||||
scene.add(mesh)
|
||||
meshes.set(hazard.id, mesh)
|
||||
}
|
||||
const point = toWorld(hazard.x, hazard.y)
|
||||
mesh.position.set(point.x, height, point.z)
|
||||
mesh.scale.setScalar(Math.max(1, hazard.radius / 16))
|
||||
}
|
||||
pruneObjectMap(meshes, active)
|
||||
}
|
||||
|
||||
function syncSpinningBlades(
|
||||
scene: THREE.Scene,
|
||||
blades: Array<{ id: string; x: number; y: number; radius: number; rotation: number }>,
|
||||
meshes: Map<string, THREE.Group>,
|
||||
toWorld: (x: number, y: number) => { x: number, z: number },
|
||||
) {
|
||||
const active = new Set<string>()
|
||||
for (const blade of blades) {
|
||||
active.add(blade.id)
|
||||
let group = meshes.get(blade.id)
|
||||
if (!group) {
|
||||
group = new THREE.Group()
|
||||
const indicator = new THREE.Group()
|
||||
indicator.name = 'spinningBladeIndicator'
|
||||
indicator.position.y = -0.245
|
||||
const indicatorFill = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(0.62, 40),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff2020, transparent: true, opacity: 0.26, side: THREE.DoubleSide }),
|
||||
)
|
||||
indicatorFill.rotation.x = -Math.PI / 2
|
||||
indicator.add(indicatorFill)
|
||||
const indicatorRing = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(0.62, 0.018, 8, 48),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff3030, transparent: true, opacity: 0.82 }),
|
||||
)
|
||||
indicatorRing.rotation.x = Math.PI / 2
|
||||
indicator.add(indicatorRing)
|
||||
group.add(indicator)
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.18, 0.12, 16),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd9f7ff, metalness: 0.9, roughness: 0.18, emissive: 0x2bc3db, emissiveIntensity: 0.35 }),
|
||||
)
|
||||
core.rotation.x = Math.PI / 2
|
||||
group.add(core)
|
||||
const bladeMaterial = new THREE.MeshStandardMaterial({ color: 0x8ef7ff, metalness: 0.85, roughness: 0.22 })
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.08, 1.15), bladeMaterial)
|
||||
arm.rotation.y = (Math.PI / 2) * i
|
||||
arm.castShadow = true
|
||||
group.add(arm)
|
||||
}
|
||||
scene.add(group)
|
||||
meshes.set(blade.id, group)
|
||||
}
|
||||
const point = toWorld(blade.x, blade.y)
|
||||
group.position.set(point.x, 0.28, point.z)
|
||||
group.rotation.y = blade.rotation
|
||||
group.scale.setScalar(Math.max(0.65, blade.radius / 24))
|
||||
}
|
||||
pruneObjectMap(meshes, active)
|
||||
}
|
||||
|
||||
function syncPartyEffects(
|
||||
scene: THREE.Scene,
|
||||
state: BulldromeState,
|
||||
effects: Map<string, THREE.Group>,
|
||||
toWorld: (x: number, y: number) => { x: number, z: number },
|
||||
) {
|
||||
const target = getAllEnemies(state).find((enemy) => enemy.hp > 0)
|
||||
syncRangedCombatProjectiles(scene, effects, {
|
||||
elapsed: state.elapsed,
|
||||
teamKey: 'party',
|
||||
toWorld,
|
||||
target: target ?? null,
|
||||
staffCaster: state.party.find((member) => member.id === 'ranged-1' && member.hp > 0),
|
||||
archer: state.party.find((member) => member.id === 'ranged-2' && member.hp > 0),
|
||||
})
|
||||
}
|
||||
|
||||
function animatePartyWeapon(
|
||||
mesh: THREE.Object3D,
|
||||
unitId: string,
|
||||
role: string,
|
||||
state: BulldromeState,
|
||||
target: { x: number; y: number; radius: number } | undefined,
|
||||
) {
|
||||
if (!target || (role !== 'tank' && role !== 'melee')) return
|
||||
const unit = state.party.find((member) => member.id === unitId)
|
||||
if (!unit || unit.hp <= 0) return
|
||||
|
||||
const targetDistance = Math.hypot(unit.x - target.x, unit.y - target.y)
|
||||
animateCombatWeapon(mesh, role, {
|
||||
elapsed: state.elapsed,
|
||||
inMelee: targetDistance <= target.radius + (role === 'tank' ? 82 : 96),
|
||||
})
|
||||
}
|
||||
|
||||
function syncHealBursts(
|
||||
scene: THREE.Scene,
|
||||
state: BulldromeState,
|
||||
effects: Map<string, THREE.Group>,
|
||||
deltaSeconds: number,
|
||||
toWorld: (x: number, y: number) => { x: number, z: number },
|
||||
) {
|
||||
for (const event of state.healEvents) {
|
||||
if (effects.has(event.id)) continue
|
||||
const burst = createHealBurstEffect()
|
||||
burst.userData.targetId = event.targetId
|
||||
burst.userData.age = 0
|
||||
burst.userData.duration = 0.85
|
||||
scene.add(burst)
|
||||
effects.set(event.id, burst)
|
||||
}
|
||||
|
||||
const active = new Set<string>()
|
||||
for (const [id, burst] of effects) {
|
||||
const age = Number(burst.userData.age ?? 0) + deltaSeconds
|
||||
const duration = Number(burst.userData.duration ?? 0.85)
|
||||
burst.userData.age = age
|
||||
if (age >= duration) continue
|
||||
|
||||
const target = getTargetableUnits(state).find((unit) => unit.id === burst.userData.targetId)
|
||||
if (!target) continue
|
||||
|
||||
active.add(id)
|
||||
const point = toWorld(target.x, target.y)
|
||||
const progress = age / duration
|
||||
const alpha = Math.max(0, 1 - progress)
|
||||
burst.position.set(point.x, 0.08, point.z)
|
||||
burst.scale.setScalar(0.65 + progress * 1.25)
|
||||
burst.rotation.y += deltaSeconds * 3
|
||||
setObjectOpacity(burst, alpha)
|
||||
}
|
||||
|
||||
pruneObjectMap(effects, active)
|
||||
}
|
||||
|
||||
function syncStunEffects(
|
||||
scene: THREE.Scene,
|
||||
state: BulldromeState,
|
||||
effects: Map<string, THREE.Group>,
|
||||
toWorld: (x: number, y: number) => { x: number, z: number },
|
||||
) {
|
||||
const active = new Set<string>()
|
||||
for (const unit of getTargetableUnits(state)) {
|
||||
if (unit.hp <= 0 || unit.stunTimer <= 0) continue
|
||||
|
||||
active.add(unit.id)
|
||||
const effect = getOrCreateStunEffect(scene, effects, unit.id)
|
||||
const point = toWorld(unit.x, unit.y)
|
||||
effect.position.set(point.x, 1.48, point.z)
|
||||
effect.rotation.y = state.elapsed * 5.2
|
||||
effect.visible = true
|
||||
}
|
||||
pruneObjectMap(effects, active)
|
||||
}
|
||||
+3180
-12
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
function adminRootRedirect(): Plugin {
|
||||
return {
|
||||
name: 'admin-root-redirect',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
if (request.url === '/') {
|
||||
response.statusCode = 302
|
||||
response.setHeader('Location', '/admin.html')
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-admin',
|
||||
rollupOptions: {
|
||||
input: 'admin.html',
|
||||
},
|
||||
},
|
||||
plugins: [react(), adminRootRedirect()],
|
||||
})
|
||||
Reference in New Issue
Block a user