I Want To Heal 2 build v1.0.5 code

This commit is contained in:
Warren H
2026-06-28 13:29:39 -04:00
parent 8abb44ea02
commit 257c34fc8d
53 changed files with 18187 additions and 1199 deletions
+7
View File
@@ -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
View File
@@ -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.
+10
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}'],
+66 -1
View File
@@ -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
View File
@@ -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",
+27
View File
@@ -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
+562
View File
@@ -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()
+692
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
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>`
}
+134
View File
@@ -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
View File
@@ -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}
/>
)
}
+983
View File
@@ -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)
}
}
+413
View File
@@ -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
View File
@@ -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'
}
+364
View File
@@ -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
+52
View File
@@ -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',
}
+145 -8
View File
@@ -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,13 +154,23 @@ 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
: null)
: 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),
yAxis: Number(keys.S.isDown) - Number(keys.W.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,
File diff suppressed because it is too large Load Diff
+215 -20
View File
@@ -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,15 +333,38 @@ function mergeMechanicConfig(saved: Partial<ActionMechanicConfig>) {
...merged[kind],
...savedEnemy,
kind,
attacks: merged[kind].attacks.map((attack) => ({
...attack,
...savedAttacks.find((candidate) => candidate.id === attack.id),
})),
attacks: merged[kind].attacks.map((attack) => {
const savedAttack = savedAttacks.find((candidate) => candidate.id === attack.id)
return {
...attack,
...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
}
function cloneConfig(config: ActionMechanicConfig): ActionMechanicConfig {
return structuredClone(config)
}
+157
View File
@@ -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
}
+474
View File
@@ -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 }
}
+760
View File
@@ -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',
}
+25
View File
@@ -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: [],
}
}
+51
View File
@@ -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
}
+483 -220
View File
@@ -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,24 +77,38 @@ 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[] = [
{
@@ -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 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)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
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],
},
reward: {
coins: coinReward,
coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`,
experience: experienceReward,
gear,
leveledUp: nextLevel > character.level,
},
}
}
const experienceReward = getActionDifficultyTier(difficulty).experience
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
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 actionCoins = addActionCoins(character.actionCoins, dungeonId, tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
copper: character.copper + loot.copper,
actionCoins,
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: loot.copper,
coinName: 'Copper',
experience: experienceReward,
items: loot.items,
leveledUp: nextLevel > character.level,
},
}
}
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)
}
+240
View File
@@ -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' }
}
+26
View File
@@ -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>,
)
+336
View File
@@ -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
}
+181
View File
@@ -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'],
}
+59
View File
@@ -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
}
+132
View File
@@ -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'
}
+519
View File
@@ -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 },
}
+123
View File
@@ -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 }
}
+429
View File
@@ -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>
)
}
+907
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+540
View File
@@ -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
+11 -1
View File
@@ -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'
+256 -260
View File
@@ -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,214 +152,191 @@ 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">
<strong>{encounterTitle}</strong>
<span>{Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}</span>
<i>
<b style={{ width: `${Math.max(0, Math.min(100, (encounterHp.hp / encounterHp.maxHp) * 100))}%` }} />
</i>
</div>
{state.player.currentCast && (
<div className="boss-castbar boss-field-castbar">
<div>
<strong>{SPELLS[state.player.currentCast.spell].name}</strong>
<span>{state.player.currentCast.remaining.toFixed(1)}s</span>
</div>
<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>
<b
style={{
width: `${Math.max(0, Math.min(100, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
}}
/>
<b style={{ width: `${Math.max(0, Math.min(100, (encounterHp.hp / encounterHp.maxHp) * 100))}%` }} />
</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}
{state.player.currentCast && (
<div className="boss-castbar boss-field-castbar">
<div>
<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>
<b
style={{
width: `${Math.max(0, Math.min(100, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
}}
/>
</i>
</div>
)}
{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>
</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',
}
}
+60
View File
@@ -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.
})
}
+231
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+629
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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()],
})