From 257c34fc8dbfddc280dfa4d1aeeb8bd691944f89 Mon Sep 17 00:00:00 2001 From: Warren H Date: Sun, 28 Jun 2026 13:29:39 -0400 Subject: [PATCH] I Want To Heal 2 build v1.0.5 code --- .gitignore | 7 + AGENTS.md | 6 + Action Mode Documents/combat-mechanics.md | 10 + Action Mode Documents/implementation-notes.md | 4 +- README.md | 8 + admin.html | 12 + android/app/build.gradle | 4 +- docs/truenas-deploy.md | 11 +- eslint.config.js | 12 +- package-lock.json | 67 +- package.json | 6 +- scripts/deploy-gui.sh | 27 + scripts/deploy_gui.py | 562 +++ server/action-assets-admin.mjs | 692 ++++ server/index.mjs | 134 + src/App.tsx | 33 +- src/action3dCombatRender.ts | 983 +++++ src/action3dSceneKit.ts | 413 +++ src/actionApi.ts | 55 +- src/actionArena.ts | 364 ++ src/actionArenaBattle.ts | 1774 +++++++++ src/actionAssets.ts | 52 + src/actionBoss/BulldromeScene.ts | 153 +- ...imulation.ts => actionCombatSimulation.ts} | 1221 ++++--- src/actionBoss/actionEncounterConfig.ts | 235 +- src/actionBoss/actionEnemyRuntime.ts | 157 + src/actionClassKits.ts | 474 +++ src/actionCombatCore.ts | 760 ++++ src/actionCombatProfile.ts | 25 + src/actionCombatTypes.ts | 51 + src/actionMode.ts | 703 ++-- src/actionModelAssets.ts | 240 ++ src/admin/main.tsx | 26 + src/claudeCraftDungeonRuntime.ts | 336 ++ src/claudeCraftDungeons.ts | 181 + src/claudeCraftEquipmentRules.ts | 59 + src/claudeCraftItems.ts | 132 + src/claudeCraftTalents.ts | 519 +++ src/claudeCraftTypes.ts | 123 + src/components/ActionCharacterCreation.tsx | 429 +++ src/components/ActionCharacterRosterPanel.tsx | 57 + src/components/ActionEquipmentMenus.tsx | 907 +++++ src/components/ActionModeScreen.tsx | 995 +++-- src/components/ActionModelViewer.tsx | 540 +++ src/components/ArenaBattleSlice.tsx | 1095 ++++++ src/components/AuthScreen.tsx | 12 +- src/components/BulldromeBossSlice.tsx | 516 ++- src/components/CombatAudio.ts | 60 + src/components/CombatHud.tsx | 231 ++ src/components/CombatPauseMenu.tsx | 63 + src/components/ThreeActionScene.tsx | 629 ++++ src/styles.css | 3192 ++++++++++++++++- vite.admin.config.ts | 29 + 53 files changed, 18187 insertions(+), 1199 deletions(-) create mode 100644 admin.html create mode 100755 scripts/deploy-gui.sh create mode 100755 scripts/deploy_gui.py create mode 100644 server/action-assets-admin.mjs create mode 100644 src/action3dCombatRender.ts create mode 100644 src/action3dSceneKit.ts create mode 100644 src/actionArena.ts create mode 100644 src/actionArenaBattle.ts create mode 100644 src/actionAssets.ts rename src/actionBoss/{bulldromeSimulation.ts => actionCombatSimulation.ts} (54%) create mode 100644 src/actionBoss/actionEnemyRuntime.ts create mode 100644 src/actionClassKits.ts create mode 100644 src/actionCombatCore.ts create mode 100644 src/actionCombatProfile.ts create mode 100644 src/actionCombatTypes.ts create mode 100644 src/actionModelAssets.ts create mode 100644 src/admin/main.tsx create mode 100644 src/claudeCraftDungeonRuntime.ts create mode 100644 src/claudeCraftDungeons.ts create mode 100644 src/claudeCraftEquipmentRules.ts create mode 100644 src/claudeCraftItems.ts create mode 100644 src/claudeCraftTalents.ts create mode 100644 src/claudeCraftTypes.ts create mode 100644 src/components/ActionCharacterCreation.tsx create mode 100644 src/components/ActionCharacterRosterPanel.tsx create mode 100644 src/components/ActionEquipmentMenus.tsx create mode 100644 src/components/ActionModelViewer.tsx create mode 100644 src/components/ArenaBattleSlice.tsx create mode 100644 src/components/CombatAudio.ts create mode 100644 src/components/CombatHud.tsx create mode 100644 src/components/CombatPauseMenu.tsx create mode 100644 src/components/ThreeActionScene.tsx create mode 100644 vite.admin.config.ts diff --git a/.gitignore b/.gitignore index 85dc671..cb2528c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ node_modules dist +dist-admin .gradle .DS_Store *.local +*.apk +external +tmp-asset-tests +tmp-dragon-previews +__pycache__ +scripts/__pycache__ diff --git a/AGENTS.md b/AGENTS.md index ec134c4..fff443e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Action Mode Documents/combat-mechanics.md b/Action Mode Documents/combat-mechanics.md index c387418..37f6fd6 100644 --- a/Action Mode Documents/combat-mechanics.md +++ b/Action Mode Documents/combat-mechanics.md @@ -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: diff --git a/Action Mode Documents/implementation-notes.md b/Action Mode Documents/implementation-notes.md index 1cf0d3b..e41137c 100644 --- a/Action Mode Documents/implementation-notes.md +++ b/Action Mode Documents/implementation-notes.md @@ -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. diff --git a/README.md b/README.md index 307b4b4..0e0930e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..00571e4 --- /dev/null +++ b/admin.html @@ -0,0 +1,12 @@ + + + + + + Action Model Admin + + +
+ + + diff --git a/android/app/build.gradle b/android/app/build.gradle index 9357a9e..113cdf6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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. diff --git a/docs/truenas-deploy.md b/docs/truenas-deploy.md index d738557..a994898 100644 --- a/docs/truenas-deploy.md +++ b/docs/truenas-deploy.md @@ -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:@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. diff --git a/eslint.config.js b/eslint.config.js index 5e335df..d8a9725 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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}'], diff --git a/package-lock.json b/package-lock.json index b18aeea..8db3d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fb7f2f9..aa3c0e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/deploy-gui.sh b/scripts/deploy-gui.sh new file mode 100755 index 0000000..4a0d02c --- /dev/null +++ b/scripts/deploy-gui.sh @@ -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 diff --git a/scripts/deploy_gui.py b/scripts/deploy_gui.py new file mode 100755 index 0000000..efbf21a --- /dev/null +++ b/scripts/deploy_gui.py @@ -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() diff --git a/server/action-assets-admin.mjs b/server/action-assets-admin.mjs new file mode 100644 index 0000000..a62a591 --- /dev/null +++ b/server/action-assets-admin.mjs @@ -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 [ + ``, + ` `, + '', + '', + ].join('\n') +} + +function clampDimension(value, fallback) { + if (!Number.isFinite(value) || value < 1) return fallback + return Math.min(4096, Math.round(value)) +} + +async function readJson(request, maxSize) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > maxSize) throw statusError(400, 'Request body is too large.') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +async function servePublicAsset(response, pathname) { + const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '') + const filePath = join(publicDir, safePath) + if (!filePath.startsWith(publicDir) || !existsSync(filePath) || !statSync(filePath).isFile()) { + sendJson(response, 404, { error: 'not_found' }) + return + } + + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': mimeTypes[extname(filePath)] ?? 'application/octet-stream', + }) + createReadStream(filePath).pipe(response) +} + +function sendHtml(response, html) { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end(html) +} + +function sendJson(response, status, payload) { + response.writeHead(status, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }) + response.end(JSON.stringify(payload)) +} + +function statusError(status, message) { + const error = new Error(message) + error.status = status + return error +} + +function escapeXml(value) { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +function adminHtml() { + return ` + + + + + Action Asset Admin + + + +
+
+

Local Only

+

Action Asset Admin

+

Uploads write directly into public/action-assets. Next web or mobile build uses them automatically.

+
+

+ Open Game +
+
+
+ +
+
+

In-Game Preview

+

Dungeon Cards

+
+
+
+

Combat Preview

+
+
+
+
+ Boss Pack + 364 / 375 + +
+
+
+
+
+
+
+
+ + + +` +} diff --git a/server/index.mjs b/server/index.mjs index 9bbf5d7..484e771 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 980fb0a..43062f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 + if (!session?.account && !session?.offline) { + return ( + + ) } return ( ) } diff --git a/src/action3dCombatRender.ts b/src/action3dCombatRender.ts new file mode 100644 index 0000000..df87a97 --- /dev/null +++ b/src/action3dCombatRender.ts @@ -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 + clips: Map + currentAction: string + mixer: THREE.AnimationMixer +} + +type CombatAnimationRequest = string | string[] + +const combatModelLoader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder) +const combatModelCache = new Map>() +const combatWeaponCache = new Map>() +let kayKitMediumAnimationRequest: Promise | 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 & { 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(), + 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() + 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() + 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(), + 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, + 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() + 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, 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, 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, active: Set) { + for (const [id, object] of map) { + if (active.has(id)) continue + object.removeFromParent() + map.delete(id) + } +} diff --git a/src/action3dSceneKit.ts b/src/action3dSceneKit.ts new file mode 100644 index 0000000..d6456a0 --- /dev/null +++ b/src/action3dSceneKit.ts @@ -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, + 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, 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, 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, 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, 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(map: Map, active: Set) { + 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() + }) +} diff --git a/src/actionApi.ts b/src/actionApi.ts index bb83d30..bda6a34 100644 --- a/src/actionApi.ts +++ b/src/actionApi.ts @@ -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('/api/pvp/queue', { + body: JSON.stringify({ mode: 'arenas' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) +} + +export function checkArenaQueue(ticketId: string) { + return requestActionApiJson(`/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' } diff --git a/src/actionArena.ts b/src/actionArena.ts new file mode 100644 index 0000000..f05efa2 --- /dev/null +++ b/src/actionArena.ts @@ -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> = { + 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 +} diff --git a/src/actionArenaBattle.ts b/src/actionArenaBattle.ts new file mode 100644 index 0000000..6af8c4b --- /dev/null +++ b/src/actionArenaBattle.ts @@ -0,0 +1,1774 @@ +import { + type BossInput, + type BossPhase, + type Point, + type RaidFrame, + type Telegraph, +} from './actionCombatTypes' +import { + ACTION_CONTROL_RULES, + ACTION_MOVEMENT_RULES, + createSpellCooldowns, + addShield, + applyHealOverTimeTimers, + applyRenewTimers, + applyShieldedDamage, + dampenHealing, + getActiveHealOverTimeFrames, + getActionSpellDefinition, + getActionSpellManaCost, + getActionSpellReach, + getActionSpellTarget, + getActionTalentAdjustedAmount, + getHealOverTimeEffectId, + HEALING_SPELL_RULES, + recordSpellCast, + SPELL_GLOBAL_COOLDOWN_SECONDS, + spendActionSpellMana, + tickHealOverTimes, + tickSpellCooldowns, + tickSpellResource, + type CastState, + type HealOverTimeEffect, + type PartyRole, + type SpellResourceState, + type SpellSlot, +} from './actionCombatCore' +import type { TalentModifiers } from './claudeCraftTalents' +import { createEmptyTalentModifiers } from './actionCombatProfile' +import { + getArenaSpellCooldownMultiplier, + getArenaSpellCostMultiplier, + type ArenaUpgradeId, +} from './actionArena' +import { + ARENA_PVP_DEFAULT_TALENT_POINTS, + ARENA_PVP_EFFECTIVE_LEVEL, + createArenaAbilityCooldowns, + getArenaAbility, + getArenaClassVitals, + getArenaClassKit, + type ArenaAbilityId, + type ArenaAbilitySchool, + type ArenaClassId, +} from './actionClassKits' + +export type ArenaBattleTeamId = 'player' | 'opponent' +export type ArenaBattleResult = 'playing' | 'player' | 'opponent' +export type ArenaBossKind = + | 'deacon-varric' + | 'vael-the-mistcaller' + | 'ysolei' + | 'korgath-the-bound' + | 'grand-necromancer-velkhar' + | 'korzul-the-gravewyrm' + +export type ArenaBattleUnit = { + abilityCooldowns: Record + activeAbility: ArenaAbilityId | null + activeTargetId: string | null + activeAbilityTimer: number + currentAbilityCast: { + abilityId: ArenaAbilityId + remaining: number + targetId: string + total: number + } | null + damageDone: number + globalCooldown: number + classId: ArenaClassId + id: string + name: string + role: PartyRole + teamId: ArenaBattleTeamId + x: number + y: number + radius: number + hp: number + maxHp: number + shield: number + stunTimer: number + invulnerableTimer: number + healOverTimes?: HealOverTimeEffect[] + renewTimer: number + renewTickTimer: number + attackTimer: number + aiSlot: number +} + +export type ArenaBossState = { + activeAction: 'idle' | 'melee' | 'run' | 'spell' | 'stomp' + activeActionTimer: number + id: string + name: string + teamId: ArenaBattleTeamId + kind: ArenaBossKind + x: number + y: number + radius: number + hp: number + maxHp: number + phase: BossPhase + phaseTimer: number + chargeVector: Point + targetId: string + chargeCount: number + tankSwingTimer: number + telegraph: Telegraph + slamTelegraph: { + active: boolean + radius: number + } + mechanics: { + addWaves: Array<{ attackTimer: number, count: number, name: string, remaining: number }> + enraged: boolean + firedSummons: number + pulseTimer: number + stompCasting: boolean + stompCastTimer: number + stompTimer: number + } + spawnCount: number +} + +export type ArenaWallHazard = { + id: string + x: number + y: number + width: number + height: number + remaining: number + warning: number +} + +export type ArenaBirdHazard = { + id: string + x: number + y: number + vx: number + radius: number + remaining: number + warning: number +} + +export type ArenaBattleState = { + round: number + arena: { + width: number + height: number + padding: number + } + player: ArenaBattleUnit & { + currentCast: CastState | null + facingX: number + facingY: number + globalCooldown: number + spellCooldowns: Record + talentModifiers: TalentModifiers + } & SpellResourceState + playerParty: ArenaBattleUnit[] + opponents: ArenaBattleUnit[] + opponentHealerResource: SpellResourceState + pvpEffectiveLevel: number + pvpDefaultTalentPoints: number + playerBoss: ArenaBossState + opponentBoss: ArenaBossState + playerBossKills: number + opponentBossKills: number + dampeningPercent: number + targetId: string + playerUpgrades: ArenaUpgradeId[] + opponentUpgrades: ArenaUpgradeId[] + walls: ArenaWallHazard[] + birds: ArenaBirdHazard[] + nextHazardId: number + wallTimer: number + birdTimer: number + healEvents: Array<{ id: string, targetId: string, amount: number }> + damageEvents: Array<{ id: string, targetId: string, amount: number, teamId: ArenaBattleTeamId }> + abilityEvents: Array<{ + id: string + abilityId: ArenaAbilityId + casterId: string + school: ArenaAbilitySchool + targetId: string + teamId: ArenaBattleTeamId + }> + noticeEvents: Array<{ id: string, targetId: string, text: string }> + elapsed: number + message: string + result: ArenaBattleResult + lastHit: 'player' | 'party' | 'opponent' | 'hazard' | 'heal' | null +} + +export type ArenaBattleSnapshot = { + playerFrames: RaidFrame[] + opponentFrames: RaidFrame[] +} + +const ARENA_WIDTH = 1680 +const ARENA_HEIGHT = 945 +const ARENA_SIZE_MULTIPLIER = 1.75 +const ARENA_SIDE_GAP = 102 +const ARENA_TANK_CLASS_POOL: ArenaClassId[] = ['warrior_protection', 'paladin_protection', 'druid_feral_tank'] +const ARENA_HEALER_CLASS_POOL: ArenaClassId[] = ['priest_holy', 'priest_discipline', 'paladin_holy', 'druid_restoration', 'shaman_restoration'] +const ARENA_DPS_CLASS_POOL: ArenaClassId[] = [ + 'priest_shadow', + 'warrior_arms', + 'warrior_fury', + 'paladin_retribution', + '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_elemental', + 'shaman_enhancement', + 'warlock_affliction', + 'warlock_demonology', + 'warlock_destruction', +] +const BOSS_STOMP_CAST_SECONDS = 1.15 +const AI_GLOBAL_COOLDOWN_SECONDS = SPELL_GLOBAL_COOLDOWN_SECONDS +const AI_ABILITY_MANA_COSTS: Partial> = { + barkskin: 90, + blessing_of_might: 70, + flash_heal: 75, + flash_of_light: 75, + healing_touch: 130, + healing_wave: 125, + heal: 130, + holy_light: 130, + lay_on_hands: 220, + lesser_heal: 65, + lightning_shield: 90, + power_word_fortitude: 80, + power_word_shield: 100, + regrowth: 95, + rejuvenation: 75, + renew: 75, +} +const BOSS_SLAM_RADIUS = 142 +const BOSS_DRIFT_SPEED = 82 +const CLAUDECRAFT_ELITE_HP_MULTIPLIER = 2.3 +const CLAUDECRAFT_ELITE_DAMAGE_MULTIPLIER = 1.5 +const ARENA_BOSS_SEQUENCE: Array<{ + attackSpeed: number + dmgBase: number + dmgPerLevel: number + hpBase: number + hpPerLevel: number + kind: ArenaBossKind + level: number + name: string + radius: number + mechanics: ArenaBossMechanicTemplate +}> = [ + { + attackSpeed: 2.2, + dmgBase: 8, + dmgPerLevel: 2.4, + hpBase: 100, + hpPerLevel: 22, + kind: 'deacon-varric', + level: 9, + name: 'Deacon Varric', + radius: 36, + mechanics: { + stomp: { radius: 170, every: 12, duration: 0.8, min: 14, max: 22, name: 'Bell Toll' }, + summonAdds: { count: 2, name: 'Funeral Ringers', atHpPct: [0.6, 0.3] }, + enrage: { belowHpPct: 0.2, dmgMult: 1.4, hasteMult: 1.2 }, + }, + }, + { + attackSpeed: 2.4, + dmgBase: 12, + dmgPerLevel: 2.6, + hpBase: 240, + hpPerLevel: 34, + kind: 'vael-the-mistcaller', + level: 13, + name: 'Vael the Mistcaller', + radius: 34, + mechanics: { + aoePulse: { min: 16, max: 24, radius: 210, every: 10, name: 'Mist Surge', school: 'frost' }, + summonAdds: { count: 2, name: 'Drowned Thralls', atHpPct: [0.6, 0.3] }, + }, + }, + { + attackSpeed: 2.5, + dmgBase: 14, + dmgPerLevel: 2.9, + hpBase: 300, + hpPerLevel: 38, + kind: 'ysolei', + level: 18, + name: 'Ysolei, Avatar of the Drowned Moon', + radius: 42, + mechanics: { + aoePulse: { min: 22, max: 32, radius: 230, every: 9, name: 'Lunar Tide', school: 'arcane' }, + summonAdds: { count: 2, name: 'Moonspawn', atHpPct: [0.6, 0.3] }, + enrage: { belowHpPct: 0.3, dmgMult: 1.4, hasteMult: 1.3 }, + }, + }, + { + attackSpeed: 2.8, + dmgBase: 14, + dmgPerLevel: 2.9, + hpBase: 260, + hpPerLevel: 36, + kind: 'korgath-the-bound', + level: 20, + name: 'Korgath the Bound', + radius: 40, + mechanics: { + stomp: { radius: 190, every: 12, duration: 1.5, min: 20, max: 30, name: 'War Stomp' }, + enrage: { belowHpPct: 0.3, dmgMult: 1.5, hasteMult: 1.3 }, + }, + }, + { + attackSpeed: 2, + dmgBase: 13, + dmgPerLevel: 2.8, + hpBase: 230, + hpPerLevel: 33, + kind: 'grand-necromancer-velkhar', + level: 20, + name: 'Grand Necromancer Velkhar', + radius: 34, + mechanics: { + summonAdds: { count: 3, name: 'Raised Bonewalkers', atHpPct: [0.66, 0.33] }, + }, + }, + { + attackSpeed: 2.6, + dmgBase: 15, + dmgPerLevel: 3, + hpBase: 420, + hpPerLevel: 48, + kind: 'korzul-the-gravewyrm', + level: 20, + name: 'Korzul the Gravewyrm', + radius: 48, + mechanics: { + aoePulse: { min: 30, max: 42, radius: 250, every: 8, name: 'Necrotic Shockwave', school: 'shadow' }, + enrage: { belowHpPct: 0.3, dmgMult: 1.5, hasteMult: 1.3 }, + }, + }, +] + +type ArenaBossMechanicTemplate = { + aoePulse?: { + every: number + max: number + min: number + name: string + radius: number + school: ArenaAbilitySchool + } + enrage?: { belowHpPct: number, dmgMult: number, hasteMult: number } + stomp?: { + duration: number + every: number + max: number + min: number + name: string + radius: number + } + summonAdds?: { atHpPct: number[], count: number, name: string } +} + +export function createArenaBattleState( + round: number, + playerUpgrades: ArenaUpgradeId[], + opponentUpgrades: ArenaUpgradeId[], + playerClassId: ArenaClassId = 'priest', + playerTalentModifiers?: TalentModifiers, +): ArenaBattleState { + const playerParty = createArenaTeamUnits(round, 'player') + const opponents = createArenaTeamUnits(round, 'opponent') + const opponentHealer = opponents.find((unit) => unit.role === 'healer') ?? opponents[0] + + return { + round, + arena: { + width: ARENA_WIDTH, + height: ARENA_HEIGHT, + padding: 34, + }, + player: { + ...createUnit('player', 'You', playerClassId, 'player', 100, scaleArenaX(360), scaleArenaY(302), 0), + currentCast: null, + facingX: 1, + facingY: 0, + globalCooldown: 0, + spellCooldowns: createSpellCooldowns(), + talentModifiers: playerTalentModifiers ?? createEmptyTalentModifiers(), + ...createArenaSpellResourceState(playerClassId), + }, + playerParty: [ + ...playerParty, + ], + opponents, + opponentHealerResource: createArenaSpellResourceState(opponentHealer.classId), + pvpEffectiveLevel: ARENA_PVP_EFFECTIVE_LEVEL, + pvpDefaultTalentPoints: ARENA_PVP_DEFAULT_TALENT_POINTS, + playerBoss: createBoss('player', 1), + opponentBoss: createBoss('opponent', 1), + playerBossKills: 0, + opponentBossKills: 0, + dampeningPercent: 0, + targetId: 'player-boss-1', + playerUpgrades, + opponentUpgrades, + walls: [], + birds: [], + nextHazardId: 1, + wallTimer: 3.2, + birdTimer: 2.1, + healEvents: [], + damageEvents: [], + abilityEvents: [], + noticeEvents: [], + elapsed: 0, + message: `PvP Roguelike round ${round}. Kill bosses until one party falls.`, + result: 'playing', + lastHit: null, + } +} + +function createArenaTeamUnits(round: number, teamId: ArenaBattleTeamId): ArenaBattleUnit[] { + const { dpsClasses, tankClass } = createArenaSharedLineup(round) + + if (teamId === 'player') { + return [ + createUnit('player-tank', getArenaUnitName(tankClass), tankClass, 'player', 142, scaleArenaX(294), scaleArenaY(270), 0), + createUnit('player-melee-1', getArenaUnitName(dpsClasses[0]), dpsClasses[0], 'player', 98, scaleArenaX(314), scaleArenaY(326), 1), + createUnit('player-ranged-1', getArenaUnitName(dpsClasses[1]), dpsClasses[1], 'player', 92, scaleArenaX(376), scaleArenaY(226), 2), + createUnit('player-ranged-2', getArenaUnitName(dpsClasses[2]), dpsClasses[2], 'player', 100, scaleArenaX(376), scaleArenaY(374), 3), + ] + } + + const healerClass = pickRandom(ARENA_HEALER_CLASS_POOL, createSeededRng(`roguelike-${round}-opponent-healer`)) + return [ + createUnit('opponent-healer', `Rival ${getArenaUnitName(healerClass)}`, healerClass, 'opponent', 100, scaleArenaX(600), scaleArenaY(302), 0), + createUnit('opponent-tank', `Rival ${getArenaUnitName(tankClass)}`, tankClass, 'opponent', 142, scaleArenaX(666), scaleArenaY(270), 1), + createUnit('opponent-melee-1', `Rival ${getArenaUnitName(dpsClasses[0])}`, dpsClasses[0], 'opponent', 98, scaleArenaX(646), scaleArenaY(326), 2), + createUnit('opponent-ranged-1', `Rival ${getArenaUnitName(dpsClasses[1])}`, dpsClasses[1], 'opponent', 92, scaleArenaX(584), scaleArenaY(226), 3), + createUnit('opponent-ranged-2', `Rival ${getArenaUnitName(dpsClasses[2])}`, dpsClasses[2], 'opponent', 100, scaleArenaX(584), scaleArenaY(374), 4), + ] +} + +function createArenaSharedLineup(round: number) { + const rng = createSeededRng(`roguelike-${round}-shared-lineup`) + return { + tankClass: pickRandom(ARENA_TANK_CLASS_POOL, rng), + dpsClasses: [ + pickRandom(ARENA_DPS_CLASS_POOL, rng), + pickRandom(ARENA_DPS_CLASS_POOL, rng), + pickRandom(ARENA_DPS_CLASS_POOL, rng), + ] as [ArenaClassId, ArenaClassId, ArenaClassId], + } +} + +function getArenaUnitName(classId: ArenaClassId) { + const kit = getArenaClassKit(classId) + return `${kit.displayName} (${kit.defaultPvpSpec})` +} + +function pickRandom(items: T[], rng: () => number) { + return items[Math.floor(rng() * items.length) % items.length] +} + +function createSeededRng(seed: string) { + let state = 2166136261 + for (let i = 0; i < seed.length; i += 1) { + state ^= seed.charCodeAt(i) + state = Math.imul(state, 16777619) + } + return () => { + state += 0x6d2b79f5 + let next = state + next = Math.imul(next ^ (next >>> 15), next | 1) + next ^= next + Math.imul(next ^ (next >>> 7), next | 61) + return ((next ^ (next >>> 14)) >>> 0) / 4294967296 + } +} + +export function updateArenaBattleState( + state: ArenaBattleState, + input: BossInput, + deltaSeconds: number, +): ArenaBattleState { + if (input.reset) return createArenaBattleState(state.round, state.playerUpgrades, state.opponentUpgrades, state.player.classId, state.player.talentModifiers) + + const next = cloneArenaBattleState(state) + next.elapsed += deltaSeconds + next.dampeningPercent = Math.floor(next.elapsed / 5) + next.lastHit = null + next.healEvents = [] + next.damageEvents = [] + next.abilityEvents = [] + next.noticeEvents = [] + + if (input.targetId) setTarget(next, input.targetId) + if (input.targetDelta !== 0) cycleTarget(next, input.targetDelta) + if (next.result !== 'playing') return next + + tickUnit(next, next.player, deltaSeconds) + tickPlayerGlobalCooldown(next, deltaSeconds) + tickCooldowns(next.player.spellCooldowns, deltaSeconds) + tickPlayerMana(next, deltaSeconds) + tickSpellResource(next.opponentHealerResource, deltaSeconds) + tickCurrentCast(next, deltaSeconds) + for (const unit of [...next.playerParty, ...next.opponents]) tickUnit(next, unit, deltaSeconds) + + movePlayer(next, input, deltaSeconds) + updateTeamAi(next, 'player', deltaSeconds) + updateTeamAi(next, 'opponent', deltaSeconds) + updateBoss(next, next.playerBoss, [next.player, ...next.playerParty], deltaSeconds) + updateBoss(next, next.opponentBoss, next.opponents, deltaSeconds) + resolveBossContact(next, next.playerBoss, [next.player, ...next.playerParty]) + resolveBossContact(next, next.opponentBoss, next.opponents) + resolveBossRespawns(next) + startPlayerSpell(next, input.castSpell) + resolveResult(next) + + return next +} + +export function getArenaBattleFrames(state: ArenaBattleState): ArenaBattleSnapshot { + return { + playerFrames: [state.player, ...state.playerParty].map((unit) => unitToFrame(state, unit)), + opponentFrames: state.opponents.map((unit) => unitToFrame(state, unit)), + } +} + +export function getArenaBattleUnits(state: ArenaBattleState): ArenaBattleUnit[] { + return [state.player, ...state.playerParty, ...state.opponents] +} + +export function getArenaBattleBosses(state: ArenaBattleState): ArenaBossState[] { + return [state.playerBoss, state.opponentBoss] +} + +export function getLivingArenaUnits(state: ArenaBattleState, teamId: ArenaBattleTeamId) { + return getArenaBattleUnits(state).filter((unit) => unit.teamId === teamId && unit.hp > 0) +} + +export function getArenaBattleSpellManaCost(upgrades: ArenaUpgradeId[], spell: SpellSlot, storedMomentumReady = false, classId: ArenaClassId = 'priest', talentModifiers?: TalentModifiers) { + return getActionSpellManaCost(classId, spell, { + costMultiplier: getArenaSpellCostMultiplier(upgrades, spell), + freeCast: storedMomentumReady, + talentMods: talentModifiers, + }) +} + +function tickPlayerMana(state: ArenaBattleState, deltaSeconds: number) { + tickSpellResource(state.player, deltaSeconds) +} + +function spendPlayerMana(state: ArenaBattleState, spell: SpellSlot) { + const spend = spendActionSpellMana(state.player, state.player.classId, spell, { + costMultiplier: getArenaSpellCostMultiplier(state.playerUpgrades, spell), + talentMods: state.player.talentModifiers, + }) + if (!spend.ok) { + state.message = `Need ${spend.cost} mana.` + showNotice(state, state.player.id, 'Mana') + return false + } + + return true +} + +function recordPlayerSpellCast(state: ArenaBattleState) { + recordSpellCast(state.player, { storedMomentum: state.playerUpgrades.includes('stored-momentum') }) +} + +function movePlayer(state: ArenaBattleState, input: BossInput, deltaSeconds: number) { + if (state.player.stunTimer > 0) return + + const length = Math.hypot(input.xAxis, input.yAxis) + if (length <= 0.01) return + + state.player.facingX = input.xAxis / length + state.player.facingY = input.yAxis / length + moveUnitWithCollision( + state, + state.player, + state.player.facingX * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds, + state.player.facingY * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds, + ) + state.player.x = Math.min(getPlayerSideMaxX(state) - state.player.radius, state.player.x) +} + +function updateTeamAi(state: ArenaBattleState, teamId: ArenaBattleTeamId, deltaSeconds: number) { + const allies = teamId === 'player' ? state.playerParty : state.opponents + const boss = teamId === 'player' ? state.playerBoss : state.opponentBoss + for (const unit of allies) { + if (unit.hp <= 0 || unit.stunTimer > 0) continue + if (unit.currentAbilityCast) continue + const kit = getArenaClassKit(unit.classId) + const targetPoint = boss.hp > 0 ? boss : findLowestFriendlyTarget(state, teamId, unit) + if (!targetPoint) continue + + if (boss.hp <= 0) { + if (unit.role === 'healer') castAiPriestHeal(state, teamId, unit) + continue + } + const dx = targetPoint.x - unit.x + const dy = targetPoint.y - unit.y + const length = Math.max(1, Math.hypot(dx, dy)) + const attackRange = getArenaUnitAttackRange(unit) + if (length > attackRange || !hasLineOfSight(state, unit, boss)) { + const speed = unit.role === 'tank' ? 116 : 132 + moveUnitWithCollision(state, unit, (dx / length) * speed * deltaSeconds, (dy / length) * speed * deltaSeconds) + } + constrainToSide(unit, teamId) + + unit.attackTimer -= deltaSeconds + if (unit.attackTimer > 0) continue + if (unit.globalCooldown > 0) continue + unit.attackTimer += kit.attackSeconds + + if (unit.role === 'healer') { + if (castAiPriestHeal(state, teamId, unit)) continue + } + + const abilityId = chooseAiDamageAbility(unit) + if (!abilityId) continue + const ability = getArenaAbility(abilityId) + if (boss.hp > 0 && distance(unit, boss) <= ability.range + boss.radius && hasLineOfSight(state, unit, boss)) { + applyAiAbilityToBoss(state, teamId, unit, abilityId) + state.lastHit = teamId === 'player' ? 'opponent' : 'party' + if (boss.hp <= 0) state.message = `${unit.name} defeated ${boss.name}.` + } + } +} + +function updateBoss(state: ArenaBattleState, boss: ArenaBossState, party: ArenaBattleUnit[], deltaSeconds: number) { + if (boss.hp <= 0) return + tickBossAction(boss, deltaSeconds) + updateClaudeCraftBossMechanics(state, boss, party, deltaSeconds) + if (boss.phase === 'slamWindup') return + + boss.phaseTimer -= deltaSeconds + + if (boss.phase === 'tracking') { + boss.targetId = getBossTankTargetId(boss, party) + const target = findBossTarget(party, boss.targetId) + if (target) driftBossTowardTarget(state, boss, target, deltaSeconds) + boss.telegraph.active = false + boss.slamTelegraph.active = false + state.message = target ? `${boss.name} tracks ${target.name}.` : `${boss.name} tracks.` + if (boss.phaseTimer <= 0) startBossTankFocus(boss) + return + } + + if (boss.phase === 'mauling') { + boss.telegraph.active = false + boss.slamTelegraph.active = false + boss.targetId = getBossTankTargetId(boss, party) + const target = findBossTarget(party, boss.targetId) + if (target) { + driftBossTowardTarget(state, boss, target, deltaSeconds) + boss.tankSwingTimer -= deltaSeconds + state.message = `${boss.name} attacks ${target.name}.` + if (boss.tankSwingTimer <= 0) { + applyDamage(target, getBossTankSwingDamage(boss)) + playBossAction(boss, 'melee', 0.75) + state.abilityEvents.push(createBossAbilityEvent(state, boss, target.id, 'physical')) + target.invulnerableTimer = 0.12 + state.lastHit = boss.teamId === 'player' ? (target.id === 'player' ? 'player' : 'party') : 'opponent' + state.message = `${boss.name} hit ${target.name}.` + boss.tankSwingTimer += getBossTankSwingSeconds(boss) + } + } + if (boss.phaseTimer <= 0) boss.phaseTimer = getBossTankFocusSeconds(boss) + return + } + + startBossTankFocus(boss) +} + +function updateClaudeCraftBossMechanics( + state: ArenaBattleState, + boss: ArenaBossState, + party: ArenaBattleUnit[], + deltaSeconds: number, +) { + const template = getArenaBossTemplateByKind(boss.kind) + const mechanics = template.mechanics + const hpFrac = boss.hp / Math.max(1, boss.maxHp) + + if (mechanics.enrage && !boss.mechanics.enraged && hpFrac <= mechanics.enrage.belowHpPct) { + boss.mechanics.enraged = true + playBossAction(boss, 'spell', 0.9) + state.abilityEvents.push(createBossAbilityEvent(state, boss, boss.id, 'fire')) + state.message = `${boss.name} becomes enraged.` + } + + if (mechanics.summonAdds) { + const thresholds = mechanics.summonAdds.atHpPct + while (boss.mechanics.firedSummons < thresholds.length && hpFrac <= thresholds[boss.mechanics.firedSummons]) { + boss.mechanics.firedSummons += 1 + boss.mechanics.addWaves.push({ + attackTimer: 0.6, + count: mechanics.summonAdds.count, + name: mechanics.summonAdds.name, + remaining: 10, + }) + playBossAction(boss, 'spell', 0.9) + state.abilityEvents.push(createBossAbilityEvent(state, boss, boss.id, 'shadow')) + state.message = `${boss.name} summons ${mechanics.summonAdds.name}.` + } + } + + for (const wave of boss.mechanics.addWaves) { + wave.remaining = Math.max(0, wave.remaining - deltaSeconds) + wave.attackTimer -= deltaSeconds + if (wave.attackTimer > 0) continue + wave.attackTimer += 1.45 + const target = selectBossMechanicTarget(party, boss, false) + if (!target) continue + applyDamage(target, wave.count * 5 * getBossLevelScale(boss) * getBossDamageMultiplier(boss)) + playBossAction(boss, 'spell', 0.55) + target.invulnerableTimer = Math.max(target.invulnerableTimer, 0.12) + state.lastHit = boss.teamId === 'player' ? (target.id === 'player' ? 'player' : 'party') : 'opponent' + state.abilityEvents.push(createBossAbilityEvent(state, boss, target.id, 'shadow')) + state.message = `${wave.name} hit ${target.name}.` + } + boss.mechanics.addWaves = boss.mechanics.addWaves.filter((wave) => wave.remaining > 0) + + if (mechanics.aoePulse) { + boss.mechanics.pulseTimer -= deltaSeconds + if (boss.mechanics.pulseTimer <= 0) { + boss.mechanics.pulseTimer = mechanics.aoePulse.every / getBossHasteMultiplier(boss) + resolveBossAoePulse(state, boss, party, mechanics.aoePulse) + } + } + + if (!mechanics.stomp) return + if (boss.mechanics.stompCasting) { + boss.mechanics.stompCastTimer -= deltaSeconds + boss.slamTelegraph.active = true + boss.slamTelegraph.radius = mechanics.stomp.radius + boss.phase = 'slamWindup' + boss.phaseTimer = boss.mechanics.stompCastTimer + if (boss.mechanics.stompCastTimer > 0) return + boss.mechanics.stompCasting = false + boss.mechanics.stompTimer = mechanics.stomp.every / getBossHasteMultiplier(boss) + resolveBossStomp(state, boss, party) + boss.slamTelegraph.active = false + startBossTankFocus(boss) + return + } + + boss.mechanics.stompTimer -= deltaSeconds + if (boss.mechanics.stompTimer <= 0) { + boss.mechanics.stompCasting = true + boss.mechanics.stompCastTimer = BOSS_STOMP_CAST_SECONDS + playBossAction(boss, 'stomp', BOSS_STOMP_CAST_SECONDS + 0.35) + boss.phase = 'slamWindup' + boss.phaseTimer = BOSS_STOMP_CAST_SECONDS + boss.slamTelegraph.active = true + boss.slamTelegraph.radius = mechanics.stomp.radius + state.message = `${boss.name} prepares ${mechanics.stomp.name}.` + } +} + +function resolveBossRespawns(state: ArenaBattleState) { + const playerDown = state.playerBoss.hp <= 0 + const opponentDown = state.opponentBoss.hp <= 0 + if (!playerDown && !opponentDown) return + + if (playerDown) markBossDefeated(state.playerBoss) + if (opponentDown) markBossDefeated(state.opponentBoss) + + if (playerDown && opponentDown) { + const wasTargetingPlayerBoss = state.targetId === state.playerBoss.id + state.playerBossKills += 1 + state.opponentBossKills += 1 + state.playerBoss = createBoss('player', state.playerBossKills + 1) + state.opponentBoss = createBoss('opponent', state.opponentBossKills + 1) + if (wasTargetingPlayerBoss) state.targetId = state.playerBoss.id + state.message = `Both bosses down. Boss ${state.playerBossKills + 1} spawned.` + } else if (playerDown) { + state.message = `Your boss is down. Waiting for rival side.` + } else { + state.message = `Rival boss is down. Finish your boss.` + } +} + +function markBossDefeated(boss: ArenaBossState) { + boss.phase = 'defeated' + boss.telegraph.active = false + boss.slamTelegraph.active = false +} + +function getBossTankTargetId(boss: ArenaBossState, party: ArenaBattleUnit[]) { + const tank = party.find((unit) => unit.role === 'tank' && unit.hp > 0) + return tank?.id ?? selectBossChargeTarget(boss, party) +} + +function findBossTarget(party: ArenaBattleUnit[], id: string) { + return party.find((unit) => unit.id === id && unit.hp > 0) ?? null +} + +function driftBossTowardTarget(state: ArenaBattleState, boss: ArenaBossState, target: ArenaBattleUnit, deltaSeconds: number) { + const dx = target.x - boss.x + const dy = target.y - boss.y + const length = Math.hypot(dx, dy) + if (length <= 1) return + + boss.chargeVector = { x: dx / length, y: dy / length } + boss.x += (dx / length) * BOSS_DRIFT_SPEED * deltaSeconds + boss.y += (dy / length) * BOSS_DRIFT_SPEED * deltaSeconds + if (boss.activeActionTimer <= 0) boss.activeAction = 'run' + constrainBossToSide(state, boss) +} + +function tickBossAction(boss: ArenaBossState, deltaSeconds: number) { + boss.activeActionTimer = Math.max(0, boss.activeActionTimer - deltaSeconds) + if (boss.activeActionTimer <= 0) boss.activeAction = 'idle' +} + +function playBossAction(boss: ArenaBossState, action: ArenaBossState['activeAction'], seconds: number) { + boss.activeAction = action + boss.activeActionTimer = Math.max(boss.activeActionTimer, seconds) +} + +function startBossTankFocus(boss: ArenaBossState) { + boss.phase = 'mauling' + boss.phaseTimer = getBossTankFocusSeconds(boss) + boss.tankSwingTimer = getBossTankSwingSeconds(boss) + boss.telegraph.active = false + boss.slamTelegraph.active = false +} + +function selectBossChargeTarget(boss: ArenaBossState, party: ArenaBattleUnit[]) { + const living = party.filter((unit) => unit.hp > 0) + const preferred = boss.teamId === 'player' + ? ['player', 'player-tank', 'player-melee-1', 'player-ranged-1', 'player-ranged-2'] + : ['opponent-healer', 'opponent-tank', 'opponent-melee-1', 'opponent-ranged-1', 'opponent-ranged-2'] + const offset = boss.chargeCount % preferred.length + const ordered = preferred.slice(offset).concat(preferred.slice(0, offset)) + return ordered.find((id) => living.some((unit) => unit.id === id)) ?? living[0]?.id ?? '' +} + +function resolveBossStomp(state: ArenaBattleState, boss: ArenaBossState, party: ArenaBattleUnit[]) { + const slam = getArenaBossTemplateByKind(boss.kind).mechanics.stomp + if (!slam) return + + let hitCount = 0 + for (const unit of party) { + if (unit.hp <= 0 || distance(unit, boss) > slam.radius) continue + applyDamage(unit, randomRange(slam.min, slam.max) * getBossLevelScale(boss) * getBossDamageMultiplier(boss)) + unit.stunTimer = slam.duration + unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds + hitCount += 1 + } + state.lastHit = hitCount > 0 ? (boss.teamId === 'player' ? 'party' : 'opponent') : null + state.abilityEvents.push(createBossAbilityEvent(state, boss, boss.id, 'physical')) + state.message = hitCount > 0 ? `${slam.name} hit ${hitCount}.` : `${slam.name} missed.` +} + +function resolveBossContact(state: ArenaBattleState, boss: ArenaBossState, party: ArenaBattleUnit[]) { + if (boss.hp <= 0) return + + for (const unit of party) { + const overlap = unit.hp > 0 && distance(unit, boss) < unit.radius + boss.radius + if (!overlap || unit.invulnerableTimer > 0) continue + + const isCharge = boss.phase === 'charging' + applyDamage(unit, isCharge ? getBossChargeDamage(boss) : getBossBodyDamage(boss)) + unit.stunTimer = isCharge ? ACTION_CONTROL_RULES.stunSeconds : 0.25 + unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds + state.lastHit = boss.teamId === 'player' ? (unit.id === 'player' ? 'player' : 'party') : 'opponent' + state.message = isCharge + ? `${unit.name} ate ${boss.name}'s charge.` + : `${unit.name} was trampled.` + + const dx = unit.x - boss.x + const dy = unit.y - boss.y + const length = Math.max(1, Math.hypot(dx, dy)) + unit.x += (dx / length) * 34 + unit.y += (dy / length) * 34 + clampToArena(state, unit) + constrainToSide(unit, unit.teamId) + } +} + +function getBossTankFocusSeconds(boss: ArenaBossState) { + return 2.6 / getBossHasteMultiplier(boss) +} + +function getBossTankSwingSeconds(boss: ArenaBossState) { + return getArenaBossTemplateByKind(boss.kind).attackSpeed / getBossHasteMultiplier(boss) +} + +function getBossTankSwingDamage(boss: ArenaBossState) { + return getBossBaseDamage(boss) * getBossDamageMultiplier(boss) +} + +function getBossBodyDamage(boss: ArenaBossState) { + return Math.round(getBossBaseDamage(boss) * 0.65 * getBossDamageMultiplier(boss)) +} + +function getBossChargeDamage(boss: ArenaBossState) { + return Math.round(getBossBaseDamage(boss) * 1.2 * getBossDamageMultiplier(boss)) +} + +function getArenaBossTemplate(spawnCount: number) { + return ARENA_BOSS_SEQUENCE[(spawnCount - 1) % ARENA_BOSS_SEQUENCE.length] +} + +function getArenaBossTemplateByKind(kind: ArenaBossKind) { + return ARENA_BOSS_SEQUENCE.find((template) => template.kind === kind) ?? ARENA_BOSS_SEQUENCE[0] +} + +function getBossBaseDamage(boss: ArenaBossState) { + const template = getArenaBossTemplateByKind(boss.kind) + return Math.round( + (template.dmgBase + template.dmgPerLevel * (ARENA_PVP_EFFECTIVE_LEVEL - 1)) + * CLAUDECRAFT_ELITE_DAMAGE_MULTIPLIER, + ) +} + +function getBossDamageMultiplier(boss: ArenaBossState) { + const enrage = getArenaBossTemplateByKind(boss.kind).mechanics.enrage + return boss.mechanics.enraged ? enrage?.dmgMult ?? 1 : 1 +} + +function getBossHasteMultiplier(boss: ArenaBossState) { + const enrage = getArenaBossTemplateByKind(boss.kind).mechanics.enrage + return boss.mechanics.enraged ? enrage?.hasteMult ?? 1 : 1 +} + +function resolveBossAoePulse( + state: ArenaBattleState, + boss: ArenaBossState, + party: ArenaBattleUnit[], + pulse: NonNullable, +) { + let hitCount = 0 + for (const unit of party) { + if (unit.hp <= 0 || distance(unit, boss) > pulse.radius) continue + applyDamage(unit, randomRange(pulse.min, pulse.max) * getBossLevelScale(boss) * getBossDamageMultiplier(boss)) + unit.invulnerableTimer = Math.max(unit.invulnerableTimer, 0.12) + hitCount += 1 + } + state.abilityEvents.push(createBossAbilityEvent(state, boss, boss.id, pulse.school)) + playBossAction(boss, 'spell', 0.9) + state.lastHit = hitCount > 0 ? (boss.teamId === 'player' ? 'party' : 'opponent') : null + state.message = hitCount > 0 ? `${boss.name} used ${pulse.name}.` : `${pulse.name} missed.` +} + +function selectBossMechanicTarget(party: ArenaBattleUnit[], boss: ArenaBossState, preferTank: boolean) { + const living = party.filter((unit) => unit.hp > 0) + if (living.length === 0) return null + if (preferTank) return findBossTarget(party, boss.targetId) ?? living[0] + return living[(boss.chargeCount + boss.mechanics.firedSummons + living.length) % living.length] +} + +function createBossAbilityEvent( + state: ArenaBattleState, + boss: ArenaBossState, + targetId: string, + school: ArenaAbilitySchool, +) { + return { + abilityId: 'smite' as ArenaAbilityId, + casterId: boss.id, + id: `${state.elapsed.toFixed(3)}-${boss.id}-${school}-${state.abilityEvents.length}`, + school, + targetId, + teamId: boss.teamId, + } +} + +function randomRange(min: number, max: number) { + return Math.round(min + (max - min) * 0.5) +} + +function getBossLevelScale(boss: ArenaBossState) { + return ARENA_PVP_EFFECTIVE_LEVEL / Math.max(1, getArenaBossTemplateByKind(boss.kind).level) +} + +function createArenaSpellResourceState(classId: ArenaClassId): SpellResourceState { + const vitals = getArenaClassVitals(classId) + return { + mana: vitals.maxMana, + maxMana: vitals.maxMana, + storedMomentumCasts: 0, + storedMomentumReady: false, + } +} + +function castAiPriestHeal(state: ArenaBattleState, teamId: ArenaBattleTeamId, healer: ArenaBattleUnit) { + if (healer.currentAbilityCast || healer.globalCooldown > 0) return false + const target = findLowestFriendlyTarget(state, teamId, healer) + if (!target) return false + const healthRatio = target.hp / target.maxHp + const kit = getArenaClassKit(healer.classId) + const shieldId = kit.actionBar.find((id) => getArenaAbility(id).kind === 'shield' && getArenaAbility(id).range > 0) + if (healthRatio < 0.48 && shieldId && canUnitUseAbility(healer, shieldId) && canAffordAiAbility(state, healer, shieldId) && distance(healer, target) <= getArenaAbility(shieldId).range) { + return startAiAbility(state, healer, target.id, shieldId) + } + + const healIds = kit.actionBar.filter((id) => getArenaAbility(id).kind === 'heal' && canAffordAiAbility(state, healer, id)) + const instantHeal = healIds.find((id) => getArenaAbility(id).castTime === 0) + const fastHeal = healIds.find((id) => getArenaAbility(id).castTime <= 1.5) + const bigHeal = healIds.find((id) => getArenaAbility(id).heal && (getArenaAbility(id).heal ?? 0) >= 200) + const healId = healthRatio < 0.42 ? fastHeal ?? bigHeal ?? healIds[0] : healthRatio < 0.62 ? bigHeal ?? healIds[0] : instantHeal ?? healIds[0] + if (!healId) return false + const ability = getArenaAbility(healId) + if (!canUnitUseAbility(healer, healId) || distance(healer, target) > ability.range || !hasLineOfSight(state, healer, target)) return false + + return startAiAbility(state, healer, target.id, healId) +} + +function findLowestFriendlyTarget(state: ArenaBattleState, teamId: ArenaBattleTeamId, source: ArenaBattleUnit) { + const allies = teamId === 'player' ? [state.player, ...state.playerParty] : state.opponents + return allies + .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && hasLineOfSight(state, source, unit)) + .sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0] ?? null +} + +function chooseAiDamageAbility(unit: ArenaBattleUnit): ArenaAbilityId | null { + const kit = getArenaClassKit(unit.classId) + const ready = (id: ArenaAbilityId) => canUnitUseAbility(unit, id) + for (const id of kit.actionBar) { + const ability = getArenaAbility(id) + if ((ability.kind === 'damage' || ability.kind === 'attack') && ready(id)) return id + } + return ready(kit.basicAttack) ? kit.basicAttack : null +} + +function applyAiAbilityToBoss(state: ArenaBattleState, teamId: ArenaBattleTeamId, unit: ArenaBattleUnit, abilityId: ArenaAbilityId) { + const boss = teamId === 'player' ? state.playerBoss : state.opponentBoss + startAiAbility(state, unit, boss.id, abilityId) +} + +function getAiAbilityManaCost(abilityId: ArenaAbilityId) { + if (AI_ABILITY_MANA_COSTS[abilityId] !== undefined) return AI_ABILITY_MANA_COSTS[abilityId] + const ability = getArenaAbility(abilityId) + if (ability.kind === 'heal') return Math.max(50, Math.round((ability.heal ?? 120) * 0.52)) + if (ability.kind === 'shield') return Math.max(60, Math.round((ability.shield ?? 120) * 0.72)) + if (ability.animation === 'spell') return Math.max(45, Math.round((ability.damage ?? 70) * 0.7)) + return 0 +} + +function getAiAbilityResource(state: ArenaBattleState, unit: ArenaBattleUnit) { + return unit.id === 'opponent-healer' ? state.opponentHealerResource : null +} + +function canAffordAiAbility(state: ArenaBattleState, unit: ArenaBattleUnit, abilityId: ArenaAbilityId) { + const resource = getAiAbilityResource(state, unit) + if (!resource) return true + return resource.mana >= getAiAbilityManaCost(abilityId) +} + +function spendAiAbilityMana(state: ArenaBattleState, unit: ArenaBattleUnit, abilityId: ArenaAbilityId) { + const resource = getAiAbilityResource(state, unit) + if (!resource) return true + const cost = getAiAbilityManaCost(abilityId) + if (resource.mana < cost) return false + resource.mana = Math.max(0, resource.mana - cost) + return true +} + +function startAiAbility(state: ArenaBattleState, unit: ArenaBattleUnit, targetId: string, abilityId: ArenaAbilityId) { + if (!spendAiAbilityMana(state, unit, abilityId)) return false + const ability = getArenaAbility(abilityId) + unit.abilityCooldowns[abilityId] = ability.cooldown + unit.globalCooldown = AI_GLOBAL_COOLDOWN_SECONDS + unit.activeAbility = abilityId + unit.activeTargetId = targetId + unit.activeAbilityTimer = ability.castTime > 0 + ? ability.castTime + : Math.max(0.24, Math.min(0.9, unit.attackTimer || 0.35)) + if (ability.castTime > 0) { + unit.currentAbilityCast = { + abilityId, + remaining: ability.castTime, + targetId, + total: ability.castTime, + } + state.message = `${unit.name} is casting ${ability.name}.` + return true + } + resolveAiAbility(state, unit, targetId, abilityId) + return true +} + +function emitAiAbilityEvent(state: ArenaBattleState, unit: ArenaBattleUnit, targetId: string, abilityId: ArenaAbilityId) { + const ability = getArenaAbility(abilityId) + state.abilityEvents.push({ + abilityId, + casterId: unit.id, + id: `${state.elapsed.toFixed(3)}-${unit.id}-${abilityId}-${state.abilityEvents.length}`, + school: ability.school, + targetId, + teamId: unit.teamId, + }) +} + +function playAbility(state: ArenaBattleState, unit: ArenaBattleUnit, targetId: string, abilityId: ArenaAbilityId) { + const ability = getArenaAbility(abilityId) + unit.activeAbility = abilityId + unit.activeTargetId = targetId + unit.activeAbilityTimer = Math.max(0.24, Math.min(0.9, ability.castTime || unit.attackTimer || 0.35)) + emitAiAbilityEvent(state, unit, targetId, abilityId) +} + +function resolveAiAbility(state: ArenaBattleState, unit: ArenaBattleUnit, targetId: string, abilityId: ArenaAbilityId) { + const ability = getArenaAbility(abilityId) + emitAiAbilityEvent(state, unit, targetId, abilityId) + if (ability.kind === 'heal' || ability.kind === 'shield') { + const target = findArenaUnitById(state, targetId) + if (!target || target.hp <= 0) return + if (ability.kind === 'shield') addShield(target, ability.shield ?? 0, { capRatio: HEALING_SPELL_RULES.arenaShieldCapRatio }) + const healOverTimeId = getHealOverTimeEffectId(abilityId) + if (healOverTimeId) applyHealOverTimeTimers(target, healOverTimeId, { durationSeconds: HEALING_SPELL_RULES.renewSeconds }) + if (ability.heal) healTarget(state, target, ability.heal) + state.lastHit = 'heal' + state.message = `${unit.name} used ${ability.name}.` + return + } + + const boss = findArenaBossById(state, targetId) + if (!boss || boss.hp <= 0 || abilityId === 'rapid_fire' || abilityId === 'taunt') return + const dotPulse = ability.dot ? ability.dot.damage / Math.max(1, ability.dot.seconds / 3) : 0 + const damage = (ability.damage ?? 0) + dotPulse + applyBossDamageFromUnit(unit, boss, damage) + showDamage(state, boss.id, damage, unit.teamId) + state.message = `${unit.name} used ${ability.name}.` +} + +function findArenaUnitById(state: ArenaBattleState, id: string) { + return [state.player, ...state.playerParty, ...state.opponents].find((unit) => unit.id === id) ?? null +} + +function findArenaBossById(state: ArenaBattleState, id: string) { + if (state.playerBoss.id === id) return state.playerBoss + if (state.opponentBoss.id === id) return state.opponentBoss + return null +} + +function canUnitUseAbility(unit: ArenaBattleUnit, abilityId: ArenaAbilityId) { + return (unit.abilityCooldowns[abilityId] ?? 0) <= 0 +} + +function getArenaUnitAttackRange(unit: ArenaBattleUnit) { + if (unit.role === 'tank' || unit.role === 'melee') return 58 + return 250 +} + +function startPlayerSpell(state: ArenaBattleState, spell: SpellSlot | null) { + if (!spell || state.player.currentCast || state.player.globalCooldown > 0 || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return + const spellDef = getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers) + if (isArenaDamageSpell(state, spell)) { + if (state.playerBoss.hp <= 0) { + state.message = 'No boss target.' + return + } + if (state.targetId !== state.playerBoss.id) { + showNotice(state, state.player.id, 'Target') + state.message = `${spellDef.name} requires a boss target.` + return + } + const reach = getPlayerSpellReach(state, state.playerBoss, spell, { includeTargetRadius: true }) + if (!reach.ok) { + setPlayerSpellReachFailure(state, state.player.id, reach, 'Boss not in range.') + return + } + if (!spendPlayerMana(state, spell)) return + triggerPlayerGlobalCooldown(state) + const definition = spellDef + state.player.currentCast = { + spell, + targetId: state.playerBoss.id, + remaining: definition.castTime, + total: definition.castTime, + } + state.message = `Casting ${definition.name} on ${state.playerBoss.name}.` + return + } + const target = findFriendlyTarget(state, state.targetId) + if (!target || target.hp <= 0) { + state.message = 'No living ally selected.' + return + } + if (!canHealTarget(state, target, spell)) return + if (!spendPlayerMana(state, spell)) return + triggerPlayerGlobalCooldown(state) + + const definition = spellDef + if (definition.castTime > 0) { + state.player.currentCast = { + spell, + targetId: target.id, + remaining: definition.castTime, + total: definition.castTime, + } + state.message = `Casting ${definition.name} on ${target.name}.` + return + } + + completePlayerSpell(state, spell, target.id) +} + +function tickCurrentCast(state: ArenaBattleState, deltaSeconds: number) { + const cast = state.player.currentCast + if (!cast) return + + cast.remaining = Math.max(0, cast.remaining - deltaSeconds) + const target = isArenaDamageSpell(state, cast.spell) ? null : findFriendlyTarget(state, cast.targetId) + state.message = `Casting ${getActionSpellDefinition(state.player.classId, cast.spell, state.player.talentModifiers).name} on ${target?.name ?? 'target'}.` + if (cast.remaining > 0) return + + state.player.currentCast = null + completePlayerSpell(state, cast.spell, cast.targetId) +} + +function completePlayerSpell(state: ArenaBattleState, spell: SpellSlot, targetId: string) { + if (isArenaDamageSpell(state, spell)) { + completePlayerDamageSpell(state, spell) + return + } + const target = findFriendlyTarget(state, targetId) + if (!target || target.hp <= 0 || !canHealTarget(state, target, spell)) return + + state.player.spellCooldowns[spell] = getSpellCooldown(state, spell) + recordPlayerSpellCast(state) + const targets = getSpellTargets(state, spell, target) + const ability = getArenaAbility(getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers).abilityId) + const healOverTimeId = getHealOverTimeEffectId(ability.id) + if (ability.shield || ability.kind === 'buff') { + const shield = getActionTalentAdjustedAmount(state.player.classId, spell, ability.shield ?? (ability.id === 'blessing_of_might' ? 80 : 90), 'shield', state.player.talentModifiers) + for (const unit of targets) { + shieldTarget(state.playerUpgrades, unit, shield) + applyLinkedEffects(state.playerUpgrades, unit, spell) + } + state.message = `${ability.name} protected ${targets.length}.` + } else if (healOverTimeId) { + const amount = getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 28, 'heal', state.player.talentModifiers) + for (const unit of targets) { + applyHealOverTimeTimers(unit, healOverTimeId, { + durationSeconds: spell === 6 && state.playerUpgrades.includes('slot-2-duration') ? HEALING_SPELL_RULES.renewSeconds * 2 : HEALING_SPELL_RULES.renewSeconds, + }) + healTarget(state, unit, amount) + applyLinkedEffects(state.playerUpgrades, unit, spell) + } + state.message = `${ability.name} on ${targets.length}.` + } else { + const amount = getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 120, 'heal', state.player.talentModifiers) * (state.playerUpgrades.includes('wide-radiance') ? 1.25 : 1) + for (const unit of targets) { + healTarget(state, unit, amount) + unit.stunTimer = 0 + applyLinkedEffects(state.playerUpgrades, unit, spell) + } + state.message = `${ability.name} healed ${targets.length}.` + } + state.lastHit = 'heal' +} + +function completePlayerDamageSpell(state: ArenaBattleState, spell: SpellSlot) { + if (state.playerBoss.hp <= 0) return + const abilityId = getPlayerAbility(state, spell) + if (!abilityId) return + const ability = getArenaAbility(abilityId) + state.player.spellCooldowns[spell] = getSpellCooldown(state, spell) + recordPlayerSpellCast(state) + const dotPulse = ability.dot ? ability.dot.damage / Math.max(1, ability.dot.seconds / 3) : 0 + const damage = getActionTalentAdjustedAmount(state.player.classId, spell, (ability.damage ?? 0) + dotPulse, 'damage', state.player.talentModifiers) + applyBossDamageFromUnit(state.player, state.playerBoss, damage) + showDamage(state, state.playerBoss.id, damage, 'player') + playAbility(state, state.player, state.playerBoss.id, abilityId) + const ally = canTriggerAtonement(state, abilityId) ? findAtonementTarget(state, spell) : undefined + if (ally) { + healTarget(state, ally, damage) + applyLinkedEffects(state.playerUpgrades, ally, spell) + } + state.lastHit = 'opponent' + state.message = ally + ? `${ability.name} hit ${state.playerBoss.name} and Atonement healed ${ally.name}.` + : `${ability.name} hit ${state.playerBoss.name}.` +} + +function canTriggerAtonement(state: ArenaBattleState, abilityId: ArenaAbilityId) { + return state.playerUpgrades.includes('atonement') && (abilityId === 'smite' || abilityId === 'shadow_word_pain') +} + +function findAtonementTarget(state: ArenaBattleState, spell: SpellSlot) { + return [state.player, ...state.playerParty] + .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && getPlayerSpellReach(state, unit, spell).ok) + .sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0] +} + +function isArenaDamageSpell(state: ArenaBattleState, spell: SpellSlot) { + return getActionSpellTarget(state.player.classId, spell) === 'Enemy target' +} + +function getPlayerAbility(state: ArenaBattleState, spell: SpellSlot): ArenaAbilityId | null { + return getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers).abilityId +} + +function canHealTarget(state: ArenaBattleState, target: ArenaBattleUnit, spell: SpellSlot) { + const reach = getPlayerSpellReach(state, target, spell) + if (!reach.ok) { + setPlayerSpellReachFailure(state, target.id, reach) + return false + } + return true +} + +function getSpellTargets(state: ArenaBattleState, _spell: SpellSlot, primary: ArenaBattleUnit) { + const base = 1 + const count = base + (state.playerUpgrades.includes('all-target') ? 1 : 0) + const allies = [state.player, ...state.playerParty] + .filter((unit) => unit.hp > 0 && getPlayerSpellReach(state, unit, _spell).ok) + .sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp)) + const targets = [primary] + for (const ally of allies) { + if (targets.length >= count) break + if (!targets.some((target) => target.id === ally.id)) targets.push(ally) + } + return targets +} + +function getPlayerSpellReach( + state: ArenaBattleState, + target: ArenaBattleUnit | ArenaBossState, + spell: SpellSlot, + options?: { includeTargetRadius?: boolean }, +) { + return getActionSpellReach({ + caster: state.player, + target, + classId: state.player.classId, + spell, + talentMods: state.player.talentModifiers, + includeTargetRadius: options?.includeTargetRadius, + hasLineOfSight: (from, to) => hasLineOfSight(state, from, to), + }) +} + +function setPlayerSpellReachFailure( + state: ArenaBattleState, + targetId: string, + reach: ReturnType, + rangeMessage = 'Not in range.', +) { + if (reach.ok) return + const blocked = reach.reason === 'lineOfSight' + showNotice(state, targetId, blocked ? 'Blocked' : 'Range') + state.message = blocked ? 'Line of sight blocked.' : rangeMessage +} + +function applyLinkedEffects(upgrades: ArenaUpgradeId[], unit: ArenaBattleUnit, spell: SpellSlot) { + if ((spell === 1 && upgrades.includes('slot-1-renew')) + || (spell === 7 && upgrades.includes('slot-3-renew')) + || (spell === 4 && upgrades.includes('slot-4-renew')) + || (spell === 9 && upgrades.includes('slot-5-renew'))) { + applyRenew(upgrades, unit, spell) + } + if ((spell === 1 && upgrades.includes('slot-1-shield')) + || (spell === 2 && upgrades.includes('slot-2-shield')) + || (spell === 9 && upgrades.includes('slot-5-shield'))) { + shieldTarget(upgrades, unit, 24) + } + if (spell === 7 && upgrades.includes('slot-3-half-shield')) shieldTarget(upgrades, unit, HEALING_SPELL_RULES.sunWardShield * 0.5) +} + +function applyRenew(upgrades: ArenaUpgradeId[], unit: ArenaBattleUnit, spell: SpellSlot) { + applyRenewTimers(unit, spell === 6 && upgrades.includes('slot-2-duration') ? HEALING_SPELL_RULES.renewSeconds * 2 : HEALING_SPELL_RULES.renewSeconds) +} + +function getSpellCooldown(state: ArenaBattleState, spell: SpellSlot) { + return (getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers)?.cooldown ?? 0.5) * getArenaSpellCooldownMultiplier(state.playerUpgrades, spell) +} + +function tickUnit(state: ArenaBattleState, unit: ArenaBattleUnit, deltaSeconds: number) { + unit.stunTimer = Math.max(0, unit.stunTimer - deltaSeconds) + unit.invulnerableTimer = Math.max(0, unit.invulnerableTimer - deltaSeconds) + unit.globalCooldown = Math.max(0, unit.globalCooldown - deltaSeconds) + if (unit.currentAbilityCast) { + unit.currentAbilityCast.remaining = Math.max(0, unit.currentAbilityCast.remaining - deltaSeconds) + unit.activeAbilityTimer = unit.currentAbilityCast.remaining + if (unit.currentAbilityCast.remaining <= 0) { + const cast = unit.currentAbilityCast + unit.currentAbilityCast = null + resolveAiAbility(state, unit, cast.targetId, cast.abilityId) + unit.activeAbility = null + unit.activeTargetId = null + unit.activeAbilityTimer = 0 + } + } else { + unit.activeAbilityTimer = Math.max(0, unit.activeAbilityTimer - deltaSeconds) + } + if (!unit.currentAbilityCast && unit.activeAbilityTimer <= 0) { + unit.activeAbility = null + unit.activeTargetId = null + } + for (const abilityId of Object.keys(unit.abilityCooldowns)) { + unit.abilityCooldowns[abilityId] = Math.max(0, unit.abilityCooldowns[abilityId] - deltaSeconds) + } + if (unit.renewTimer > 0 && unit.hp > 0) { + tickHealOverTimes(unit, deltaSeconds, (effect) => healTarget(state, unit, effect.tickHeal)) + } else if (unit.healOverTimes?.length && unit.hp > 0) { + tickHealOverTimes(unit, deltaSeconds, (effect) => healTarget(state, unit, effect.tickHeal)) + } +} + +function tickCooldowns(cooldowns: Record, deltaSeconds: number) { + tickSpellCooldowns(cooldowns, deltaSeconds) +} + +function tickPlayerGlobalCooldown(state: ArenaBattleState, deltaSeconds: number) { + state.player.globalCooldown = Math.max(0, state.player.globalCooldown - deltaSeconds) +} + +function triggerPlayerGlobalCooldown(state: ArenaBattleState) { + state.player.globalCooldown = SPELL_GLOBAL_COOLDOWN_SECONDS +} + +function resolveResult(state: ArenaBattleState) { + const playerAlive = getLivingArenaUnits(state, 'player').length > 0 + const opponentAlive = getLivingArenaUnits(state, 'opponent').length > 0 + if (playerAlive && opponentAlive) return + state.result = playerAlive ? 'player' : 'opponent' + state.message = state.result === 'player' + ? `Round won. You killed ${state.playerBossKills} bosses.` + : `Round lost. Rival killed ${state.opponentBossKills} bosses.` +} + +function healTarget(state: ArenaBattleState, target: ArenaBattleUnit, amount: number) { + const before = target.hp + const dampenedAmount = dampenHealing(amount, state.dampeningPercent) + target.hp = Math.min(target.maxHp, target.hp + dampenedAmount) + const healed = target.hp - before + if (healed <= 0) return + state.healEvents.push({ + id: `${state.elapsed.toFixed(3)}-${target.id}-${state.healEvents.length}`, + targetId: target.id, + amount: healed, + }) +} + +function showDamage(state: ArenaBattleState, targetId: string, amount: number, teamId: ArenaBattleTeamId) { + const rounded = Math.round(amount) + if (rounded <= 0) return + state.damageEvents.push({ + id: `${state.elapsed.toFixed(3)}-${targetId}-damage-${state.damageEvents.length}`, + targetId, + amount: rounded, + teamId, + }) +} + +function shieldTarget(upgrades: ArenaUpgradeId[], target: ArenaBattleUnit, amount: number) { + const value = amount * (upgrades.includes('dense-shields') ? 1.25 : 1) + addShield(target, value, { capRatio: HEALING_SPELL_RULES.arenaShieldCapRatio }) +} + +function applyDamage(target: ArenaBattleUnit, amount: number) { + applyShieldedDamage(target, amount) +} + +function applyBossDamage(target: ArenaBossState, amount: number) { + target.hp = Math.max(0, target.hp - amount) +} + +function applyBossDamageFromUnit(source: ArenaBattleUnit, target: ArenaBossState, amount: number) { + const before = target.hp + applyBossDamage(target, amount) + source.damageDone += Math.max(0, before - target.hp) +} + +function moveUnitWithCollision(state: ArenaBattleState, unit: ArenaBattleUnit, dx: number, dy: number) { + unit.x += dx + clampToArena(state, unit) + for (const wall of state.walls) resolveWallCollision(unit, wall) + unit.y += dy + clampToArena(state, unit) + for (const wall of state.walls) resolveWallCollision(unit, wall) + constrainToSide(unit, unit.teamId) +} + +function constrainToSide(unit: ArenaBattleUnit, teamId: ArenaBattleTeamId) { + if (teamId === 'player') unit.x = Math.min(getPlayerSideMaxXForWidth(ARENA_WIDTH) - unit.radius, unit.x) + else unit.x = Math.max(getOpponentSideMinXForWidth(ARENA_WIDTH) + unit.radius, unit.x) +} + +function constrainBossToSide(state: ArenaBattleState, boss: ArenaBossState) { + if (boss.teamId === 'player') { + boss.x = Math.max(state.arena.padding + boss.radius, Math.min(getPlayerSideMaxX(state) - boss.radius, boss.x)) + } else { + boss.x = Math.max(getOpponentSideMinX(state) + boss.radius, Math.min(state.arena.width - state.arena.padding - boss.radius, boss.x)) + } + boss.y = Math.max(state.arena.padding + boss.radius, Math.min(state.arena.height - state.arena.padding - boss.radius, boss.y)) +} + +function getPlayerSideMaxX(state: ArenaBattleState) { + return getPlayerSideMaxXForWidth(state.arena.width) +} + +function getOpponentSideMinX(state: ArenaBattleState) { + return getOpponentSideMinXForWidth(state.arena.width) +} + +function getPlayerSideMaxXForWidth(width: number) { + return width / 2 - ARENA_SIDE_GAP / 2 +} + +function getOpponentSideMinXForWidth(width: number) { + return width / 2 + ARENA_SIDE_GAP / 2 +} + +function scaleArenaX(value: number) { + return Math.round(value * ARENA_SIZE_MULTIPLIER) +} + +function scaleArenaY(value: number) { + return Math.round(value * ARENA_SIZE_MULTIPLIER) +} + +function resolveWallCollision(unit: ArenaBattleUnit, wall: ArenaWallHazard) { + if (wall.warning > 0) return + const left = wall.x - wall.width / 2 + const right = wall.x + wall.width / 2 + const top = wall.y - wall.height / 2 + const bottom = wall.y + wall.height / 2 + const nearestX = Math.max(left, Math.min(right, unit.x)) + const nearestY = Math.max(top, Math.min(bottom, unit.y)) + const dx = unit.x - nearestX + const dy = unit.y - nearestY + const overlap = unit.radius - Math.hypot(dx, dy) + if (overlap <= 0) return + + if (Math.abs(dx) > Math.abs(dy)) unit.x += Math.sign(dx || 1) * overlap + else unit.y += Math.sign(dy || 1) * overlap +} + +function hasLineOfSight(state: ArenaBattleState, from: { x: number, y: number }, to: { x: number, y: number }) { + return !state.walls.some((wall) => wall.warning <= 0 && segmentIntersectsRect(from, to, wall)) +} + +function segmentIntersectsRect(from: { x: number, y: number }, to: { x: number, y: number }, wall: ArenaWallHazard) { + const left = wall.x - wall.width / 2 + const right = wall.x + wall.width / 2 + const top = wall.y - wall.height / 2 + const bottom = wall.y + wall.height / 2 + for (let i = 0; i <= 12; i += 1) { + const t = i / 12 + const x = from.x + (to.x - from.x) * t + const y = from.y + (to.y - from.y) * t + if (x >= left && x <= right && y >= top && y <= bottom) return true + } + return false +} + +function findFriendlyTarget(state: ArenaBattleState, id: string) { + return [state.player, ...state.playerParty].find((unit) => unit.id === id) +} + +function setTarget(state: ArenaBattleState, id: string) { + if (findFriendlyTarget(state, id) || id === state.playerBoss.id) state.targetId = id +} + +function cycleTarget(state: ArenaBattleState, delta: -1 | 1) { + const units = [state.playerBoss, state.player, ...state.playerParty] + const currentIndex = Math.max(0, units.findIndex((unit) => unit.id === state.targetId)) + state.targetId = units[(currentIndex + delta + units.length) % units.length].id +} + +function showNotice(state: ArenaBattleState, targetId: string, text: string) { + state.noticeEvents.push({ + id: `${state.elapsed.toFixed(3)}-${targetId}-${state.noticeEvents.length}-${text}`, + targetId, + text, + }) +} + +function unitToFrame(state: ArenaBattleState, unit: ArenaBattleUnit): RaidFrame { + return { + id: unit.id, + name: unit.name, + role: unit.role, + specLabel: getArenaClassKit(unit.classId).defaultPvpSpec, + damageDone: unit.damageDone, + hp: unit.hp, + maxHp: unit.maxHp, + healOverTimes: getActiveHealOverTimeFrames(unit), + renewTimer: unit.renewTimer, + burnTimer: 0, + bleedTimer: 0, + selected: unit.id === state.targetId, + shield: unit.shield, + } +} + +function createUnit( + id: string, + name: string, + classId: ArenaClassId, + teamId: ArenaBattleTeamId, + _maxHp: number, + x: number, + y: number, + aiSlot: number, +): ArenaBattleUnit { + const kit = getArenaClassKit(classId) + const vitals = getArenaClassVitals(classId) + const maxHp = vitals.maxHp + return { + abilityCooldowns: createArenaAbilityCooldowns(classId), + activeAbility: null, + activeTargetId: null, + activeAbilityTimer: 0, + currentAbilityCast: null, + damageDone: 0, + globalCooldown: 0, + classId, + id, + name, + role: kit.role, + teamId, + x, + y, + radius: kit.role === 'tank' ? 17 : 14, + hp: maxHp, + maxHp, + shield: 0, + stunTimer: 0, + invulnerableTimer: 0, + healOverTimes: [], + renewTimer: 0, + renewTickTimer: 0, + attackTimer: kit.attackSeconds + aiSlot * 0.13, + aiSlot, + } +} + +function createBoss(teamId: ArenaBattleTeamId, spawnCount: number): ArenaBossState { + const template = getArenaBossTemplate(spawnCount) + const cycle = Math.floor((spawnCount - 1) / ARENA_BOSS_SEQUENCE.length) + const cycleMultiplier = 1 + cycle * 0.12 + const maxHp = Math.round( + (template.hpBase + template.hpPerLevel * (ARENA_PVP_EFFECTIVE_LEVEL - 1)) + * CLAUDECRAFT_ELITE_HP_MULTIPLIER + * cycleMultiplier, + ) + const x = teamId === 'player' ? scaleArenaX(118) : scaleArenaX(842) + const y = scaleArenaY(270) + const name = cycle > 0 ? `${template.name} ${cycle + 1}` : template.name + return { + activeAction: 'idle', + activeActionTimer: 0, + id: `${teamId}-boss-${spawnCount}`, + name, + teamId, + kind: template.kind, + x, + y, + radius: template.radius, + hp: maxHp, + maxHp, + phase: 'tracking', + phaseTimer: 1.15, + chargeVector: { x: teamId === 'player' ? 1 : -1, y: 0 }, + targetId: teamId === 'player' ? 'player-tank' : 'opponent-tank', + chargeCount: 0, + tankSwingTimer: 1.15, + telegraph: { + active: false, + start: { x, y }, + end: { x: teamId === 'player' ? getPlayerSideMaxXForWidth(ARENA_WIDTH) : getOpponentSideMinXForWidth(ARENA_WIDTH), y }, + width: template.radius * 2.4, + }, + slamTelegraph: { + active: false, + radius: BOSS_SLAM_RADIUS, + }, + mechanics: { + addWaves: [], + enraged: false, + firedSummons: 0, + pulseTimer: Math.min(3, template.mechanics.aoePulse?.every ?? 0), + stompCasting: false, + stompCastTimer: 0, + stompTimer: Math.min(3.5, template.mechanics.stomp?.every ?? 0), + }, + spawnCount, + } +} + +function clampToArena(state: ArenaBattleState, unit: ArenaBattleUnit) { + unit.x = Math.max(state.arena.padding + unit.radius, Math.min(state.arena.width - state.arena.padding - unit.radius, unit.x)) + unit.y = Math.max(state.arena.padding + unit.radius, Math.min(state.arena.height - state.arena.padding - unit.radius, unit.y)) +} + +function distance(a: { x: number, y: number }, b: { x: number, y: number }) { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function cloneBoss(boss: ArenaBossState): ArenaBossState { + return { + ...boss, + chargeVector: { ...boss.chargeVector }, + telegraph: { + active: boss.telegraph.active, + start: { ...boss.telegraph.start }, + end: { ...boss.telegraph.end }, + width: boss.telegraph.width, + }, + slamTelegraph: { ...boss.slamTelegraph }, + mechanics: { + ...boss.mechanics, + addWaves: boss.mechanics.addWaves.map((wave) => ({ ...wave })), + }, + } +} + +function cloneArenaUnit(unit: T): T { + return { + ...unit, + abilityCooldowns: { ...unit.abilityCooldowns }, + currentAbilityCast: unit.currentAbilityCast ? { ...unit.currentAbilityCast } : null, + healOverTimes: unit.healOverTimes?.map((effect) => ({ ...effect })), + } +} + +function cloneArenaBattleState(state: ArenaBattleState): ArenaBattleState { + return { + ...state, + arena: { ...state.arena }, + player: { + ...cloneArenaUnit(state.player), + spellCooldowns: { ...state.player.spellCooldowns }, + currentCast: state.player.currentCast ? { ...state.player.currentCast } : null, + }, + playerParty: state.playerParty.map((unit) => cloneArenaUnit(unit)), + opponents: state.opponents.map((unit) => cloneArenaUnit(unit)), + opponentHealerResource: { ...state.opponentHealerResource }, + playerBoss: cloneBoss(state.playerBoss), + opponentBoss: cloneBoss(state.opponentBoss), + playerUpgrades: [...state.playerUpgrades], + opponentUpgrades: [...state.opponentUpgrades], + walls: state.walls.map((wall) => ({ ...wall })), + birds: state.birds.map((bird) => ({ ...bird })), + healEvents: state.healEvents.map((event) => ({ ...event })), + damageEvents: state.damageEvents.map((event) => ({ ...event })), + abilityEvents: state.abilityEvents.map((event) => ({ ...event })), + noticeEvents: state.noticeEvents.map((event) => ({ ...event })), + } +} diff --git a/src/actionAssets.ts b/src/actionAssets.ts new file mode 100644 index 0000000..a103140 --- /dev/null +++ b/src/actionAssets.ts @@ -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 = { + 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 = { + bulldrome: '/action-assets/dungeons/bulldrome.svg', + 'yian-kut-ku': '/action-assets/dungeons/yian-kut-ku.svg', + 'cyber-dragon': '/action-assets/dungeons/cyber-dragon.svg', +} diff --git a/src/actionBoss/BulldromeScene.ts b/src/actionBoss/BulldromeScene.ts index 0b67e45..38accb3 100644 --- a/src/actionBoss/BulldromeScene.ts +++ b/src/actionBoss/BulldromeScene.ts @@ -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() 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() private seenNoticeEventIds = new Set() @@ -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, diff --git a/src/actionBoss/bulldromeSimulation.ts b/src/actionBoss/actionCombatSimulation.ts similarity index 54% rename from src/actionBoss/bulldromeSimulation.ts rename to src/actionBoss/actionCombatSimulation.ts index bac6e4a..9d5dfe0 100644 --- a/src/actionBoss/bulldromeSimulation.ts +++ b/src/actionBoss/actionCombatSimulation.ts @@ -1,12 +1,81 @@ -import { getActionAttack, getEnabledActionAttack, type ActionAttackKind } from './actionEncounterConfig' -import { getActionDifficultyTier, type ActionDifficulty } from '../actionMode' +import { + getActionAiAvoidance, + getActionAttack, + getEnabledActionAttack, + type ActionAiAvoidanceConfig, + type ActionAttackKind, +} from './actionEncounterConfig' +import { + ACTION_CONTROL_RULES, + ACTION_MOVEMENT_RULES, + createSpellCooldowns, + createSpellResourceState, + addShield, + applyHealOverTimeTimers, + applyShieldedDamage, + getActiveHealOverTimeFrames, + getActionSpellDefinition, + getActionSpellReach, + getActionSpellTarget, + getActionTalentAdjustedAmount, + getHealOverTimeEffectId, + RANGED_ATTACK_RANGE, + recordSpellCast, + ARENA_PRIEST_SPELL_BAR, + DUNGEON_PRIEST_SPELL_BAR, + SPELL_GLOBAL_COOLDOWN_SECONDS, + SPELLS, + spendActionSpellMana, + tickHealOverTimes, + tickSpellCooldowns as tickSharedSpellCooldowns, + tickSpellResource, + type CastState, + type HealOverTimeEffect, + type PartyRole, + type SpellDefinition, + type SpellResourceState, + type SpellSlot, + type ActionSpellReachResult, +} from '../actionCombatCore' +import { getArenaAbility, getArenaClassVitals, type ArenaClassId } from '../actionClassKits' +import type { TalentModifiers } from '../claudeCraftTalents' +import { createEmptyTalentModifiers } from '../actionCombatProfile' +import { + type BossInput, + type BossPhase, + type Point, + type ArcTelegraph, + type RaidFrame, + type Telegraph, +} from '../actionCombatTypes' +import { + getEnemyBodyDamage, + getEnemyChargeDamage, + getEnemyChargeSeconds, + getEnemyChargeSpeed, + getEnemyRecoverSeconds, + getEnemyTankFocusSeconds, + getEnemyTankSwingDamage, + getEnemyTankSwingSeconds, + getEnemyTrackSeconds, + getEnemyWindupSeconds, +} from './actionEnemyRuntime' +import { getActionDifficultyTier, type ActionDifficulty, type ActionDungeonId, type ActionRunMode } from '../actionMode' +import type { PlayerClass } from '../claudeCraftTypes' -export type ActionRunMode = 'hunt' | 'marathon' -export type BossPhase = 'tracking' | 'windup' | 'charging' | 'recovering' | 'mauling' | 'slamWindup' | 'defeated' | 'victory' -export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' -export type PartyRole = 'healer' | 'tank' | 'melee' | 'ranged' -export type SpellSlot = 1 | 2 | 3 | 4 | 5 -export type EnemyKind = 'bulldrome' | 'bullfango' | 'yian-kut-ku' | 'bird' +export type { ActionDungeonId, ActionRunMode } from '../actionMode' +export { ARENA_PRIEST_SPELL_BAR, DUNGEON_PRIEST_SPELL_BAR, SPELLS } +export type { CastState, PartyRole, SpellDefinition, SpellSlot } +export type EnemyKind = + | 'bulldrome' + | 'bullfango' + | 'yian-kut-ku' + | 'bird' + | 'cyber-dragon' + | 'claudecraft-mob' + | 'morthen-the-gravecaller' + | 'vael-the-mistcaller' + | 'korzul-the-gravewyrm' export type EncounterStep = 'trash' | 'boss' type BirdFlightStep = 'ground' | 'toLeft' | 'across' | 'toTank' @@ -25,35 +94,19 @@ export type TargetableState = FighterState & { shield: number stunTimer: number invulnerableTimer: number + healOverTimes?: HealOverTimeEffect[] renewTimer: number renewTickTimer: number burnTimer: number burnTickTimer: number -} - -export type CastState = { - spell: SpellSlot - targetId: string - remaining: number - total: number + bleedTimer: number + bleedTickTimer: number } export type PartyMemberState = TargetableState & { aiSlot: number } -export type Point = { - x: number - y: number -} - -export type Telegraph = { - active: boolean - start: Point - end: Point - width: number -} - export type EnemyState = FighterState & { id: string name: string @@ -66,6 +119,9 @@ export type EnemyState = FighterState & { tankSwingTimer: number flightStep: BirdFlightStep flightY: number + orbitAngle: number + mechanicTimers: Partial> + arcTelegraph: ArcTelegraph telegraph: Telegraph slamTelegraph: { active: boolean @@ -84,17 +140,26 @@ export type BulldromeState = { padding: number } player: TargetableState & { + classId: PlayerClass currentCast: CastState | null + facingX: number + facingY: number + globalCooldown: number spellCooldowns: Record - } + talentModifiers: TalentModifiers + } & SpellResourceState party: PartyMemberState[] targetId: string boss: EnemyState adds: EnemyState[] fireballs: FireballState[] fireSpots: FireSpotState[] + spinningBlades: SpinningBladeState[] nextHazardId: number + damageDoneByUnit: Record bossKills: number + dungeonWaveIndex: number + dungeonWaveTotal: number respawnTimer: number healEvents: HealTextEvent[] noticeEvents: NoticeTextEvent[] @@ -122,24 +187,16 @@ export type FireSpotState = { remaining: number } -export type BossInput = { - xAxis: number - yAxis: number - reset: boolean - targetDelta: -1 | 0 | 1 - targetId: string | null - castSpell: SpellSlot | null -} - -export type RaidFrame = { +export type SpinningBladeState = { id: string - name: string - role: PartyRole - hp: number - maxHp: number - renewTimer: number - selected: boolean - shield: number + sourceKind: EnemyKind + x: number + y: number + radius: number + speed: number + remaining: number + targetId: string + rotation: number } export type EnemyFrame = { @@ -163,168 +220,136 @@ export type NoticeTextEvent = { text: string } -export type SpellDefinition = { - slot: SpellSlot - name: string - cooldown: number - castTime: number -} +type CombatSpellTarget = TargetableState | EnemyState -export const SPELLS: Record = { - 1: { slot: 1, name: 'Mend', cooldown: 0.5, castTime: 0.75 }, - 2: { slot: 2, name: 'Renew', cooldown: 0.5, castTime: 0 }, - 3: { slot: 3, name: 'Radiance', cooldown: 8, castTime: 0.75 }, - 4: { slot: 4, name: 'Sun Ward', cooldown: 7, castTime: 0 }, - 5: { slot: 5, name: 'Purify', cooldown: 5, castTime: 0 }, -} - -const PLAYER_SPEED = 245 -const PLAYER_STUN_SECONDS = 0.92 -const PLAYER_INVULNERABLE_SECONDS = 0.7 -const MEND_HEAL = 34 -const RENEW_SECONDS = 8 -const RENEW_TICK_SECONDS = 1 -const RENEW_HEAL = 7 -const RADIANCE_HEAL = 22 -const SUN_WARD_SHIELD = 36 -const PURIFY_HEAL = 14 -const ARENA_SQUARE_SIZE = 48 -const HEAL_RANGE = ARENA_SQUARE_SIZE * 6 -const RANGED_ATTACK_RANGE = ARENA_SQUARE_SIZE * 6 -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_SLAM_CAST_SECONDS = 1.25 const BOSS_SLAM_RADIUS = 142 -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 DUNGEON_ARENA_HEIGHT = 648 +const DUNGEON_ARENA_PADDING = 42 +const DUNGEON_ARENA_WIDTH = 1152 const YIAN_FIREBALL_WINDUP_SECONDS = 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 FIREBALL_SPEED = 275 +const FIREBALL_SPEED = 190 const FIREBALL_DAMAGE = 16 const FIRE_DOT_SECONDS = 5 const FIRE_DOT_TICK_SECONDS = 1 const FIRE_DOT_DAMAGE = 5 const FIRE_SPOT_SECONDS = 20 -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 MAX_FIRE_SPOTS = 8 +const CYBER_BLADE_SPAWN_SECONDS = 10 +const CYBER_BLADE_SECONDS = 15 +const CYBER_BLADE_SPEED = 96 +const CYBER_BLADE_RADIUS = 24 +const CYBER_BLEED_SECONDS = 8 +const CYBER_BLEED_TICK_SECONDS = 1 +const CYBER_BLEED_DAMAGE = 3 +const CYBER_CIRCLE_SECONDS = 1.35 +const CYBER_CIRCLE_RADIUS = 124 +const CYBER_CHARGE_SECONDS = 0.72 +const CYBER_CHARGE_STUN_SECONDS = 1 const AI_MOVE_SPEED = 132 const TANK_DPS = 4 const MELEE_DPS = 10.5 const RANGED_DPS = 8.75 +function getInitialArena(dungeonId: ActionDungeonId) { + void dungeonId + return { + width: DUNGEON_ARENA_WIDTH, + height: DUNGEON_ARENA_HEIGHT, + padding: DUNGEON_ARENA_PADDING, + } +} + +function getInitialPartyStart(dungeonId: ActionDungeonId) { + void dungeonId + const centerX = DUNGEON_ARENA_WIDTH / 2 + const centerY = DUNGEON_ARENA_HEIGHT / 2 + return { + player: { x: centerX, y: centerY + 134 }, + tank: { x: centerX, y: centerY - 92 }, + melee: { x: centerX, y: centerY - 50 }, + ranged1: { x: centerX - 120, y: centerY + 106 }, + ranged2: { x: centerX + 120, y: centerY + 106 }, + } +} + +function toArenaClassId(classId: PlayerClass): ArenaClassId { + if (classId === 'hunter') return 'ranger' + if (classId === 'warrior' || classId === 'mage' || classId === 'rogue' || classId === 'paladin' || classId === 'shaman' || classId === 'druid' || classId === 'priest') return classId + return 'priest' +} + export function createBulldromeState( difficulty: ActionDifficulty = 'ilvl-1', dungeonId: ActionDungeonId = 'bulldrome', runMode: ActionRunMode = 'hunt', + playerClassId: PlayerClass = 'priest', + playerTalentModifiers?: TalentModifiers, ): BulldromeState { + const dungeonWaveTotal = 0 + const arena = getInitialArena(dungeonId) + const partyStart = getInitialPartyStart(dungeonId) + const playerVitals = getArenaClassVitals(toArenaClassId(playerClassId)) const state: BulldromeState = { dungeonId, difficulty, runMode, - encounterStep: dungeonId === 'bulldrome' || dungeonId === 'yian-kut-ku' ? 'trash' : 'boss', - arena: { - width: 960, - height: 540, - padding: 34, - }, + encounterStep: 'boss', + arena, player: { id: 'player', name: 'You', + classId: playerClassId, role: 'healer', - x: 480, - y: 404, + x: partyStart.player.x, + y: partyStart.player.y, radius: 15, - hp: 100, - maxHp: 100, - shield: 0, - stunTimer: 0, - invulnerableTimer: 0, - renewTimer: 0, + hp: playerVitals.maxHp, + maxHp: playerVitals.maxHp, + shield: 0, + stunTimer: 0, + invulnerableTimer: 0, + healOverTimes: [], + renewTimer: 0, renewTickTimer: 0, burnTimer: 0, burnTickTimer: 0, + bleedTimer: 0, + bleedTickTimer: 0, currentCast: null, - spellCooldowns: { - 1: 0, - 2: 0, - 3: 0, - 4: 0, - 5: 0, - }, + facingX: 0, + facingY: -1, + globalCooldown: 0, + spellCooldowns: createSpellCooldowns(), + talentModifiers: playerTalentModifiers ?? createEmptyTalentModifiers(), + ...createSpellResourceState(playerClassId), }, party: [ - createPartyMember('tank', 'Brakka', 'tank', 120, 480, 232, 0), - createPartyMember('melee-1', 'Kael', 'melee', 82, 480, 274, 1), - createPartyMember('ranged-1', 'Sera', 'ranged', 74, 360, 376, 3), - createPartyMember('ranged-2', 'Nix', 'ranged', 72, 600, 376, 4), + createPartyMember('tank', 'Brakka', 'tank', getArenaClassVitals('warrior_protection').maxHp, partyStart.tank.x, partyStart.tank.y, 0), + createPartyMember('melee-1', 'Kael', 'melee', getArenaClassVitals('rogue_combat').maxHp, partyStart.melee.x, partyStart.melee.y, 1), + createPartyMember('ranged-1', 'Sera', 'ranged', getArenaClassVitals('mage_fire').maxHp, partyStart.ranged1.x, partyStart.ranged1.y, 3), + createPartyMember('ranged-2', 'Nix', 'ranged', getArenaClassVitals('hunter_marksmanship').maxHp, partyStart.ranged2.x, partyStart.ranged2.y, 4), ], targetId: 'tank', - boss: dungeonId === 'yian-kut-ku' - ? createBird('bird-1', 'Bird', 368, 148, 0, 150) - : createBullfango('bullfango-1', 'Bullfango A', 368, 148, 0), - adds: dungeonId === 'yian-kut-ku' - ? [ - createBird('bird-2', 'Bird', 480, 126, 0, 234), - createBird('bird-3', 'Bird', 592, 148, 0, 318), - ] - : [ - createBullfango('bullfango-2', 'Bullfango B', 480, 126, 0.55), - createBullfango('bullfango-3', 'Bullfango C', 592, 148, 1.05), - ], + boss: createInitialEnemy(dungeonId), + adds: [], fireballs: [], fireSpots: [], + spinningBlades: [], nextHazardId: 0, + damageDoneByUnit: createDamageDoneTracker(), bossKills: 0, + dungeonWaveIndex: 0, + dungeonWaveTotal, respawnTimer: 0, healEvents: [], noticeEvents: [], elapsed: 0, - message: dungeonId === 'yian-kut-ku' - ? 'Bird flock guards the roost.' - : 'Bullfango pack blocks the hunting grounds.', + message: getDungeonStartMessage(dungeonId), result: 'playing', lastHit: null, } - if (dungeonId === 'yian-kut-ku') { - state.adds = [ - createBird('bird-2', 'Bird', 480, 126, 0, 234), - createBird('bird-3', 'Bird', 592, 148, 0, 318), - ] - } else if (isHighActionTier(difficulty)) { - state.boss = createBulldrome() - state.adds = [ - createBullfango('hard-bullfango-1', 'Bullfango A', 342, 188, 0.55), - createBullfango('hard-bullfango-2', 'Bullfango B', 618, 188, 1.1), - ] - state.message = 'Hard hunt. Bulldrome brought two Bullfangos.' - } - applyDifficultyScaling(state) return state @@ -335,7 +360,7 @@ export function updateBulldromeState( input: BossInput, deltaSeconds: number, ): BulldromeState { - if (input.reset) return createBulldromeState(state.difficulty, state.dungeonId, state.runMode) + if (input.reset) return createBulldromeState(state.difficulty, state.dungeonId, state.runMode, state.player.classId, state.player.talentModifiers) const next = cloneState(state) next.elapsed += deltaSeconds @@ -348,10 +373,12 @@ export function updateBulldromeState( if (next.result !== 'playing') return next - tickTargetable(next.player, deltaSeconds) + tickTargetable(next, next.player, deltaSeconds) + tickPlayerGlobalCooldown(next, deltaSeconds) tickSpellCooldowns(next.player.spellCooldowns, deltaSeconds) + tickSpellResource(next.player, deltaSeconds) tickCurrentCast(next, deltaSeconds) - for (const member of next.party) tickTargetable(member, deltaSeconds) + for (const member of next.party) tickTargetable(next, member, deltaSeconds) movePlayer(next, input, deltaSeconds) @@ -368,6 +395,7 @@ export function updateBulldromeState( resolvePartyDamage(next, deltaSeconds) updateEnemies(next, deltaSeconds) updateFireHazards(next, deltaSeconds) + updateSpinningBlades(next, deltaSeconds) resolveContact(next) startPlayerSpell(next, input.castSpell) resolveResult(next) @@ -382,9 +410,13 @@ export function getRaidFrames(state: BulldromeState): RaidFrame[] { role: unit.role, hp: unit.hp, maxHp: unit.maxHp, + healOverTimes: getActiveHealOverTimeFrames(unit), renewTimer: unit.renewTimer, + burnTimer: unit.burnTimer, + bleedTimer: unit.bleedTimer, shield: unit.shield, selected: unit.id === state.targetId, + damageDone: state.damageDoneByUnit[unit.id] ?? 0, })) } @@ -400,10 +432,8 @@ export function getEnemyFrames(state: BulldromeState): EnemyFrame[] { } export function getEncounterTitle(state: BulldromeState) { - if (state.dungeonId === 'yian-kut-ku' && state.encounterStep === 'trash') return 'Bird Flock' - if (state.dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku + Birds' - if (state.encounterStep === 'trash') return 'Bullfango Pack' - if (isHighActionTier(state.difficulty)) return 'Bulldrome + Bullfangos' + if (state.dungeonId === 'cyber-dragon') return 'Cyber Dragon' + if (state.dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku' return 'Bulldrome' } @@ -448,35 +478,42 @@ function createPartyMember( radius: role === 'tank' ? 17 : 14, hp: maxHp, maxHp, - shield: 0, - stunTimer: 0, - invulnerableTimer: 0, - renewTimer: 0, + shield: 0, + stunTimer: 0, + invulnerableTimer: 0, + healOverTimes: [], + renewTimer: 0, renewTickTimer: 0, burnTimer: 0, burnTickTimer: 0, + bleedTimer: 0, + bleedTickTimer: 0, aiSlot, } } function createBulldrome(): EnemyState { - return createEnemy('bulldrome', 'Bulldrome', 'bulldrome', 520, 34, 480, 154, 0) + return createEnemy('bulldrome', 'Bulldrome', 'bulldrome', 1320, 34, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } function createYianKutKu(): EnemyState { - return createEnemy('yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku', 620, 32, 480, 154, 0) + return createEnemy('yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku', 1540, 32, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } -function createBullfango(id: string, name: string, x: number, y: number, timerOffset: number): EnemyState { - return createEnemy(id, name, 'bullfango', 125, 22, x, y, timerOffset) +function createCyberDragon(): EnemyState { + return createEnemy('cyber-dragon', 'Cyber Dragon', 'cyber-dragon', 1880, 36, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0) } -function createBird(id: string, name: string, x: number, y: number, timerOffset: number, flightY: number): EnemyState { - const bird = createEnemy(id, name, 'bird', 112, 18, x, y, timerOffset) - bird.phase = 'mauling' - bird.phaseTimer = BIRD_FLIGHT_TIMER_SECONDS - bird.flightY = flightY - return bird +function createInitialEnemy(dungeonId: ActionDungeonId): EnemyState { + if (dungeonId === 'cyber-dragon') return createCyberDragon() + if (dungeonId === 'yian-kut-ku') return createYianKutKu() + return createBulldrome() +} + +function getDungeonStartMessage(dungeonId: ActionDungeonId) { + if (dungeonId === 'cyber-dragon') return 'Cyber Dragon boots its blade array.' + if (dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku watches the roost.' + return 'Bulldrome blocks the hunting grounds.' } function createEnemy( @@ -508,6 +545,18 @@ function createEnemy( tankSwingTimer: getTankSwingSeconds(kind), flightStep: 'ground', flightY: y, + orbitAngle: 0, + mechanicTimers: { + spinningBlade: getAttackFrequency(kind, 'spinningBlade', CYBER_BLADE_SPAWN_SECONDS), + }, + arcTelegraph: { + active: false, + center: { x, y }, + radius: 0, + startAngle: 0, + endAngle: 0, + width: 0, + }, telegraph: { active: false, start: { x, y }, @@ -522,9 +571,15 @@ function createEnemy( } function tickSpellCooldowns(cooldowns: Record, deltaSeconds: number) { - for (const slot of [1, 2, 3, 4, 5] as const) { - cooldowns[slot] = Math.max(0, cooldowns[slot] - deltaSeconds) - } + tickSharedSpellCooldowns(cooldowns, deltaSeconds) +} + +function tickPlayerGlobalCooldown(state: BulldromeState, deltaSeconds: number) { + state.player.globalCooldown = Math.max(0, state.player.globalCooldown - deltaSeconds) +} + +function triggerPlayerGlobalCooldown(state: BulldromeState) { + state.player.globalCooldown = SPELL_GLOBAL_COOLDOWN_SECONDS } function tickCurrentCast(state: BulldromeState, deltaSeconds: number) { @@ -532,7 +587,7 @@ function tickCurrentCast(state: BulldromeState, deltaSeconds: number) { if (!cast) return cast.remaining = Math.max(0, cast.remaining - deltaSeconds) - state.message = `Casting ${SPELLS[cast.spell].name} on ${findTarget(state, cast.targetId)?.name ?? 'target'}.` + state.message = `Casting ${getDungeonSpell(state, cast.spell).name} on ${findTarget(state, cast.targetId)?.name ?? 'target'}.` if (cast.remaining > 0) return const completed = { ...cast } @@ -540,7 +595,7 @@ function tickCurrentCast(state: BulldromeState, deltaSeconds: number) { completePlayerSpell(state, completed.spell, completed.targetId) } -function tickTargetable(unit: TargetableState, deltaSeconds: number) { +function tickTargetable(state: BulldromeState, unit: TargetableState, deltaSeconds: number) { unit.stunTimer = Math.max(0, unit.stunTimer - deltaSeconds) unit.invulnerableTimer = Math.max(0, unit.invulnerableTimer - deltaSeconds) if (unit.burnTimer > 0) { @@ -551,13 +606,19 @@ function tickTargetable(unit: TargetableState, deltaSeconds: number) { unit.burnTickTimer += FIRE_DOT_TICK_SECONDS } } - if (unit.renewTimer <= 0) return - - unit.renewTimer = Math.max(0, unit.renewTimer - deltaSeconds) - unit.renewTickTimer -= deltaSeconds - if (unit.renewTickTimer <= 0) { - unit.hp = Math.min(unit.maxHp, unit.hp + RENEW_HEAL) - unit.renewTickTimer += RENEW_TICK_SECONDS + if (unit.bleedTimer > 0) { + unit.bleedTimer = Math.max(0, unit.bleedTimer - deltaSeconds) + unit.bleedTickTimer -= deltaSeconds + if (unit.bleedTickTimer <= 0) { + applyDamage(unit, CYBER_BLEED_DAMAGE) + unit.bleedTickTimer += CYBER_BLEED_TICK_SECONDS + } + } + if (unit.healOverTimes?.length || unit.renewTimer > 0) { + tickHealOverTimes(unit, deltaSeconds, (effect) => { + healTarget(state, unit, effect.tickHeal) + addHealEvent(state, unit, 0) + }) } } @@ -568,17 +629,23 @@ function movePlayer(state: BulldromeState, input: BossInput, deltaSeconds: numbe } const length = Math.hypot(input.xAxis, input.yAxis) - if (length <= 0) return + if (length <= 0.01) return - const speed = PLAYER_SPEED * deltaSeconds - state.player.x += (input.xAxis / length) * speed - state.player.y += (input.yAxis / length) * speed + state.player.facingX = input.xAxis / length + state.player.facingY = input.yAxis / length + state.player.x += state.player.facingX * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds + state.player.y += state.player.facingY * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds clampToArena(state, state.player) } function updatePartyAi(state: BulldromeState, deltaSeconds: number) { for (const member of state.party) { if (member.hp <= 0 || member.stunTimer > 0) continue + const hazardAvoidance = getDamagingEffectAvoidance(state, member) + if (hazardAvoidance) { + moveToward(state, member, hazardAvoidance.point, hazardAvoidance.moveSpeed * deltaSeconds) + continue + } const desired = getAiDesiredPoint(state, member) moveToward(state, member, desired, AI_MOVE_SPEED * deltaSeconds) } @@ -586,8 +653,6 @@ function updatePartyAi(state: BulldromeState, deltaSeconds: number) { function getAiDesiredPoint(state: BulldromeState, member: PartyMemberState): Point { const target = getAiTargetForMember(state, member) - const dodgePoint = getTelegraphDodgePoint(state, member) - if (dodgePoint) return dodgePoint if (!target) return { x: member.x, y: member.y } @@ -658,6 +723,11 @@ function updateEnemy(state: BulldromeState, enemy: EnemyState, deltaSeconds: num return } + if (enemy.kind === 'cyber-dragon') { + updateCyberDragon(state, enemy, deltaSeconds) + return + } + if (enemy.phase === 'tracking') { enemy.targetId = getTankTargetId(state, enemy) const target = findTarget(state, enemy.targetId) ?? state.player @@ -780,7 +850,7 @@ function updateYianKutKu(state: BulldromeState, enemy: EnemyState, deltaSeconds: } if (enemy.phase === 'windup') { - state.message = 'Fireballs: northeast, northwest, south.' + state.message = 'Fireballs: northeast and northwest.' if (enemy.phaseTimer <= 0) { spawnYianFireballs(state, enemy) enemy.phase = 'recovering' @@ -826,7 +896,6 @@ function spawnYianFireballs(state: BulldromeState, enemy: EnemyState) { const directions = [ { x: 0.72, y: -0.7 }, { x: -0.72, y: -0.7 }, - { x: 0, y: 1 }, ] for (const direction of directions) { state.fireballs.push({ @@ -918,6 +987,226 @@ function updateBird(state: BulldromeState, enemy: EnemyState, deltaSeconds: numb } } +function updateCyberDragon(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { + updateSpinningBladeSpawner(state, enemy, deltaSeconds) + + const circularCharge = getEnabledActionAttack(enemy.kind, 'circularCharge') + enemy.slamTelegraph.active = false + + if (enemy.phase === 'tracking' || enemy.phase === 'mauling') { + enemy.telegraph.active = false + enemy.arcTelegraph.active = false + enemy.targetId = getTankTargetId(state, enemy) + const tank = findTarget(state, enemy.targetId) ?? state.player + driftTowardTarget(state, enemy, tank, deltaSeconds) + enemy.tankSwingTimer -= deltaSeconds + state.message = `Cyber Dragon claws ${tank.name}.` + if (enemy.tankSwingTimer <= 0) { + applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) + tank.invulnerableTimer = 0.12 + state.lastHit = tank.id === 'player' ? 'player' : 'party' + enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) + } + if (enemy.phaseTimer <= 0) { + if (circularCharge) startCircularCharge(state, enemy) + else enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + } + return + } + + if (enemy.phase === 'circling') { + enemy.telegraph.active = false + enemy.orbitAngle += Math.PI * 1.7 * deltaSeconds + const center = getArenaCenter(state) + const radius = circularCharge?.radius ?? CYBER_CIRCLE_RADIUS + syncCircularChargeTelegraph(enemy, center, radius) + enemy.x = center.x + Math.cos(enemy.orbitAngle) * radius + enemy.y = center.y + Math.sin(enemy.orbitAngle) * radius + clampToArena(state, enemy) + state.message = 'Cyber Dragon circles the room.' + if (enemy.phaseTimer <= 0) startCyberChargeWindup(state, enemy) + return + } + + if (enemy.phase === 'windup') { + enemy.arcTelegraph.active = false + enemy.telegraph.active = true + state.message = 'Cyber Dragon locks a charge lane.' + if (enemy.phaseTimer <= 0) { + enemy.phase = 'charging' + enemy.phaseTimer = CYBER_CHARGE_SECONDS + state.message = 'Cyber Dragon charges.' + } + return + } + + if (enemy.phase === 'charging') { + enemy.arcTelegraph.active = false + enemy.telegraph.active = true + enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds + enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds + clampToArena(state, enemy) + if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) { + enemy.phase = 'recovering' + enemy.phaseTimer = getRecoverSeconds(enemy.kind) + enemy.telegraph.active = false + state.message = 'Cyber Dragon vents heat.' + } + return + } + + if (enemy.phase === 'recovering') { + enemy.telegraph.active = false + enemy.arcTelegraph.active = false + state.message = 'Cyber Dragon vents heat.' + if (enemy.phaseTimer <= 0) { + enemy.phase = 'mauling' + enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + enemy.tankSwingTimer = 0.35 + } + } +} + +function updateSpinningBladeSpawner(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { + const attack = getEnabledActionAttack(enemy.kind, 'spinningBlade') + if (!attack || enemy.hp <= 0) return + + const timer = (enemy.mechanicTimers.spinningBlade ?? attack.frequencySeconds) - deltaSeconds + if (timer > 0) { + enemy.mechanicTimers.spinningBlade = timer + return + } + + spawnSpinningBlade(state, enemy, attack) + enemy.mechanicTimers.spinningBlade = attack.frequencySeconds +} + +function spawnSpinningBlade(state: BulldromeState, enemy: EnemyState, attack: NonNullable>) { + const target = chooseRandomLivingTarget(state) + if (!target) return + + state.spinningBlades.push({ + id: `spinning-blade-${state.nextHazardId}`, + sourceKind: enemy.kind, + x: enemy.x, + y: enemy.y, + radius: attack.radius ?? CYBER_BLADE_RADIUS, + speed: attack.speed ?? CYBER_BLADE_SPEED, + remaining: attack.recoverSeconds ?? attack.durationSeconds ?? CYBER_BLADE_SECONDS, + targetId: target.id, + rotation: 0, + }) + state.nextHazardId += 1 + state.message = `Spinning blade targets ${target.name}.` +} + +function updateSpinningBlades(state: BulldromeState, deltaSeconds: number) { + const nextBlades: SpinningBladeState[] = [] + for (const blade of state.spinningBlades) { + const attack = getEnabledActionAttack(blade.sourceKind, 'spinningBlade') + if (!attack) continue + + const next = { + ...blade, + remaining: blade.remaining - deltaSeconds, + rotation: blade.rotation + deltaSeconds * 9, + } + if (next.remaining <= 0) continue + + let target = findTarget(state, next.targetId) + if (!target || target.hp <= 0) { + target = chooseRandomLivingTarget(state) + if (target) next.targetId = target.id + } + + if (target) { + const dx = target.x - next.x + const dy = target.y - next.y + const length = Math.hypot(dx, dy) + if (length > 1) { + const move = Math.min(length, next.speed * deltaSeconds) + next.x += (dx / length) * move + next.y += (dy / length) * move + } + } + + for (const unit of getTargetableUnits(state)) { + if (unit.hp <= 0 || distance(unit, next) > unit.radius + next.radius) continue + applyDamage(unit, scaleDamageForDifficulty(state, attack.damage) * deltaSeconds) + applyBleed(unit) + state.lastHit = unit.id === 'player' ? 'player' : 'party' + state.message = `${unit.name} is clipped by a spinning blade.` + } + + nextBlades.push(next) + } + state.spinningBlades = nextBlades +} + +function applyBleed(unit: TargetableState) { + unit.bleedTimer = CYBER_BLEED_SECONDS + unit.bleedTickTimer = Math.max(0.15, Math.min(unit.bleedTickTimer || CYBER_BLEED_TICK_SECONDS, CYBER_BLEED_TICK_SECONDS)) +} + +function chooseRandomLivingTarget(state: BulldromeState) { + const living = getTargetableUnits(state).filter((unit) => unit.hp > 0) + if (living.length <= 0) return undefined + return living[Math.floor(Math.random() * living.length)] +} + +function startCircularCharge(state: BulldromeState, enemy: EnemyState) { + const attack = getEnabledActionAttack(enemy.kind, 'circularCharge') + const center = getArenaCenter(state) + enemy.phase = 'circling' + enemy.phaseTimer = attack?.windupSeconds ?? CYBER_CIRCLE_SECONDS + enemy.orbitAngle = Math.atan2(enemy.y - center.y, enemy.x - center.x) + enemy.telegraph.active = false + syncCircularChargeTelegraph(enemy, center, attack?.radius ?? CYBER_CIRCLE_RADIUS) + state.message = 'Cyber Dragon starts a circular charge.' +} + +function syncCircularChargeTelegraph(enemy: EnemyState, center: Point, radius: number) { + enemy.arcTelegraph = { + active: true, + center, + radius, + startAngle: enemy.orbitAngle, + endAngle: enemy.orbitAngle + Math.PI, + width: 72, + } +} + +function startCyberChargeWindup(state: BulldromeState, enemy: EnemyState) { + const target = selectNearestTarget(state, enemy) ?? state.player + const dx = target.x - enemy.x + const dy = target.y - enemy.y + const length = Math.max(1, Math.hypot(dx, dy)) + const chargeVector = { x: dx / length, y: dy / length } + const end = projectToArenaWall(state, enemy, chargeVector) + + enemy.phase = 'windup' + enemy.phaseTimer = 0.42 + enemy.targetId = target.id + enemy.chargeVector = chargeVector + enemy.telegraph = { + active: true, + start: { x: enemy.x, y: enemy.y }, + end, + width: 72, + } + enemy.chargeCount += 1 +} + +function selectNearestTarget(state: BulldromeState, enemy: EnemyState) { + return getTargetableUnits(state) + .filter((unit) => unit.hp > 0) + .sort((a, b) => distance(a, enemy) - distance(b, enemy))[0] ?? null +} + +function getArenaCenter(state: BulldromeState): Point { + return { x: state.arena.width / 2, y: state.arena.height / 2 } +} + function startBirdFlight(state: BulldromeState, enemy: EnemyState) { enemy.phase = 'windup' enemy.flightStep = 'toLeft' @@ -958,7 +1247,7 @@ function resolveGroundSlam(state: BulldromeState, enemy: EnemyState) { if (unit.hp <= 0 || distance(unit, enemy) > (slam.radius ?? BOSS_SLAM_RADIUS)) continue applyDamage(unit, scaleDamageForDifficulty(state, slam.damage)) unit.stunTimer = 0.35 - unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds hitCount += 1 } state.lastHit = hitCount > 0 ? 'party' : null @@ -980,7 +1269,7 @@ function resolvePartyDamage(state: BulldromeState, deltaSeconds: number) { if (member.role === 'ranged' && enemyDistance <= RANGED_ATTACK_RANGE) damage = RANGED_DPS * deltaSeconds if (damage <= 0) continue - target.hp = Math.max(0, target.hp - damage) + damageEnemy(state, target, damage, member.id) if (target.hp <= 0) { target.phase = 'defeated' target.telegraph.active = false @@ -1028,7 +1317,7 @@ function updateFireHazards(state: BulldromeState, deltaSeconds: number) { igniteUnit(unit) addFireSpot(state, next.x, next.y) bounceFireballOffUnit(next, unit) - unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = `${unit.name} was hit by a fireball.` } @@ -1068,6 +1357,9 @@ function addFireSpot(state: BulldromeState, x: number, y: number) { remaining: FIRE_SPOT_SECONDS, }) state.nextHazardId += 1 + if (state.fireSpots.length > MAX_FIRE_SPOTS) { + state.fireSpots = state.fireSpots.slice(-MAX_FIRE_SPOTS) + } } function igniteUnit(unit: TargetableState) { @@ -1081,21 +1373,13 @@ function getAiTargetForMember(state: BulldromeState, member: PartyMemberState): if (member.role === 'tank') return getPrimaryTankEnemy(state) - if (state.encounterStep === 'trash') { - return enemies[member.aiSlot % enemies.length] ?? enemies[0] - } - - if (isHighActionTier(state.difficulty) && state.dungeonId === 'bulldrome') { - if (member.id === 'melee-1') return enemies.find((enemy) => enemy.id === 'hard-bullfango-1') ?? enemies[0] - if (member.id === 'ranged-1') return enemies.find((enemy) => enemy.id === 'hard-bullfango-2') ?? enemies[0] - } - - return enemies.find((enemy) => enemy.kind === 'bulldrome' || enemy.kind === 'yian-kut-ku') ?? enemies[0] + void member + return state.boss.hp > 0 ? state.boss : enemies[0] } function getPrimaryTankEnemy(state: BulldromeState): EnemyState | null { const enemies = getLivingEnemies(state) - return enemies.find((enemy) => enemy.kind === 'bulldrome' || enemy.kind === 'yian-kut-ku') ?? enemies[0] ?? null + return state.boss.hp > 0 ? state.boss : enemies[0] ?? null } function getTankTargetId(state: BulldromeState, enemy: EnemyState) { @@ -1114,7 +1398,8 @@ function driftTowardTarget( const length = Math.hypot(dx, dy) if (length <= 1) return - const driftSpeed = (enemy.kind === 'bulldrome' ? 82 : enemy.kind === 'yian-kut-ku' ? 72 : 62) * deltaSeconds + enemy.chargeVector = { x: dx / length, y: dy / length } + const driftSpeed = (enemy.kind === 'bulldrome' ? 82 : enemy.kind === 'cyber-dragon' ? 74 : enemy.kind === 'yian-kut-ku' ? 72 : 62) * deltaSeconds enemy.x += (dx / length) * driftSpeed enemy.y += (dy / length) * driftSpeed clampToArena(state, enemy) @@ -1153,7 +1438,7 @@ function startWindup(state: BulldromeState, enemy: EnemyState) { function selectChargeTarget(state: BulldromeState, enemy: EnemyState) { const living = getTargetableUnits(state).filter((unit) => unit.hp > 0) - const preferred = enemy.kind === 'bulldrome' + const preferred = enemy.kind === 'bulldrome' || enemy.kind === 'cyber-dragon' ? ['player', 'tank', 'melee-1', 'ranged-1', 'ranged-2'] : ['tank', 'melee-1', 'player', 'ranged-1', 'ranged-2'] const offset = enemy.chargeCount % preferred.length @@ -1167,10 +1452,10 @@ function resolveContact(state: BulldromeState) { const overlap = unit.hp > 0 && distance(unit, enemy) < unit.radius + enemy.radius if (!overlap || unit.invulnerableTimer > 0) continue - const isCharge = enemy.phase === 'charging' + const isCharge = enemy.phase === 'charging' || enemy.phase === 'circling' applyDamage(unit, scaleDamageForDifficulty(state, isCharge ? getChargeDamage(enemy.kind) : getBodyDamage(enemy.kind))) - unit.stunTimer = isCharge ? PLAYER_STUN_SECONDS : 0.25 - unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + unit.stunTimer = isCharge && enemy.kind === 'cyber-dragon' ? CYBER_CHARGE_STUN_SECONDS : isCharge ? ACTION_CONTROL_RULES.stunSeconds : 0.25 + unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds state.lastHit = unit.id === 'player' ? 'player' : 'party' state.message = isCharge ? `${unit.name} ate ${enemy.name}'s charge.` @@ -1186,21 +1471,44 @@ function resolveContact(state: BulldromeState) { } } +function getDungeonSpell(state: BulldromeState, spell: SpellSlot): SpellDefinition { + return getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers) +} + function startPlayerSpell(state: BulldromeState, spell: SpellSlot | null) { - if (!spell || state.player.currentCast || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return - const target = findTarget(state, state.targetId) + if (!spell || state.player.currentCast || state.player.globalCooldown > 0 || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return + const target = findCombatTarget(state, state.targetId) if (!target || target.hp <= 0) { state.message = 'No living target selected.' return } - if (!isInHealRange(state, target)) { - showNotice(state, target.id, 'Not in range') - state.message = 'Not in range.' + const damageSpell = isDungeonDamageSpell(state, spell) + const enemyTarget = isEnemyTarget(state, target) + if (damageSpell && !enemyTarget) { + showNotice(state, state.player.id, 'Enemy target') + state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.` + return + } + if (!damageSpell && enemyTarget) { + showNotice(state, state.player.id, 'Ally target') + state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.` + return + } + const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell }) + if (!reach.ok) { + setDungeonSpellReachFailure(state, reach) + return + } + const spend = spendActionSpellMana(state.player, state.player.classId, spell, { talentMods: state.player.talentModifiers }) + if (!spend.ok) { + showNotice(state, state.player.id, 'Mana') + state.message = `Need ${spend.cost} mana.` return } - const definition = SPELLS[spell] + const definition = getDungeonSpell(state, spell) + triggerPlayerGlobalCooldown(state) if (definition.castTime > 0) { state.player.currentCast = { spell, @@ -1216,57 +1524,88 @@ function startPlayerSpell(state: BulldromeState, spell: SpellSlot | null) { } function completePlayerSpell(state: BulldromeState, spell: SpellSlot, targetId: string) { - const target = findTarget(state, targetId) + const target = findCombatTarget(state, targetId) if (!target || target.hp <= 0) { state.message = 'Spell fizzled. Target is down.' return } - if (!isInHealRange(state, target)) { - showNotice(state, target.id, 'Not in range') - state.message = 'Not in range.' + const damageSpell = isDungeonDamageSpell(state, spell) + const enemyTarget = isEnemyTarget(state, target) + if (damageSpell && !enemyTarget) { + state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.` + return + } + if (!damageSpell && enemyTarget) { + state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.` + return + } + const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell }) + if (!reach.ok) { + setDungeonSpellReachFailure(state, reach) return } - state.player.spellCooldowns[spell] = SPELLS[spell].cooldown - if (spell === 1) { - healTarget(state, target, MEND_HEAL) + state.player.spellCooldowns[spell] = getDungeonSpell(state, spell).cooldown + recordSpellCast(state.player) + + if (damageSpell) { + completePlayerDamageSpell(state, spell, target as EnemyState) + return + } + + const friendlyTarget = target as TargetableState + const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId) + if (ability.shield || ability.kind === 'buff') { + const shield = getActionTalentAdjustedAmount(state.player.classId, spell, ability.shield ?? (ability.id === 'blessing_of_might' ? 80 : 90), 'shield', state.player.talentModifiers) + addShield(friendlyTarget, shield, { cap: Math.max(shield, friendlyTarget.maxHp) }) + addHealEvent(state, friendlyTarget, 0) state.lastHit = 'heal' - state.message = `Mend healed ${target.name}.` + state.message = `${ability.name} protected ${friendlyTarget.name}.` return } - - if (spell === 2) { - target.renewTimer = RENEW_SECONDS - target.renewTickTimer = RENEW_TICK_SECONDS - healTarget(state, target, 4) + const healOverTimeId = getHealOverTimeEffectId(ability.id) + if (healOverTimeId) { + applyHealOverTimeTimers(friendlyTarget, healOverTimeId) + healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 28, 'heal', state.player.talentModifiers)) state.lastHit = 'heal' - state.message = `Renew on ${target.name}.` + state.message = `${ability.name} on ${friendlyTarget.name}.` return } - if (spell === 3) { - const injured = getTargetableUnits(state) - .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && isInHealRange(state, unit)) - .sort((a, b) => (b.maxHp - b.hp) - (a.maxHp - a.hp)) - .slice(0, 4) - for (const unit of injured) healTarget(state, unit, RADIANCE_HEAL) - state.lastHit = 'heal' - state.message = `Radiance healed ${injured.length || 0}.` - return - } - - if (spell === 4) { - target.shield = Math.min(60, target.shield + SUN_WARD_SHIELD) - state.lastHit = 'heal' - state.message = `Sun Ward shields ${target.name}.` - return - } - - healTarget(state, target, PURIFY_HEAL) - target.stunTimer = 0 + healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 120, 'heal', state.player.talentModifiers)) + addHealEvent(state, friendlyTarget, 0) state.lastHit = 'heal' - state.message = `Purify steadied ${target.name}.` + state.message = `${ability.name} healed ${friendlyTarget.name}.` +} + +function completePlayerDamageSpell(state: BulldromeState, spell: SpellSlot, target: EnemyState) { + const damage = getDungeonDamageSpellAmount(state, spell) + damageEnemy(state, target, damage, state.player.id) + state.lastHit = 'boss' + const spellDef = getDungeonSpell(state, spell) + state.message = `${spellDef.name} hit ${target.name}.` + if (spellDef.abilityId !== 'smite') return + + const ally = getTargetableUnits(state) + .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && isInHealRange(state, unit, spell)) + .sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0] + if (!ally) return + + healTarget(state, ally, damage * 0.5) + addHealEvent(state, ally, 0) + state.lastHit = 'heal' + state.message = `Smite hit ${target.name} and healed ${ally.name}.` +} + +function getDungeonDamageSpellAmount(state: BulldromeState, spell: SpellSlot) { + const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId) + const dotPulse = ability.dot ? ability.dot.damage / Math.max(1, ability.dot.seconds / 3) : 0 + return getActionTalentAdjustedAmount(state.player.classId, spell, (ability.damage ?? 0) + dotPulse, 'damage', state.player.talentModifiers) +} + +function isDungeonDamageSpell(state: BulldromeState, spell: SpellSlot) { + return getActionSpellTarget(state.player.classId, spell) === 'Enemy target' } function healTarget(state: BulldromeState, target: TargetableState, amount: number) { @@ -1275,15 +1614,49 @@ function healTarget(state: BulldromeState, target: TargetableState, amount: numb const healed = target.hp - before if (healed <= 0) return + addHealEvent(state, target, healed) +} + +function addHealEvent(state: BulldromeState, target: TargetableState, amount: number) { + if (state.healEvents.some((event) => event.targetId === target.id)) return + state.healEvents.push({ id: `${state.elapsed.toFixed(3)}-${target.id}-${state.healEvents.length}`, targetId: target.id, - amount: healed, + amount, }) } -function isInHealRange(state: BulldromeState, target: TargetableState) { - return distance(state.player, target) <= HEAL_RANGE +function isInHealRange(state: BulldromeState, target: TargetableState, spell: SpellSlot) { + return getDungeonSpellReach(state, target, spell).ok +} + +function getDungeonSpellReach( + state: BulldromeState, + target: CombatSpellTarget, + spell: SpellSlot, + options?: { includeTargetRadius?: boolean }, +) { + return getActionSpellReach({ + caster: state.player, + target, + classId: state.player.classId, + spell, + talentMods: state.player.talentModifiers, + includeTargetRadius: options?.includeTargetRadius, + hasLineOfSight: () => hasDungeonLineOfSight(), + }) +} + +function hasDungeonLineOfSight() { + return true +} + +function setDungeonSpellReachFailure(state: BulldromeState, reach: ActionSpellReachResult) { + if (reach.ok) return + const blocked = reach.reason === 'lineOfSight' + showNotice(state, state.player.id, blocked ? 'Blocked' : 'Range') + state.message = blocked ? 'Line of sight blocked.' : 'Not in range.' } function showNotice(state: BulldromeState, targetId: string, text: string) { @@ -1294,27 +1667,121 @@ function showNotice(state: BulldromeState, targetId: string, text: string) { }) } -function applyDamage(unit: TargetableState, amount: number) { - const absorbed = Math.min(unit.shield, amount) - unit.shield -= absorbed - unit.hp = Math.max(0, unit.hp - (amount - absorbed)) +function createDamageDoneTracker(): Record { + return { + player: 0, + tank: 0, + 'melee-1': 0, + 'ranged-1': 0, + 'ranged-2': 0, + } } -function getTelegraphDodgePoint(state: BulldromeState, member: PartyMemberState): Point | null { - const telegraphEnemy = getLivingEnemies(state).find((enemy) => ( - enemy.telegraph.active - && enemy.phase === 'windup' - && isPointNearSegment(member, enemy.telegraph.start, enemy.telegraph.end, enemy.telegraph.width * 0.42) - )) - if (!telegraphEnemy) return null +function damageEnemy(state: BulldromeState, target: EnemyState, amount: number, sourceId: string) { + const before = target.hp + target.hp = Math.max(0, target.hp - amount) + const dealt = Math.max(0, before - target.hp) + if (dealt > 0) state.damageDoneByUnit[sourceId] = (state.damageDoneByUnit[sourceId] ?? 0) + dealt +} - const vector = telegraphEnemy.chargeVector - const perpendicular = { x: -vector.y, y: vector.x } - const side = member.aiSlot % 2 === 0 ? 1 : -1 - return clampPointToArena(state, { - x: member.x + perpendicular.x * side * 112, - y: member.y + perpendicular.y * side * 112, - }, member.radius) +function applyDamage(unit: TargetableState, amount: number) { + applyShieldedDamage(unit, amount) +} + +function getDamagingEffectAvoidance(state: BulldromeState, member: PartyMemberState): { point: Point, moveSpeed: number } | null { + let escapeX = 0 + let escapeY = 0 + let moveSpeed = AI_MOVE_SPEED + let avoidDistance = 1 + + const fireAreaAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'persistentArea') + for (const spot of state.fireSpots) { + if (!fireAreaAvoidance) continue + const avoidRadius = member.radius + spot.radius + fireAreaAvoidance.buffer + addRepulsionFromPoint(member, spot, fireAreaAvoidance, (avoidRadius - distance(member, spot)) / avoidRadius) + } + + const fireballAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'projectile') + for (const fireball of state.fireballs) { + if (!fireballAvoidance) continue + const avoidRadius = member.radius + fireball.radius + fireballAvoidance.buffer + addRepulsionFromPoint(member, fireball, fireballAvoidance, (avoidRadius - distance(member, fireball)) / avoidRadius) + } + + for (const blade of state.spinningBlades) { + const bladeAvoidance = getActionAiAvoidance(blade.sourceKind, 'spinningBlade', 'persistentArea') + if (!bladeAvoidance) continue + const avoidRadius = member.radius + blade.radius + bladeAvoidance.buffer + addRepulsionFromPoint(member, blade, bladeAvoidance, (avoidRadius - distance(member, blade)) / avoidRadius) + } + + for (const enemy of getLivingEnemies(state)) { + const slamAvoidance = getActionAiAvoidance(enemy.kind, 'groundSlam', 'circleTelegraph') + if (enemy.slamTelegraph.active) { + if (slamAvoidance) { + const avoidRadius = enemy.slamTelegraph.radius + member.radius + slamAvoidance.buffer + addRepulsionFromPoint(member, enemy, slamAvoidance, (avoidRadius - distance(member, enemy)) / avoidRadius) + } + } + + const lineAttackKind: ActionAttackKind = enemy.kind === 'bird' + ? 'birdDive' + : enemy.kind === 'cyber-dragon' + ? 'circularCharge' + : 'charge' + const lineAvoidance = getActionAiAvoidance(enemy.kind, lineAttackKind, 'lineTelegraph') + if ( + lineAvoidance + && enemy.telegraph.active + && enemy.phase === 'windup' + && isPointNearSegment( + member, + enemy.telegraph.start, + enemy.telegraph.end, + enemy.telegraph.width * (lineAvoidance.lineWidthMultiplier ?? 0.42) + lineAvoidance.buffer, + ) + ) { + const perpendicular = { x: -enemy.chargeVector.y, y: enemy.chargeVector.x } + const side = member.aiSlot % 2 === 0 ? 1 : -1 + escapeX += perpendicular.x * side + escapeY += perpendicular.y * side + moveSpeed = Math.max(moveSpeed, lineAvoidance.moveSpeed) + avoidDistance = Math.max(avoidDistance, lineAvoidance.avoidDistance) + } + } + + const escapeLength = Math.hypot(escapeX, escapeY) + if (escapeLength <= 0.01) return null + + return { + point: clampPointToArena(state, { + x: member.x + (escapeX / escapeLength) * avoidDistance, + y: member.y + (escapeY / escapeLength) * avoidDistance, + }, member.radius), + moveSpeed, + } + + function addRepulsionFromPoint( + unit: TargetableState, + hazard: Point, + avoidance: ActionAiAvoidanceConfig, + weight: number, + ) { + if (weight <= 0) return + moveSpeed = Math.max(moveSpeed, avoidance.moveSpeed) + avoidDistance = Math.max(avoidDistance, avoidance.avoidDistance) + const dx = unit.x - hazard.x + const dy = unit.y - hazard.y + const length = Math.hypot(dx, dy) + if (length <= 0.01) { + const angle = member.aiSlot * 1.7 + escapeX += Math.cos(angle) * weight + escapeY += Math.sin(angle) * weight + return + } + escapeX += (dx / length) * weight + escapeY += (dy / length) * weight + } } function isPointNearSegment(point: Point, start: Point, end: Point, width: number) { @@ -1335,26 +1802,23 @@ function resolveResult(state: BulldromeState) { const livingEnemies = getLivingEnemies(state) if (livingEnemies.length <= 0) { - if (state.encounterStep === 'trash') { - startBossEncounter(state) - return - } - if (state.runMode === 'marathon') { state.bossKills += 1 state.respawnTimer = 5 state.fireballs = [] state.fireSpots = [] + state.spinningBlades = [] + state.damageDoneByUnit = createDamageDoneTracker() state.message = `Boss ${state.bossKills} down. Next boss in 5.` return } state.result = 'win' - state.message = state.dungeonId === 'yian-kut-ku' - ? 'Yian Kut-Ku defeated. Hunt complete.' - : isHighActionTier(state.difficulty) - ? 'Hard hunt complete. Bulldrome and Bullfangos down.' - : 'Bulldrome broken. Hunt complete.' + state.message = state.dungeonId === 'cyber-dragon' + ? 'Cyber Dragon disabled. Hunt complete.' + : state.dungeonId === 'yian-kut-ku' + ? 'Yian Kut-Ku defeated. Hunt complete.' + : 'Bulldrome broken. Hunt complete.' } if (state.player.hp <= 0) { @@ -1368,57 +1832,27 @@ function resolveResult(state: BulldromeState) { } } -function startBossEncounter(state: BulldromeState) { - state.encounterStep = 'boss' - if (state.dungeonId === 'yian-kut-ku') { - state.boss = createYianKutKu() - state.adds = [ - createBird('boss-bird-1', 'Bird', 342, 188, 0, 188), - createBird('boss-bird-2', 'Bird', 618, 188, 0, 318), - ] - scaleEnemyForDifficulty(state.difficulty, state.boss) - state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) - state.message = 'Birds scatter. Yian Kut-Ku lands with two birds.' - state.fireballs = [] - state.fireSpots = [] - return - } - - state.boss = createBulldrome() - state.adds = [] - scaleEnemyForDifficulty(state.difficulty, state.boss) - state.fireballs = [] - state.fireSpots = [] - state.message = 'Bullfangos down. Bulldrome crashes into the grounds.' -} - function respawnBossEncounter(state: BulldromeState) { state.encounterStep = 'boss' - if (state.dungeonId === 'yian-kut-ku') { - state.boss = createYianKutKu() - state.adds = [ - createBird(`marathon-bird-${state.bossKills}-1`, 'Bird', 342, 188, 0, 188), - createBird(`marathon-bird-${state.bossKills}-2`, 'Bird', 618, 188, 0, 318), - ] + if (state.dungeonId === 'cyber-dragon') { + state.boss = createCyberDragon() + state.adds = [] scaleEnemyForDifficulty(state.difficulty, state.boss) - state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) - state.message = 'Marathon continues. Yian Kut-Ku lands with two birds.' + state.message = 'Marathon continues. Cyber Dragon reboots.' + } else if (state.dungeonId === 'yian-kut-ku') { + state.boss = createYianKutKu() + state.adds = [] + scaleEnemyForDifficulty(state.difficulty, state.boss) + state.message = 'Marathon continues. Yian Kut-Ku returns.' } else { state.boss = createBulldrome() - state.adds = isHighActionTier(state.difficulty) - ? [ - createBullfango(`marathon-bullfango-${state.bossKills}-1`, 'Bullfango A', 342, 188, 0.55), - createBullfango(`marathon-bullfango-${state.bossKills}-2`, 'Bullfango B', 618, 188, 1.1), - ] - : [] + state.adds = [] scaleEnemyForDifficulty(state.difficulty, state.boss) - state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) - state.message = isHighActionTier(state.difficulty) - ? 'Marathon continues. Bulldrome returns with Bullfangos.' - : 'Marathon continues. Bulldrome returns.' + state.message = 'Marathon continues. Bulldrome returns.' } state.fireballs = [] state.fireSpots = [] + state.spinningBlades = [] state.respawnTimer = 0 } @@ -1426,8 +1860,20 @@ function findTarget(state: BulldromeState, id: string) { return getTargetableUnits(state).find((unit) => unit.id === id) } +function findEnemyTarget(state: BulldromeState, id: string) { + return getAllEnemies(state).find((enemy) => enemy.id === id) +} + +function findCombatTarget(state: BulldromeState, id: string): CombatSpellTarget | undefined { + return findTarget(state, id) ?? findEnemyTarget(state, id) +} + +function isEnemyTarget(state: BulldromeState, target: CombatSpellTarget): target is EnemyState { + return getAllEnemies(state).some((enemy) => enemy.id === target.id) +} + function setTarget(state: BulldromeState, id: string) { - const target = findTarget(state, id) + const target = findCombatTarget(state, id) if (target) state.targetId = target.id } @@ -1450,7 +1896,7 @@ function clampToArena(state: BulldromeState, fighter: FighterState) { } function clampPointToArena(state: BulldromeState, point: Point, radius: number): Point { - return { + const clamped = { x: Math.max( state.arena.padding + radius, Math.min(state.arena.width - state.arena.padding - radius, point.x), @@ -1460,6 +1906,7 @@ function clampPointToArena(state: BulldromeState, point: Point, radius: number): Math.min(state.arena.height - state.arena.padding - radius, point.y), ), } + return clamped } function hitArenaWall(state: BulldromeState, fighter: FighterState) { @@ -1491,81 +1938,43 @@ function projectToArenaWall(state: BulldromeState, start: Point, vector: Point): } function getWindupSeconds(kind: EnemyKind) { - const fallback = kind === 'bulldrome' ? BOSS_WINDUP_SECONDS : BULLFANGO_WINDUP_SECONDS - return getAttackNumber(kind, 'charge', 'windupSeconds', fallback) + return getEnemyWindupSeconds(kind) } function getChargeSeconds(kind: EnemyKind) { - if (kind === 'yian-kut-ku' || kind === 'bird') return 0 - return kind === 'bulldrome' ? BOSS_CHARGE_SECONDS : BULLFANGO_CHARGE_SECONDS + return getEnemyChargeSeconds(kind) } function getChargeSpeed(kind: EnemyKind) { - if (kind === 'yian-kut-ku') return 0 - if (kind === 'bird') return getAttackNumber(kind, 'birdDive', 'speed', BIRD_FLIGHT_SPEED) - const fallback = kind === 'bulldrome' ? BOSS_CHARGE_SPEED : BULLFANGO_CHARGE_SPEED - return getAttackNumber(kind, 'charge', 'speed', fallback) + return getEnemyChargeSpeed(kind) } function getRecoverSeconds(kind: EnemyKind) { - if (kind === 'yian-kut-ku') return getAttackNumber(kind, 'fireballVolley', 'recoverSeconds', YIAN_RECOVER_SECONDS) - if (kind === 'bird') return 0 - const fallback = kind === 'bulldrome' ? BOSS_RECOVER_SECONDS : BULLFANGO_RECOVER_SECONDS - return getAttackNumber(kind, 'charge', 'recoverSeconds', fallback) + return getEnemyRecoverSeconds(kind) } function getTankFocusSeconds(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) - const fallback = kind === 'bulldrome' ? BOSS_TANK_FOCUS_SECONDS : BULLFANGO_TANK_FOCUS_SECONDS - return getAttackFrequency(kind, 'charge', fallback) + return getEnemyTankFocusSeconds(kind) } function getTankSwingSeconds(kind: EnemyKind) { - const fallback = kind === 'yian-kut-ku' - ? YIAN_TANK_SWING_SECONDS - : kind === 'bird' - ? BIRD_TANK_SWING_SECONDS - : kind === 'bulldrome' - ? BOSS_TANK_SWING_SECONDS - : BULLFANGO_TANK_SWING_SECONDS - return getAttackFrequency(kind, 'tankMelee', fallback) + return getEnemyTankSwingSeconds(kind) } function getTankSwingDamage(kind: EnemyKind) { - const fallback = kind === 'yian-kut-ku' - ? YIAN_TANK_SWING_DAMAGE - : kind === 'bird' - ? BIRD_TANK_SWING_DAMAGE - : kind === 'bulldrome' - ? BOSS_TANK_SWING_DAMAGE - : BULLFANGO_TANK_SWING_DAMAGE - return getAttackDamage(kind, 'tankMelee', fallback) + return getEnemyTankSwingDamage(kind) } function getChargeDamage(kind: EnemyKind) { - if (kind === 'yian-kut-ku') return 0 - if (kind === 'bird') return getAttackDamage(kind, 'birdDive', BIRD_CHARGE_DAMAGE) - const fallback = kind === 'bulldrome' ? BOSS_CHARGE_DAMAGE : BULLFANGO_CHARGE_DAMAGE - return getAttackDamage(kind, 'charge', fallback) + return getEnemyChargeDamage(kind) } function getBodyDamage(kind: EnemyKind) { - const fallback = kind === 'yian-kut-ku' - ? YIAN_BODY_DAMAGE - : kind === 'bird' - ? BIRD_BODY_DAMAGE - : kind === 'bulldrome' - ? BOSS_BODY_DAMAGE - : BULLFANGO_BODY_DAMAGE - return getAttackDamage(kind, 'bodyContact', fallback) + return getEnemyBodyDamage(kind) } function getTrackSeconds(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) - const fallback = kind === 'bulldrome' ? BOSS_TRACK_SECONDS : BULLFANGO_TRACK_SECONDS - return getAttackFrequency(kind, 'charge', fallback) + return getEnemyTrackSeconds(kind) } function getAttackFrequency(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) { @@ -1579,6 +1988,10 @@ function getAttackDamage(kind: EnemyKind, attackKind: ActionAttackKind, fallback } function applyDifficultyScaling(state: BulldromeState) { + scaleCurrentEnemiesForDifficulty(state) +} + +function scaleCurrentEnemiesForDifficulty(state: BulldromeState) { for (const enemy of getAllEnemies(state)) { scaleEnemyForDifficulty(state.difficulty, enemy) } @@ -1594,23 +2007,19 @@ function scaleDamageForDifficulty(state: BulldromeState, damage: number) { return Math.ceil(damage * getActionDifficultyTier(state.difficulty).damageMultiplier) } -function isHighActionTier(difficulty: ActionDifficulty) { - return getActionDifficultyTier(difficulty).itemLevel > 1 -} - -function getAttackNumber( - kind: EnemyKind, - attackKind: ActionAttackKind, - field: 'windupSeconds' | 'recoverSeconds' | 'speed' | 'radius' | 'everyNthCharge', - fallback: number, -) { - return getActionAttack(kind, attackKind)?.[field] ?? fallback -} - function cloneEnemy(enemy: EnemyState): EnemyState { return { ...enemy, chargeVector: { ...enemy.chargeVector }, + mechanicTimers: { ...enemy.mechanicTimers }, + arcTelegraph: { + active: enemy.arcTelegraph.active, + center: { ...enemy.arcTelegraph.center }, + radius: enemy.arcTelegraph.radius, + startAngle: enemy.arcTelegraph.startAngle, + endAngle: enemy.arcTelegraph.endAngle, + width: enemy.arcTelegraph.width, + }, slamTelegraph: { ...enemy.slamTelegraph }, telegraph: { active: enemy.telegraph.active, @@ -1628,19 +2037,27 @@ function cloneState(state: BulldromeState): BulldromeState { runMode: state.runMode, encounterStep: state.encounterStep, arena: { ...state.arena }, - player: { - ...state.player, - currentCast: state.player.currentCast ? { ...state.player.currentCast } : null, - spellCooldowns: { ...state.player.spellCooldowns }, - }, - party: state.party.map((member) => ({ ...member })), + player: { + ...state.player, + currentCast: state.player.currentCast ? { ...state.player.currentCast } : null, + healOverTimes: state.player.healOverTimes?.map((effect) => ({ ...effect })), + spellCooldowns: { ...state.player.spellCooldowns }, + }, + party: state.party.map((member) => ({ + ...member, + healOverTimes: member.healOverTimes?.map((effect) => ({ ...effect })), + })), targetId: state.targetId, boss: cloneEnemy(state.boss), adds: state.adds.map(cloneEnemy), fireballs: state.fireballs.map((fireball) => ({ ...fireball })), fireSpots: state.fireSpots.map((spot) => ({ ...spot })), + spinningBlades: state.spinningBlades.map((blade) => ({ ...blade })), nextHazardId: state.nextHazardId, + damageDoneByUnit: { ...state.damageDoneByUnit }, bossKills: state.bossKills, + dungeonWaveIndex: state.dungeonWaveIndex, + dungeonWaveTotal: state.dungeonWaveTotal, respawnTimer: state.respawnTimer, healEvents: state.healEvents.map((event) => ({ ...event })), noticeEvents: state.noticeEvents.map((event) => ({ ...event })), diff --git a/src/actionBoss/actionEncounterConfig.ts b/src/actionBoss/actionEncounterConfig.ts index 0401b20..d604013 100644 --- a/src/actionBoss/actionEncounterConfig.ts +++ b/src/actionBoss/actionEncounterConfig.ts @@ -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> } export type ActionEnemyMechanicConfig = { @@ -31,7 +49,7 @@ export type ActionEnemyMechanicConfig = { export type ActionMechanicConfig = Record -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 { + return { + enabled: true, + avoidDistance: 132, + buffer: 0, + moveSpeed: 190, + ...options, + } +} + function mergeMechanicConfig(saved: Partial) { const merged = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) for (const kind of Object.keys(merged) as EnemyKind[]) { @@ -161,15 +333,38 @@ function mergeMechanicConfig(saved: Partial) { ...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) } diff --git a/src/actionBoss/actionEnemyRuntime.ts b/src/actionBoss/actionEnemyRuntime.ts new file mode 100644 index 0000000..c3ec2ca --- /dev/null +++ b/src/actionBoss/actionEnemyRuntime.ts @@ -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 +} diff --git a/src/actionClassKits.ts b/src/actionClassKits.ts new file mode 100644 index 0000000..d73aedf --- /dev/null +++ b/src/actionClassKits.ts @@ -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> = { + 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> = { + 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 = { + 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 = { + 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 +} + +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> = {}, +): ArenaAbilityDefinition { + return { animation, castTime, cooldown, id, kind, name, range, school, ...effects } +} diff --git a/src/actionCombatCore.ts b/src/actionCombatCore.ts new file mode 100644 index 0000000..f70315d --- /dev/null +++ b/src/actionCombatCore.ts @@ -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 + manaCosts: Record + ranks: Record + schools: Record + targets: Record + descriptions: Record + 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 + +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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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, + manaCosts: Record, + ranks: Record, + schools: Record, + targets: Record, + descriptions: Record, +): 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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 = { + regrowth: 'Regrowth', + rejuvenation: 'Rejuvenation', + renew: 'Renew', +} diff --git a/src/actionCombatProfile.ts b/src/actionCombatProfile.ts new file mode 100644 index 0000000..da6cb4a --- /dev/null +++ b/src/actionCombatProfile.ts @@ -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: [], + } +} diff --git a/src/actionCombatTypes.ts b/src/actionCombatTypes.ts new file mode 100644 index 0000000..ddea9d3 --- /dev/null +++ b/src/actionCombatTypes.ts @@ -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 +} diff --git a/src/actionMode.ts b/src/actionMode.ts index 073c318..894ab41 100644 --- a/src/actionMode.ts +++ b/src/actionMode.ts @@ -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> +export type ActionEquipment = Partial> +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 = { + 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 = { + 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 - 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) { - const fullItem = item as Partial> - 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 & Partial>) { - 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): 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) { + 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) { - 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() + + 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() + for (const item of raw) { + if (!item || typeof item !== 'object') continue + const candidate = item as Partial & { 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 & { character?: unknown } + const rawCharacters = Array.isArray(parsed.characters) + ? parsed.characters + : parsed.character + ? [parsed.character] + : [] + const seenIds = new Set() + 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 & { 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 : {} + 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) { - const itemWithLevel = item as Pick & Partial> - 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> + 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) { 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) } diff --git a/src/actionModelAssets.ts b/src/actionModelAssets.ts new file mode 100644 index 0000000..867b69a --- /dev/null +++ b/src/actionModelAssets.ts @@ -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' } +} diff --git a/src/admin/main.tsx b/src/admin/main.tsx new file mode 100644 index 0000000..fa62af2 --- /dev/null +++ b/src/admin/main.tsx @@ -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 ( +
+
+
+
+

Admin

+

Model Viewer

+
+
+ +
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/claudeCraftDungeonRuntime.ts b/src/claudeCraftDungeonRuntime.ts new file mode 100644 index 0000000..3f54f10 --- /dev/null +++ b/src/claudeCraftDungeonRuntime.ts @@ -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 = { + '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 +} diff --git a/src/claudeCraftDungeons.ts b/src/claudeCraftDungeons.ts new file mode 100644 index 0000000..875361c --- /dev/null +++ b/src/claudeCraftDungeons.ts @@ -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> = {}, +): 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 = { + 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 = { + 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'], +} diff --git a/src/claudeCraftEquipmentRules.ts b/src/claudeCraftEquipmentRules.ts new file mode 100644 index 0000000..cbfc3e8 --- /dev/null +++ b/src/claudeCraftEquipmentRules.ts @@ -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(['warrior', 'paladin', 'shaman']) +const LEATHER_CLASSES = new Set(['druid', 'rogue', 'hunter']) +const CLOTH_CLASSES = new Set(['mage', 'priest', 'warlock']) +const CASTER_ARCHETYPE_CLASSES = new Set(['mage', 'priest', 'warlock', 'druid']) +const WARRIOR_WEAPON_CLASSES = new Set(['warrior', 'rogue', 'hunter', 'shaman', 'paladin']) +const CASTER_WEAPON_CLASSES = new Set(['mage', 'priest', 'warlock', 'shaman', 'paladin', 'druid']) +const ROGUE_WEAPON_CLASSES = new Set(['rogue', 'hunter']) +const OLD_WARRIOR_WEAPON_ARCHETYPE = new Set(['warrior', 'paladin', 'shaman']) +const OLD_CASTER_WEAPON_ARCHETYPE = new Set(['mage', 'priest', 'warlock', 'druid']) + +const ARMOR_RANK: Record = { + cloth: 0, + leather: 1, + mail: 2, +} + +function subsetOf(classes: readonly PlayerClass[], allowed: ReadonlySet): 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 +} diff --git a/src/claudeCraftItems.ts b/src/claudeCraftItems.ts new file mode 100644 index 0000000..feacf93 --- /dev/null +++ b/src/claudeCraftItems.ts @@ -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 + +function weapon( + id: string, + name: string, + quality: Quality, + weaponInfo: WeaponInfo, + stats: Partial, + 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, + quality: Quality, + stats: Partial, + 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 = { + 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' +} diff --git a/src/claudeCraftTalents.ts b/src/claudeCraftTalents.ts new file mode 100644 index 0000000..3d0697d --- /dev/null +++ b/src/claudeCraftTalents.ts @@ -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 + +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> + global?: Partial + 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 + choices: Record +} + +export type TalentModifiers = { + spec: string | null + role: TalentRole | null + abilities: Record + 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 : {} + 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 = {} + 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 = {} + 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 }, +} diff --git a/src/claudeCraftTypes.ts b/src/claudeCraftTypes.ts new file mode 100644 index 0000000..b58bdfb --- /dev/null +++ b/src/claudeCraftTypes.ts @@ -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 + 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 } +} diff --git a/src/components/ActionCharacterCreation.tsx b/src/components/ActionCharacterCreation.tsx new file mode 100644 index 0000000..0e3da99 --- /dev/null +++ b/src/components/ActionCharacterCreation.tsx @@ -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 = { + 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('priest') + const [skin, setSkin] = useState(0) + const [skinCatalog] = useState('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 ( +
+
+ +
+

Appearance

+

{trimmedName || 'New Character'}

+ {CLASS_META[classId].label} · Skin {skin + 1} +
+
+ +
{ + event.preventDefault() + if (!canCreate) return + onCreate({ + appearance: { skin, skinCatalog }, + classId, + name: trimmedName, + }) + }} + > + + {nameTaken &&

Name already exists.

} + +
+
+

Class

+

Healer

+
+
+ {HEALER_ACTION_CLASSES.map((healerClass) => ( + + ))} +
+
+ +
+
+

Look

+

Skin

+
+
+ {skinOptions.map((skinIndex) => ( + + ))} +
+
+ +
+ {onCancel && } + +
+
+
+ ) +} + +function ActionCharacterModelPreview({ classId, skin }: { classId: PlayerClass, skin: number }) { + const mountRef = useRef(null) + const runtimeRef = useRef(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 ( +
+ ) +} + +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 ( +
+
+
+

Characters

+

Roster

+
+ +
+
+ {characters.map((character) => ( + + ))} +
+
+ ) +} diff --git a/src/components/ActionCharacterRosterPanel.tsx b/src/components/ActionCharacterRosterPanel.tsx new file mode 100644 index 0000000..e9a16bc --- /dev/null +++ b/src/components/ActionCharacterRosterPanel.tsx @@ -0,0 +1,57 @@ +import type { ActionCharacter } from '../actionMode' +import type { PlayerClass } from '../claudeCraftTypes' + +const CLASS_META: Record = { + 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 ( +
+
+
+

Characters

+

Roster

+
+ +
+
+ {characters.map((character) => ( + + ))} +
+
+ ) +} diff --git a/src/components/ActionEquipmentMenus.tsx b/src/components/ActionEquipmentMenus.tsx new file mode 100644 index 0000000..c1c0fe5 --- /dev/null +++ b/src/components/ActionEquipmentMenus.tsx @@ -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('mainhand') + const selectedItem = getEquippedActionItem(character, selectedSlot) + const stats = useMemo(() => getEquipmentStats(character), [character]) + const weapon = getEquippedActionItem(character, 'mainhand') + + return ( +
+
+
+

ClaudeCraft

+

Character

+
+
+ {character.classId} + Level {character.level} + {character.copper} Copper +
+
+ +
+
+ {ACTION_EQUIP_SLOTS.map((slot) => { + const equipped = getEquippedActionItem(character, slot.slot) + return ( + + ) + })} +
+ + +
+
+ ) +} + +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>({}) + const [talentTab, setTalentTab] = useState<'class' | string>('class') + const [selectedTalentId, setSelectedTalentId] = useState(null) + const canEdit = Boolean(onCharacterChange) + + if (!talents) { + return ( +
+
+
+ No trees +
+ {onBack && ( + + )} +
+
+ ) + } + + 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 ( +
+
+
+ {character.name} + Level {character.level} + Available: {points.available} / {points.total} + Spent: {points.spent} +
+
+ {onBack && ( + + )} + +
+
+ +
+ + {talents.specs.map((spec) => ( + + ))} +
+ + {talentTab !== 'class' && selectedSpec && ( +
+ Mastery: {selectedSpec.mastery.name} + {selectedSpec.mastery.description} +
+ )} + +
+ setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))} + onSpend={spendPoint} + points={points} + selectedId={selectedTalent?.id ?? null} + selectedSpec={selectedSpec?.id ?? null} + onSelect={(node) => setSelectedTalentId(node.id)} + /> + setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))} + onSpend={spendPoint} + node={selectedTalent} + nodes={visibleNodes} + points={points} + selectedSpec={selectedSpec?.id ?? null} + /> +
+ +
+ ) +} + +export function ActionInventoryMenu({ + character, + onEquip, +}: { + character: ActionCharacter + onEquip: (itemId: string) => void +}) { + const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') + const [tooltipItemId, setTooltipItemId] = useState(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 ( +
+
+
+

ClaudeCraft

+

Inventory

+
+
+ {entries.length} Slots + {getInventoryCount(entries)} Items + {character.copper} Copper +
+
+ +
+
+
+
+

Bags

+

{getFilterLabel(filter)}

+
+ setQuery(event.target.value)} + placeholder="Search" + value={query} + /> +
+ +
+ {(['all', 'weapon', 'armor', 'other'] as InventoryFilter[]).map((nextFilter) => ( + + ))} +
+ +
setTooltipItemId(null)}> + {filteredEntries.length === 0 ? ( +

Bags empty.

+ ) : ( + filteredEntries.map(({ item, count }) => ( + + )) + )} +
+ {tooltipItem && ( + + )} +
+
+
+ ) +} + +export function ActionMenuOverlay({ + children, + onBack, + onClose, + title, +}: { + children: ReactNode + onBack: () => void + onClose: () => void + title: string +}) { + return ( +
+
+
+
+

Paused

+

{title}

+
+
+ + +
+
+ {children} +
+
+ ) +} + +function TalentTreeCanvas({ + allocation, + canEdit, + classId, + choices, + nodes, + onChoice, + onSelect, + onSpend, + points, + selectedId, + selectedSpec, +}: { + allocation: ActionCharacter['talents'] + canEdit: boolean + classId: ActionCharacter['classId'] + choices: Record + 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
Choose a specialization.
+ } + + return ( +
+
+ + {nodes.flatMap((node) => (node.requires ?? []).map((required) => { + const parent = byId.get(required) + if (!parent) return null + const filled = (allocation.ranks[required] ?? 0) > 0 + return ( + + ) + }))} + + {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 ( + + ) + })} +
+
+ ) +} + +function TalentDetailPanel({ + allocation, + canEdit, + classId, + choices, + node, + nodes, + onChoice, + onSpend, + points, + selectedSpec, +}: { + allocation: ActionCharacter['talents'] + canEdit: boolean + classId: ActionCharacter['classId'] + choices: Record + 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 + + 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 ( + + ) +} + +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' + ? `` + : glyph === 'heart' + ? `` + : glyph === 'cross' + ? `` + : glyph === 'fist' + ? `` + : glyph === 'spark' + ? `` + : glyph === 'gem' + ? `` + : glyph === 'burst' + ? `` + : `` + return `${mark}` +} + +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 ( +
+
{label}
+
{value}
+
+ ) +} + +function GearDetail({ + equippedItem, + item, + onEquip, + slot, +}: { + equippedItem?: ItemDef | null + item: ItemDef | null + onEquip?: () => void + slot?: EquipSlot +}) { + if (!item) { + return ( +
+

{slot ? `No item equipped in ${slot}.` : 'Select an item.'}

+
+ ) + } + + return ( +
+
+
+

Selected

+

{item.name}

+
+ {item.quality ?? 'common'} +
+
+
+
Stats
+
{getItemStatsText(item)}
+
+
+
Equipped
+
{equippedItem ? getItemStatsText(equippedItem) : 'Empty'}
+
+
+
Slot
+
{item.slot ?? 'Bag'}
+
+
+
Class
+
{item.requiredClass?.join(', ') ?? 'Any'}
+
+
+ {onEquip && ( + + )} +
+ ) +} + +function InventoryItemTooltip({ + canEquip, + count, + equippedItem, + item, +}: { + canEquip: boolean + count: number + equippedItem?: ItemDef | null + item: ItemDef +}) { + const rows = getItemTooltipRows(item, equippedItem) + return ( + + ) +} + +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 = ['armor', 'str', 'agi', 'sta', 'int', 'spi'] +const STAT_LABELS: Record = { + 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 +} diff --git a/src/components/ActionModeScreen.tsx b/src/components/ActionModeScreen.tsx index beb370b..a0939a3 100644 --- a/src/components/ActionModeScreen.tsx +++ b/src/components/ActionModeScreen.tsx @@ -1,50 +1,108 @@ -import { useEffect, useMemo, useState } from 'react' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { loadActionMechanicConfig, resetActionMechanicConfig, saveActionMechanicConfig, + type ActionAiAvoidanceSource, type ActionAttackConfig, type ActionMechanicConfig, } from '../actionBoss/actionEncounterConfig' -import type { EnemyKind } from '../actionBoss/bulldromeSimulation' +import type { EnemyKind } from '../actionBoss/actionCombatSimulation' import { ACTION_DIFFICULTY_TIERS, - ACTION_GEAR_SLOTS, - ACTION_GEAR_UPGRADE_COST, + addActionRosterCharacter, completeActionDungeonHunt, + completeActionPvpRound, + equipActionItem, + getActionTalentPoints, getActionCoinCount, getActionDifficultyTier, - getActionGearStats, getActionLevelProgress, - getUpgradeCoinCount, - getUpgradeCoinName, - loadActionCharacter, - saveActionCharacter, - upgradeBulldromeGear, + getActiveActionCharacter, + loadActionRoster, + saveActionRoster, + switchActionRosterCharacter, + updateActionRosterCharacter, type ActionCharacter, + type ActionCharacterRoster, type ActionDifficulty, type ActionDungeonId, - type ActionGearPiece, - type ActionGearSlot, type ActionGearSource, type ActionRunMode, type ActionRunReward, } from '../actionMode' +import { ACTION_DUNGEON_ICONS } from '../actionAssets' +import { + buyArenaUpgrade, + createArenaMatchState, + getArenaAvailableUpgrades, + getArenaSpellName, + getArenaSpellLoadout, + resolveArenaRoundWinner, + startNextArenaRound, + type ArenaMatchState, +} from '../actionArena' +import { PauseSettingsMenu } from './CombatPauseMenu' +import { ActionCharacterRosterPanel } from './ActionCharacterRosterPanel' +import { ActionCharacterMenu, ActionInventoryMenu, ActionMenuOverlay, ActionTalentMenu } from './ActionEquipmentMenus' import { BulldromeBossSlice } from './BulldromeBossSlice' -import type { AuthAccount } from '../actionApi' +import type { SpellSlot } from '../actionCombatCore' +import { + cancelArenaQueue, + checkArenaQueue, + joinArenaQueue, + type AuthAccount, +} from '../actionApi' type ActionModeScreenProps = { account?: AuthAccount + authActionLabel?: string onCharacterSaved?: (character: ActionCharacter) => void onBack?: () => void onLogout?: () => void onMechanicsSaved?: (config: ActionMechanicConfig) => void - serverMessage?: string } -type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings' +const ArenaBattleSlice = lazy(() => import('./ArenaBattleSlice').then((module) => ({ default: module.ArenaBattleSlice }))) +const ActionCharacterCreator = lazy(() => import('./ActionCharacterCreation').then((module) => ({ default: module.ActionCharacterCreator }))) + +type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'character' | 'inventory' | 'talents' | 'characters' | 'settings' type ActionHubScreen = 'menu' | ActionHubTab -type PlayableActionDungeonId = Exclude +type PlayableActionDungeonId = ActionDungeonId +type ArenaShopCategory = `Slot ${SpellSlot}` | 'Misc' + +const AI_AVOIDANCE_LABELS: Record = { + projectile: 'AI Avoid Projectile', + persistentArea: 'AI Avoid Floor', + circleTelegraph: 'AI Avoid Circle', + lineTelegraph: 'AI Avoid Line', +} + +const ARENA_SHOP_SLOT_CATEGORIES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const + +function getArenaShopCategory(scope: string): ArenaShopCategory { + const slot = Number(scope.replace('Slot ', '')) + return ARENA_SHOP_SLOT_CATEGORIES.includes(slot as SpellSlot) ? `Slot ${slot as SpellSlot}` : 'Misc' +} + +function getArenaShopCategoryLabel(category: ArenaShopCategory) { + const slot = Number(category.replace('Slot ', '')) + return ARENA_SHOP_SLOT_CATEGORIES.includes(slot as SpellSlot) ? getArenaSpellName(slot as SpellSlot) : 'General Buffs' +} + +function getArenaUpgradeScopeLabel(scope: string) { + if (scope === 'Every Slot') return 'Every Spell' + return getArenaShopCategoryLabel(getArenaShopCategory(scope)) +} + +function getArenaUpgradeDescription(description: string) { + return ARENA_SHOP_SLOT_CATEGORIES.reduce((text, slot) => { + const name = getArenaSpellName(slot) + return text + .replaceAll(`Slot ${slot}`, name) + .replaceAll(`slot ${slot}`, name) + }, description) +} const ACTION_HUB_ITEMS: Array<{ id: ActionHubTab @@ -56,51 +114,95 @@ const ACTION_HUB_ITEMS: Array<{ { id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' }, { id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' }, { id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' }, - { id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' }, + { id: 'character', label: 'Character', glyph: 'C', description: 'View ClaudeCraft equipment and stats.' }, + { id: 'inventory', label: 'Inventory', glyph: 'B', description: 'Manage ClaudeCraft bags and loot.' }, + { id: 'talents', label: 'Talents', glyph: 'T', description: 'Spend ClaudeCraft talent points.' }, + { id: 'characters', label: 'Characters', glyph: '@', description: 'Create or switch local characters.' }, { id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' }, ] export function ActionModeScreen({ account, + authActionLabel = 'Logout', onBack, onCharacterSaved, onLogout, onMechanicsSaved, - serverMessage = '', }: ActionModeScreenProps) { - const [character, setCharacter] = useState(() => loadActionCharacter()) + const [roster, setRoster] = useState(() => loadActionRoster()) const [activeDifficulty, setActiveDifficulty] = useState(null) const [activeDungeonId, setActiveDungeonId] = useState('bulldrome') const [activeRunMode, setActiveRunMode] = useState('hunt') const [activeScreen, setActiveScreen] = useState('menu') + const [creatingCharacter, setCreatingCharacter] = useState(false) const [lastReward, setLastReward] = useState(null) const [runKey, setRunKey] = useState(0) const [message, setMessage] = useState('') - const progress = useMemo(() => getActionLevelProgress(character), [character]) + const character = useMemo(() => getActiveActionCharacter(roster), [roster]) + const progress = useMemo(() => character ? getActionLevelProgress(character) : null, [character]) + const talentPoints = useMemo(() => character ? getActionTalentPoints(character) : null, [character]) + const updateCharacter = (updater: (character: ActionCharacter) => ActionCharacter) => { + setRoster((current) => { + const activeCharacter = getActiveActionCharacter(current) + if (!activeCharacter) return current + return updateActionRosterCharacter(current, updater(activeCharacter)) + }) + } + const equipCharacterItem = (itemId: string) => { + updateCharacter((current) => { + const nextCharacter = equipActionItem(current, itemId) + setMessage(nextCharacter !== current ? 'Item equipped.' : 'Cannot equip that item.') + return nextCharacter + }) + } useEffect(() => { - saveActionCharacter(character) - onCharacterSaved?.(character) - }, [character, onCharacterSaved]) + saveActionRoster(roster) + if (character) onCharacterSaved?.(character) + }, [character, onCharacterSaved, roster]) + + if (!character || creatingCharacter) { + return ( +
+
+ Loading character creator...
}> + candidate.name)} + onCancel={character ? () => setCreatingCharacter(false) : undefined} + onCreate={(input) => { + setRoster((current) => addActionRosterCharacter(current, input)) + setCreatingCharacter(false) + setActiveScreen('menu') + setMessage('Character created.') + }} + /> + + + + ) + } if (activeDifficulty) { return (
updateCharacter(() => nextCharacter)} onExit={() => { setLastReward(null) setActiveDifficulty(null) setActiveScreen('dungeons') }} onRunComplete={() => { - if (lastReward && activeRunMode === 'hunt') return + if (lastReward && activeRunMode !== 'marathon') return const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty) - setCharacter(nextCharacter) - if (activeRunMode === 'hunt') setLastReward(reward) + updateCharacter(() => nextCharacter) + if (activeRunMode !== 'marathon') setLastReward(reward) setMessage('') }} /> @@ -127,61 +229,63 @@ export function ActionModeScreen({ return (
-
-
-

Action Mode

-

{getHubTitle(activeScreen)}

-
-
-
- {character.name} - {account && @{account.username}} - Healer - Level {character.level} -
- -
-
+ {activeScreen !== 'menu' && activeScreen !== 'pvp' && activeScreen !== 'talents' && ( +
+ {onLogout && ( - - )} - {(activeScreen !== 'menu' || onBack) && ( - )}
-
- - {activeScreen === 'menu' && ( - )} - {(message || serverMessage) &&

{message || serverMessage}

} + {activeScreen === 'menu' && ( +
+ + +
+ )} + + {activeScreen !== 'pvp' && message &&

{message}

} {activeScreen === 'dungeons' && ( )} - {activeScreen === 'customize' && ( - { - const before = character.inventory.find((item) => item.id === itemId) - const nextCharacter = upgradeBulldromeGear(character, itemId) - const coinCount = before ? getUpgradeCoinCount(character, before) : 0 - setCharacter(nextCharacter) - setMessage( - before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST - ? `${before.name} upgraded to item level ${before.itemLevel + 1}.` - : 'Upgrade unavailable.', - ) + onOpenInventory={() => setActiveScreen('inventory')} + onOpenTalents={() => setActiveScreen('talents')} + /> + )} + + {activeScreen === 'inventory' && ( + + )} + + {activeScreen === 'talents' && ( + setActiveScreen('menu')} + onCharacterChange={(nextCharacter) => updateCharacter(() => nextCharacter)} + /> + )} + + {activeScreen === 'characters' && ( + setCreatingCharacter(true)} + onSwitch={(characterId) => { + setRoster((current) => switchActionRosterCharacter(current, characterId)) + setMessage('Character switched.') }} /> )} + {activeScreen === 'pvp' && ( + setActiveScreen('menu')} + onCharacterChange={(nextCharacter) => updateCharacter(() => nextCharacter)} + onEquipItem={equipCharacterItem} + /> + )} + {activeScreen === 'settings' && ( )} - {activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && ( + {activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'character' && activeScreen !== 'inventory' && activeScreen !== 'talents' && activeScreen !== 'characters' && activeScreen !== 'pvp' && activeScreen !== 'settings' && (

{getHubTitle(activeScreen)}

Coming Soon

-

This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.

+

Content hooks can land here when this section is ready.

)}
@@ -236,6 +368,408 @@ export function ActionModeScreen({ ) } +function ArenasPanel({ + account, + character, + onBack, + onCharacterChange, + onEquipItem, +}: { + account?: AuthAccount + character: ActionCharacter + onBack: () => void + onCharacterChange: (character: ActionCharacter) => void + onEquipItem: (itemId: string) => void +}) { + const [match, setMatch] = useState(() => createArenaMatchState(character.name)) + const [shopReady, setShopReady] = useState(false) + const [shopCategory, setShopCategory] = useState('Slot 1') + const [menuPaused, setMenuPaused] = useState(false) + const [menuPausePanel, setMenuPausePanel] = useState<'settings' | 'character' | 'inventory' | 'talents'>('settings') + const [queueMessage, setQueueMessage] = useState('') + const [queueing, setQueueing] = useState(false) + const availableUpgrades = useMemo(() => getArenaAvailableUpgrades(match), [match]) + const visibleUpgrades = useMemo( + () => availableUpgrades.filter((upgrade) => { + if (getArenaShopCategory(upgrade.scope) === shopCategory) return true + return shopCategory !== 'Misc' && upgrade.scope === 'Every Slot' + }), + [availableUpgrades, shopCategory], + ) + const ownedUpgrades = useMemo( + () => availableUpgrades.filter((upgrade) => upgrade.owned), + [availableUpgrades], + ) + const spellLoadout = useMemo(() => getArenaSpellLoadout(), []) + + useEffect(() => { + if (match.phase === 'fighting') return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat || event.key !== 'Escape') return + event.preventDefault() + setMenuPausePanel('settings') + setMenuPaused((current) => !current) + } + let frameId = 0 + let startPressed = false + const pollGamepadStart = () => { + const pressed = Array.from(navigator.getGamepads?.() ?? []) + .some((gamepad) => gamepad?.buttons[9]?.pressed) + if (pressed && !startPressed) { + setMenuPausePanel('settings') + setMenuPaused((current) => !current) + } + startPressed = pressed + frameId = window.requestAnimationFrame(pollGamepadStart) + } + window.addEventListener('keydown', handleKeyDown) + frameId = window.requestAnimationFrame(pollGamepadStart) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.cancelAnimationFrame(frameId) + } + }, [match.phase]) + + async function findOnlineMatch() { + if (!account || queueing) return + setQueueing(true) + setQueueMessage('Searching for arena opponent...') + let ticketId = '' + try { + const joined = await joinArenaQueue() + ticketId = joined.ticketId + let result = joined + for (let attempt = 0; attempt < 5 && result.status !== 'matched'; attempt += 1) { + await new Promise((resolve) => window.setTimeout(resolve, 1000)) + result = await checkArenaQueue(ticketId) + } + if (result.status === 'matched' && result.match && result.side) { + const opponentSide = result.side === 'a' ? 'b' : 'a' + const opponentName = result.match.players[opponentSide].displayName + setMatch({ + ...createArenaMatchState(character.name), + opponentName, + history: [`Matched against ${opponentName}.`], + }) + setQueueMessage(`Matched against ${opponentName}.`) + return + } + await cancelArenaQueue(ticketId).catch(() => null) + setMatch({ + ...createArenaMatchState(character.name), + opponentName: 'Rival Five', + history: ['No online opponent found. CPU team loaded.'], + }) + setQueueMessage('No online opponent found. CPU team loaded.') + } catch (error) { + if (ticketId) await cancelArenaQueue(ticketId).catch(() => null) + setQueueMessage(error instanceof Error ? error.message : 'Arena queue failed.') + } finally { + setQueueing(false) + } + } + + function beginRound() { + const next = startNextArenaRound(match) + setShopReady(false) + setMatch(next) + } + + function resetMatch() { + setMatch(createArenaMatchState(character.name)) + setQueueMessage('') + setShopReady(false) + setShopCategory('Slot 1') + } + + function completeArenaRound(winner: 'player' | 'opponent', elapsedSeconds: number) { + const nextMatch = resolveArenaRoundWinner(match, winner, elapsedSeconds) + const playerWonRound = winner === 'player' + const playerWonMatch = nextMatch.phase === 'complete' && nextMatch.playerWins > nextMatch.opponentWins + const { character: nextCharacter, reward } = completeActionPvpRound(character, playerWonRound, playerWonMatch) + const rewardParts = [ + `+${reward.roundExperience} XP`, + ...(reward.matchBonusExperience > 0 ? [`+${reward.matchBonusExperience} match bonus`] : []), + ...(reward.leveledUp ? ['Level up'] : []), + ] + + onCharacterChange(nextCharacter) + setMatch({ + ...nextMatch, + history: [`${rewardParts.join(' · ')}.`, ...nextMatch.history].slice(0, 6), + }) + setQueueMessage(rewardParts.join(' · ')) + setShopReady(false) + } + + const matchComplete = match.phase === 'complete' + const completedRounds = match.playerWins + match.opponentWins + const leader = match.playerWins === match.opponentWins + ? 'Tied' + : match.playerWins > match.opponentWins + ? character.name + : match.opponentName + const lastResult = match.history[0] || 'No rounds played yet.' + + if (match.phase === 'fighting') { + return ( + Loading arena...
}> + { + setMatch((current) => ({ ...current, phase: 'upgrade' })) + setShopReady(false) + }} + onRoundComplete={(winner, elapsedSeconds) => { + completeArenaRound(winner, elapsedSeconds) + }} + playerName={match.playerName} + playerUpgrades={match.playerUpgrades} + playerWins={match.playerWins} + round={match.round} + /> + + ) + } + + return ( +
+
+
+ {match.playerName} + {match.playerWins} - {match.opponentWins} + {match.opponentName} +
+
+ + {queueMessage &&

{queueMessage}

} + + {matchComplete ? ( +
+
{match.playerWins > match.opponentWins ? 'WIN' : 'LOSS'}
+
+

Match Complete

+

{leader} wins the race

+

{lastResult}

+
+ +
+ ) : match.phase === 'upgrade' ? ( +
+
+
+

Buy Round

+

Round {completedRounds} Complete

+ {lastResult} +
+
+ Available Points + {match.playerCredits} +
+
+ + +
+
+ +
+ + +
+
+
+

Buffs

+

{getArenaShopCategoryLabel(shopCategory)}

+
+ {visibleUpgrades.length} Buffs +
+
+ {visibleUpgrades.length ? visibleUpgrades.map((upgrade) => ( + + )) : ( +
+ No buffs yet + Pick another spell or General Buffs. +
+ )} +
+
+ + +
+
+ ) : ( +
+
+
+
+

Match Type

+

Boss Race

+
+
+
+ + +
+
+
+
Rounds
+
First to 3
+
+
+
Dampening
+
+1% every 5s
+
+
+
Room
+
Split Boss Arena
+
+
+
+ +
+
VS
+
+

Queue

+

{match.playerName} vs {match.opponentName}

+

Race the other party through boss spawns. Buy buffs after each round.

+
+
+ + {account && ( + + )} +
+
+ +
+ )} + {menuPaused && ( + menuPausePanel === 'settings' ? ( + setMenuPausePanel('character')} + onOpenInventory={() => setMenuPausePanel('inventory')} + onOpenTalents={() => setMenuPausePanel('talents')} + onResume={() => setMenuPaused(false)} + /> + ) : menuPausePanel === 'character' ? ( + setMenuPausePanel('settings')} + onClose={() => setMenuPaused(false)} + title="Character" + > + setMenuPausePanel('inventory')} + onOpenTalents={() => setMenuPausePanel('talents')} + /> + + ) : menuPausePanel === 'talents' ? ( + setMenuPausePanel('settings')} + onClose={() => setMenuPaused(false)} + title="Talents" + > + + + ) : ( + setMenuPausePanel('settings')} + onClose={() => setMenuPaused(false)} + title="Inventory" + > + + + ) + )} +
+ ) +} + function ActionMechanicsAdmin({ onConfigSaved }: { onConfigSaved?: (config: ActionMechanicConfig) => void }) { const [config, setConfig] = useState(() => loadActionMechanicConfig()) const [enemyPage, setEnemyPage] = useState(0) @@ -362,6 +896,28 @@ function AttackEditor({ attack: ActionAttackConfig onChange: (patch: Partial) => void }) { + const aiAvoidanceEntries = Object.entries(attack.aiAvoidance ?? {}) as Array<[ + ActionAiAvoidanceSource, + NonNullable[ActionAiAvoidanceSource], + ]> + + function updateAiAvoidance( + source: ActionAiAvoidanceSource, + patch: Partial[ActionAiAvoidanceSource]>, + ) { + const current = attack.aiAvoidance?.[source] + if (!current) return + onChange({ + aiAvoidance: { + ...attack.aiAvoidance, + [source]: { + ...current, + ...patch, + }, + }, + }) + } + return (
@@ -454,6 +1010,68 @@ function AttackEditor({ )} + + {aiAvoidanceEntries.length > 0 && ( +
+ {aiAvoidanceEntries.map(([source, avoidance]) => { + if (!avoidance) return null + return ( +
+ + + + + {avoidance.lineWidthMultiplier !== undefined && ( + + )} +
+ ) + })} +
+ )}
) } @@ -489,7 +1107,10 @@ function DungeonsPanel({ }} type="button" > - {dungeon.glyph} + + + {dungeon.glyph} + {dungeon.eyebrow} {dungeon.name} @@ -513,7 +1134,7 @@ function DungeonsPanel({
Tier Coins
{selectedCoinCount}
-
Gear Drop
iLvl {selectedTier.itemLevel}
+
Gear Drop
Shared action loot
XP
{selectedTier.experience}
@@ -551,7 +1172,7 @@ function DungeonsPanel({ Start Marathon -

Marathon respawns the boss and extra mobs after a 5 second break.

+

Marathon respawns the boss after a 5 second break.

@@ -562,6 +1183,7 @@ const ACTION_DUNGEONS: Array<{ id: ActionDungeonId eyebrow: string glyph: string + icon: string name: string summary: string description: string @@ -573,9 +1195,10 @@ const ACTION_DUNGEONS: Array<{ id: 'bulldrome', eyebrow: 'Dungeon 1', glyph: 'B', + icon: ACTION_DUNGEON_ICONS.bulldrome, name: 'Bulldrome Hunting Grounds', - summary: 'Charges, slams, and Bullfango pressure.', - description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.', + summary: 'Charge lanes and ground slam telegraphs.', + description: 'Bulldrome is a single boss hunt built on the shared action combat rules.', source: 'bulldrome', coinLabel: 'White Bulldrome Coins', }, @@ -583,22 +1206,23 @@ const ACTION_DUNGEONS: Array<{ id: 'yian-kut-ku', eyebrow: 'Dungeon 2', glyph: 'Y', + icon: ACTION_DUNGEON_ICONS['yian-kut-ku'], name: 'Yian Kut-Ku Roost', - summary: 'Bouncing fireballs and dive birds.', - description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.', + summary: 'Bouncing fireballs and fire patches.', + description: 'Yian Kut-Ku is a single boss hunt with bouncing fireball mechanics.', source: 'yian-kut-ku', coinLabel: 'White Yian Kut-Ku Coins', }, { - id: 'rathian', + id: 'cyber-dragon', eyebrow: 'Dungeon 3', - glyph: 'R', - name: 'Rathian Nest', - summary: 'Coming next.', - description: 'Rathian mechanics will be built after Yian Kut-Ku.', - source: 'bulldrome', - coinLabel: 'Rathian Coins', - locked: true, + glyph: 'C', + icon: ACTION_DUNGEON_ICONS['cyber-dragon'], + name: 'Cyber Dragon Core', + summary: 'Chasing saw blades and circular charge lanes.', + description: 'Cyber Dragon spawns slow pursuit blades that bleed targets and charges after circling the room.', + source: 'cyber-dragon', + coinLabel: 'Cyber Dragon Coins', }, ] @@ -638,11 +1262,11 @@ function RunRewardModal({
Loot - {reward.gear.length === 0 ? ( + {reward.items.length === 0 ? (

No gear dropped.

) : ( - reward.gear.map((item) => ( - {item.name} · ilvl {item.itemLevel} + reward.items.map((item, index) => ( + {item.name} · {item.quality ?? 'common'} · {item.slot} )) )}
@@ -655,159 +1279,20 @@ function RunRewardModal({ ) } -function CustomizePanel({ - character, - onUpgrade, -}: { - character: ActionCharacter - onUpgrade: (itemId: string) => void -}) { - const [selectedSlot, setSelectedSlot] = useState('weapon') - const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0] - const filteredItems = useMemo(() => ( - character.inventory - .filter((item) => item.slot === selectedSlot) - .sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name)) - ), [character.inventory, selectedSlot]) - const [selectedItemId, setSelectedItemId] = useState(null) - const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null - - return ( -
-
-

Craft Table

-

Bulldrome Gear

-

- Pick a gear slot, inspect that slot inventory, then preview the upgrade - cost and stat gain before spending coins. -

-
- -
-
- {ACTION_GEAR_SLOTS.map((slot) => { - const count = character.inventory.filter((item) => item.slot === slot.slot).length - return ( - - ) - })} -
- - -
-
- ) -} - -function GearDetail({ - coins, - item, - onUpgrade, -}: { - coins: number - item: ActionGearPiece | null - onUpgrade: () => void -}) { - if (!item) { - return ( -
-

Select a slot with gear to see stats and upgrade costs.

-
- ) - } - - const currentStats = getActionGearStats(item) - const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) } - const nextStats = getActionGearStats(nextItem) - const coinName = getUpgradeCoinName(item) - const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5 - - return ( -
-
-
-

Selected

-

{item.name}

-
- ilvl {item.itemLevel} -
-
-
-
Healing
-
{currentStats.healingPower} → {nextStats.healingPower}
-
-
-
Stamina
-
{currentStats.stamina} → {nextStats.stamina}
-
-
-
Upgrade Cost
-
{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}
-
-
-
You Have
-
{coins} {coinName}
-
-
- -
- ) -} - function getHubTitle(screen: ActionHubScreen) { if (screen === 'menu') return 'Action Mode' return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode' } +function getClassLabel(classId: ActionCharacter['classId']) { + if (classId === 'paladin') return 'Paladin' + if (classId === 'shaman') return 'Shaman' + if (classId === 'druid') return 'Druid' + if (classId === 'priest') return 'Priest' + return classId +} + function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) { - const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt' + const dungeonName = ACTION_DUNGEONS.find((dungeon) => dungeon.id === dungeonId)?.name ?? 'Dungeon' return `${getActionDifficultyTier(difficulty).label} ${dungeonName}` } diff --git a/src/components/ActionModelViewer.tsx b/src/components/ActionModelViewer.tsx new file mode 100644 index 0000000..e775a18 --- /dev/null +++ b/src/components/ActionModelViewer.tsx @@ -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(null) + const runtimeRef = useRef(null) + const autoRotateRef = useRef(true) + const speedRef = useRef(1) + const [category, setCategory] = useState('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([]) + 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 ( +
+
+ + + + + + +
+ +
+ +
+ {selectedAsset.label} + {selectedAsset.source} + {selectedAsset.type === 'file' ? selectedAsset.url : selectedAsset.enemyKind} + {selectedWeapon.id === 'none' ? status : `${status} / ${selectedWeapon.label}`} +
+
+ ) +} + +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) { + 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['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() +} diff --git a/src/components/ArenaBattleSlice.tsx b/src/components/ArenaBattleSlice.tsx new file mode 100644 index 0000000..d88542d --- /dev/null +++ b/src/components/ArenaBattleSlice.tsx @@ -0,0 +1,1095 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import * as THREE from 'three' +import { + addBirdMesh, + addBoarMesh, + cancelCombatRenderLoads, + clearCombatRenderCaches, + addClaudeCraftBossModel, + addCyberDragonMesh, + addCombatUnitMesh, + animateCombatWeapon, + updateCombatUnitModel, +} from '../action3dCombatRender' +import { + ACTION_ARENA_SCALE, + ACTION_PLAYER_KEYS, + ACTION_RENDER_QUALITY, + clearCombatActions, + createActionCombatCamera, + createActionCombatCameraController, + createHealBurstEffect, + getCircleTelegraph, + getLineTelegraph, + pruneObjectMap, + readCombatInput, + resizeActionCombatCamera, + setObjectOpacity, + setupActionSceneEnvironment, + syncLineTelegraphGeometry, + updateActionCombatCamera, +} from '../action3dSceneKit' +import { + getActionSpellbook, + getActionSpellDefinition, + getActionSpellIconUrl, + getActionSpellTarget, + getActionSpellTooltip, +} from '../actionCombatCore' +import { + createArenaBattleState, + getArenaBattleBosses, + getArenaBattleFrames, + getArenaBattleSpellManaCost, + getArenaBattleUnits, + updateArenaBattleState, + type ArenaBossState, + type ArenaBattleState, + type ArenaBattleUnit, +} from '../actionArenaBattle' +import type { RaidFrame } from '../actionCombatTypes' +import type { SpellSlot } from '../actionCombatCore' +import type { ArenaUpgradeId } from '../actionArena' +import { getArenaAbility } from '../actionClassKits' +import type { ArenaClassId } from '../actionClassKits' +import type { ActionCharacter } from '../actionMode' +import { ActionCharacterMenu, ActionInventoryMenu, ActionMenuOverlay, ActionTalentMenu } from './ActionEquipmentMenus' +import type { TalentModifiers } from '../claudeCraftTalents' +import { createActionCombatProfile } from '../actionCombatProfile' +import { CombatActionBar, CombatPartyFrames, CombatTargetFrame, type CombatActionSlot } from './CombatHud' +import { PauseSettingsMenu } from './CombatPauseMenu' + +type ArenaBattleSliceProps = { + character?: ActionCharacter + opponentName: string + opponentUpgrades: ArenaUpgradeId[] + playerName: string + playerUpgrades: ArenaUpgradeId[] + playerWins: number + opponentWins: number + round: number + onEquipItem?: (itemId: string) => void + onCharacterChange?: (character: ActionCharacter) => void + onExit: () => void + onRoundComplete: (winner: 'player' | 'opponent', elapsedSeconds: number) => void +} + +type ArenaThreeHandle = { + castSpell: (slot: SpellSlot) => void + selectTarget: (targetId: string) => void +} + +type UnitMesh = THREE.Group & { + userData: { + kind?: string + role?: string + teamId?: string + } +} + +type WallFrameMesh = THREE.Mesh & { + userData: { + canvas?: HTMLCanvasElement + context?: CanvasRenderingContext2D | null + texture?: THREE.CanvasTexture + } +} + +type ArenaTargetInfo = { + id: string + kind: 'ally' | 'boss' + label: string + detail: string + hp: number + maxHp: number +} + +export function ArenaBattleSlice({ + character, + opponentUpgrades, + onCharacterChange, + onEquipItem, + onExit, + onRoundComplete, + playerUpgrades, + round, +}: ArenaBattleSliceProps) { + const combatProfile = useMemo(() => createActionCombatProfile(character), [character]) + const playerClassId = combatProfile.classId as ArenaClassId + const sceneRef = useRef(null) + const completionSentRef = useRef(false) + const [paused, setPaused] = useState(false) + const [pausePanel, setPausePanel] = useState<'settings' | 'character' | 'inventory' | 'talents'>('settings') + const [state, setState] = useState(() => createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, combatProfile.talentModifiers)) + const frames = useMemo(() => getArenaBattleFrames(state), [state]) + const target = useMemo(() => getArenaTargetInfo(state), [state]) + + useEffect(() => { + if (state.result === 'playing' || completionSentRef.current) return + completionSentRef.current = true + onRoundComplete(state.result, state.elapsed) + }, [onRoundComplete, state.elapsed, state.result]) + + 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 === 'Tab') { + event.preventDefault() + sceneRef.current?.selectTarget(state.playerBoss.id) + return + } + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault() + const selectedIndex = Math.max(0, frames.playerFrames.findIndex((frame) => frame.selected)) + const delta = event.key === 'ArrowDown' ? 1 : -1 + const nextFrame = frames.playerFrames[(selectedIndex + delta + frames.playerFrames.length) % frames.playerFrames.length] + if (nextFrame) sceneRef.current?.selectTarget(nextFrame.id) + } + const spellKey = event.key === '0' ? 10 : Number(event.key) + if (getActionSpellbook(state.player.classId).bar.includes(spellKey as SpellSlot)) { + event.preventDefault() + sceneRef.current?.castSpell(spellKey as SpellSlot) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [frames.playerFrames, paused, state.player.classId, state.playerBoss.id]) + + useEffect(() => { + let frameId = 0 + let startPressed = false + const pollGamepadStart = () => { + const pressed = Array.from(navigator.getGamepads?.() ?? []) + .some((gamepad) => gamepad?.buttons[9]?.pressed) + if (pressed && !startPressed) { + setPausePanel('settings') + setPaused((current) => !current) + } + startPressed = pressed + frameId = window.requestAnimationFrame(pollGamepadStart) + } + frameId = window.requestAnimationFrame(pollGamepadStart) + return () => window.cancelAnimationFrame(frameId) + }, []) + + return ( +
+
+
+
+
+
+ sceneRef.current?.selectTarget(id)} + /> +
+ {state.player.currentCast && ( +
+
+ {getActionSpellDefinition(state.player.classId, state.player.currentCast.spell, state.player.talentModifiers).name} + {state.player.currentCast.remaining.toFixed(1)}s +
+ + + +
+ )} + + sceneRef.current?.selectTarget(state.playerBoss.id) } : undefined} + detail={target.detail} + hp={target.hp} + kindLabel={target.kind === 'boss' ? 'Target Boss' : 'Target Ally'} + label={target.label} + maxHp={target.maxHp} + tone={target.kind === 'boss' ? 'enemy' : 'ally'} + /> + sceneRef.current?.castSpell(slot)} + slots={createArenaActionSlots(state, target.kind)} + /> + {paused && ( + pausePanel === 'settings' ? ( + setPausePanel('character') : undefined} + onOpenInventory={character && onEquipItem ? () => setPausePanel('inventory') : undefined} + onOpenTalents={character ? () => setPausePanel('talents') : undefined} + onResume={() => setPaused(false)} + /> + ) : character && pausePanel === 'character' ? ( + setPausePanel('settings')} + onClose={() => setPaused(false)} + title="Character" + > + setPausePanel('inventory') : undefined} + onOpenTalents={() => setPausePanel('talents')} + /> + + ) : character && pausePanel === 'talents' ? ( + setPausePanel('settings')} + onClose={() => setPaused(false)} + title="Talents" + > + + + ) : character && onEquipItem ? ( + setPausePanel('settings')} + onClose={() => setPaused(false)} + title="Inventory" + > + + + ) : null + )} +
+
+
+
+
+ ) +} + +function getArenaTargetInfo(state: ArenaBattleState): ArenaTargetInfo { + if (state.targetId === state.playerBoss.id) { + return { + detail: `${Math.ceil(state.playerBoss.hp)} / ${state.playerBoss.maxHp}`, + hp: state.playerBoss.hp, + id: state.playerBoss.id, + kind: 'boss', + label: state.playerBoss.name, + maxHp: state.playerBoss.maxHp, + } + } + + const ally = [state.player, ...state.playerParty].find((unit) => unit.id === state.targetId) ?? state.player + return { + detail: `${ally.role.toUpperCase()} · ${Math.ceil(ally.hp)} / ${ally.maxHp}`, + hp: ally.hp, + id: ally.id, + kind: 'ally', + label: ally.name, + maxHp: ally.maxHp, + } +} + +function createArenaActionSlots(state: ArenaBattleState, targetKind: ArenaTargetInfo['kind']): Array> { + return getActionSpellbook(state.player.classId).bar.map((slot) => { + const spell = getActionSpellDefinition(state.player.classId, slot, state.player.talentModifiers) + const manaCost = getArenaBattleSpellManaCost(state.playerUpgrades, slot, state.player.storedMomentumReady, state.player.classId, state.player.talentModifiers) + const canAfford = state.player.mana >= manaCost + const tooltip = getActionSpellTooltip(state.player.classId, slot) + const requiresBoss = getActionSpellTarget(state.player.classId, slot) === 'Enemy target' + const hasTarget = !requiresBoss || targetKind === 'boss' + 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 === 0 ? 'Free' : `${manaCost} mana`, + description: tooltip.description, + rank: `Rank ${tooltip.rank} · highest at level ${state.pvpEffectiveLevel}`, + school: tooltip.school, + target: tooltip.target, + }, + } + }) +} + +const ArenaThreeScene = forwardRef void +}>(function ArenaThreeScene({ + opponentUpgrades, + onStateChange, + paused, + playerClassId = 'priest', + playerTalentModifiers, + playerUpgrades, + round, +}, ref) { + const mountRef = useRef(null) + const stateRef = useRef(createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, playerTalentModifiers)) + const queuedTargetRef = useRef(null) + const queuedSpellRef = useRef(null) + const actionQueueRef = useRef({ reset: false }) + const keysRef = useRef(new Set()) + const pausedRef = useRef(paused) + const cameraControllerRef = useRef(createActionCombatCameraController()) + + useImperativeHandle(ref, () => ({ + castSpell: (slot) => { + queuedSpellRef.current = slot + }, + selectTarget: (targetId) => { + queuedTargetRef.current = targetId + }, + })) + + useEffect(() => { + pausedRef.current = paused + if (paused) { + queuedTargetRef.current = null + queuedSpellRef.current = null + clearCombatActions(actionQueueRef.current) + } + }, [paused]) + + useEffect(() => { + stateRef.current = createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, playerTalentModifiers) + onStateChange(stateRef.current) + }, [onStateChange, opponentUpgrades, playerClassId, playerTalentModifiers, playerUpgrades, round]) + + useEffect(() => { + const mount = mountRef.current + if (!mount) return + + const scene = new THREE.Scene() + const renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, powerPreference: 'high-performance' }) + 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: 80 }) + const initialState = stateRef.current + setupActionSceneEnvironment(scene, { + divider: true, + worldHeight: initialState.arena.height / ACTION_ARENA_SCALE, + worldWidth: initialState.arena.width / ACTION_ARENA_SCALE, + }) + + const units = new Map() + const walls = new Map() + const wallFrames = new Map() + const telegraphs = new Map() + const slamTelegraphs = new Map() + const birds = new Map() + const birdLines = new Map() + const effects = new Map() + const clock = new THREE.Clock() + let frameId = 0 + let hudTimer = 0 + + const resize = () => { + const rect = mount.getBoundingClientRect() + renderer.setSize(Math.max(1, rect.width), Math.max(1, rect.height), false) + resizeActionCombatCamera(camera, rect.width, rect.height) + } + const observer = new ResizeObserver(resize) + observer.observe(mount) + resize() + + const animate = () => { + const delta = Math.min(clock.getDelta(), 0.05) + const input = readCombatInput(keysRef.current, cameraControllerRef.current.yaw, queuedTargetRef.current, queuedSpellRef.current, actionQueueRef.current, { + fallbackAim: { x: 1, y: 0 }, + }) + queuedTargetRef.current = null + queuedSpellRef.current = null + clearCombatActions(actionQueueRef.current) + + if (!pausedRef.current) stateRef.current = updateArenaBattleState(stateRef.current, input, delta) + else clearCombatActions(actionQueueRef.current) + syncArenaWorld(scene, stateRef.current, units, walls, wallFrames, telegraphs, slamTelegraphs, birds, birdLines, effects, camera, delta, playerClassId) + updateActionCombatCamera(camera, cameraControllerRef.current.yaw, stateRef.current.player, (x, y) => toArenaWorld(stateRef.current, x, y)) + 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) => { + 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) + + 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() + } + }, [onStateChange, opponentUpgrades, playerClassId, playerTalentModifiers, playerUpgrades, round]) + + return ( +
+
+
+ ) +}) + +function syncArenaWorld( + scene: THREE.Scene, + state: ArenaBattleState, + units: Map, + walls: Map, + wallFrames: Map, + telegraphs: Map, + slamTelegraphs: Map, + birds: Map, + birdLines: Map, + effects: Map, + camera: THREE.PerspectiveCamera, + deltaSeconds: number, + playerClassId: ArenaClassId, +) { + const toWorld = (x: number, y: number) => toArenaWorld(state, x, y) + const activeUnits = new Set() + for (const unit of getArenaBattleUnits(state)) { + activeUnits.add(unit.id) + const mesh = getOrCreateArenaUnit(scene, units, unit, playerClassId) + const point = toWorld(unit.x, unit.y) + const prevX = Number(mesh.userData.prevArenaX ?? unit.x) + const prevY = Number(mesh.userData.prevArenaY ?? unit.y) + const moved = Math.hypot(unit.x - prevX, unit.y - prevY) > 0.08 + mesh.userData.prevArenaX = unit.x + mesh.userData.prevArenaY = unit.y + mesh.position.set(point.x, 0, point.z) + mesh.visible = unit.hp > 0 + mesh.scale.setScalar(1) + const weapon = mesh.getObjectByName('weapon') + if (weapon) weapon.rotation.x = -0.18 + const boss = unit.teamId === 'player' ? state.playerBoss : state.opponentBoss + const inMelee = boss.hp > 0 && Math.hypot(unit.x - boss.x, unit.y - boss.y) <= boss.radius + (unit.role === 'tank' ? 82 : 96) + const activeTarget = unit.activeTargetId ? findArenaRenderTarget(state, unit.activeTargetId) : null + const facingTarget = unit.id === 'player' ? null : activeTarget ?? (inMelee ? boss : null) + mesh.rotation.y = unit.id === 'player' + ? Math.atan2(state.player.facingX, state.player.facingY) + : facingTarget + ? Math.atan2(facingTarget.x - unit.x, facingTarget.y - unit.y) + : unit.teamId === 'player' ? Math.PI / 2 : -Math.PI / 2 + animateCombatWeapon(mesh, unit.role, { elapsed: state.elapsed + unit.aiSlot * 0.17, inMelee }) + updateCombatUnitModel(mesh, deltaSeconds, { + abilityId: unit.activeAbility, + action: unit.id === 'player' && moved + ? 'run' + : inMelee && (unit.role === 'tank' || unit.role === 'melee') + ? 'attack' + : moved + ? 'run' + : 'idle', + role: unit.id === 'player' ? 'player' : unit.role, + }) + } + for (const boss of getArenaBattleBosses(state)) { + activeUnits.add(boss.id) + const mesh = getOrCreateBossUnit(scene, units, boss) + const point = toWorld(boss.x, boss.y) + mesh.position.set(point.x, 0, point.z) + mesh.visible = boss.hp > 0 + mesh.rotation.y = Math.atan2(boss.chargeVector.x, boss.chargeVector.y) + const actionPulse = boss.activeAction === 'melee' || boss.activeAction === 'spell' || boss.activeAction === 'stomp' + ? 1 + Math.sin(state.elapsed * 22) * 0.045 + : 1 + mesh.scale.setScalar((1 + (boss.spawnCount - 1) * 0.08) * actionPulse) + syncBossTargetRing(mesh, boss, state.targetId === boss.id) + updateCombatUnitModel(mesh, deltaSeconds, { + action: boss.activeAction === 'run' + ? 'run' + : boss.activeAction === 'spell' || boss.activeAction === 'stomp' + ? 'spell' + : boss.activeAction === 'melee' + ? 'attack' + : 'idle', + role: 'boss', + }) + } + pruneObjectMap(units, activeUnits) + + const activeTelegraphs = new Set() + for (const boss of getArenaBattleBosses(state)) { + if (boss.telegraph.active) { + activeTelegraphs.add(boss.id) + const mesh = getLineTelegraph(scene, telegraphs, boss.id) + syncLineTelegraphGeometry(mesh, boss.telegraph.start, boss.telegraph.end, boss.telegraph.width, (x, y) => toArenaWorld(state, x, y)) + } + } + pruneObjectMap(telegraphs, activeTelegraphs) + + const activeSlams = new Set() + for (const boss of getArenaBattleBosses(state)) { + if (boss.slamTelegraph.active) { + activeSlams.add(boss.id) + const mesh = getCircleTelegraph(scene, slamTelegraphs, boss.id) + const point = toWorld(boss.x, boss.y) + mesh.position.set(point.x, 0.04, point.z) + mesh.scale.setScalar(boss.slamTelegraph.radius / ACTION_ARENA_SCALE) + } + } + pruneObjectMap(slamTelegraphs, activeSlams) + + const activeWalls = new Set() + for (const wall of state.walls) { + activeWalls.add(wall.id) + let mesh = walls.get(wall.id) + if (!mesh) { + mesh = new THREE.Mesh( + new THREE.BoxGeometry(wall.width / ACTION_ARENA_SCALE, 1.8, wall.height / ACTION_ARENA_SCALE), + new THREE.MeshStandardMaterial({ color: 0x596170, roughness: 0.72, transparent: true, opacity: 0.92 }), + ) + mesh.castShadow = true + mesh.receiveShadow = true + scene.add(mesh) + walls.set(wall.id, mesh) + } + const point = toWorld(wall.x, wall.y) + mesh.position.set(point.x, wall.warning > 0 ? 0.18 : 0.9, point.z) + mesh.scale.y = wall.warning > 0 ? 0.2 : 1 + } + pruneObjectMap(walls, activeWalls) + syncBossWallFrames(scene, wallFrames, state) + syncWallPartyFrames(scene, wallFrames, state) + + const activeBirds = new Set() + const activeBirdLines = new Set() + for (const bird of state.birds) { + activeBirds.add(bird.id) + activeBirdLines.add(bird.id) + let line = birdLines.get(bird.id) + if (!line) { + line = new THREE.Mesh( + new THREE.PlaneGeometry(20, Math.max(0.42, (bird.radius * 2) / ACTION_ARENA_SCALE)), + new THREE.MeshBasicMaterial({ color: 0xff3434, transparent: true, opacity: 0.36, side: THREE.DoubleSide }), + ) + line.rotation.x = -Math.PI / 2 + scene.add(line) + birdLines.set(bird.id, line) + } + const linePoint = toWorld(state.arena.width / 2, bird.y) + line.position.set(0, 0.045, linePoint.z) + const lineMaterial = line.material as THREE.MeshBasicMaterial + lineMaterial.opacity = bird.warning > 0 ? 0.52 + Math.sin(state.elapsed * 18) * 0.16 : 0.24 + + let mesh = birds.get(bird.id) + if (!mesh) { + mesh = new THREE.Group() + addBirdMesh(mesh, 'bird') + scene.add(mesh) + birds.set(bird.id, mesh) + } + const point = toWorld(bird.x, bird.y) + mesh.position.set(point.x, 1.05 + Math.sin(state.elapsed * 16) * 0.14, point.z) + mesh.rotation.y = bird.vx > 0 ? Math.PI / 2 : -Math.PI / 2 + mesh.visible = bird.warning <= 0 + } + pruneObjectMap(birds, activeBirds) + pruneObjectMap(birdLines, activeBirdLines) + + syncArenaEffects(scene, state, effects, camera, deltaSeconds) +} + +function toArenaWorld(state: ArenaBattleState, x: number, y: number) { + return { + x: (x - state.arena.width / 2) / ACTION_ARENA_SCALE, + z: (y - state.arena.height / 2) / ACTION_ARENA_SCALE, + } +} + +function findArenaRenderTarget(state: ArenaBattleState, targetId: string) { + return getArenaBattleUnits(state).find((unit) => unit.id === targetId) + ?? getArenaBattleBosses(state).find((boss) => boss.id === targetId) + ?? null +} + +function getOrCreateArenaUnit(scene: THREE.Scene, units: Map, unit: ArenaBattleUnit, playerClassId: ArenaClassId) { + const existing = units.get(unit.id) + if (existing) return existing + + const group = new THREE.Group() as UnitMesh + group.userData.role = unit.role + group.userData.teamId = unit.teamId + addHumanoidMesh(group, unit, playerClassId) + scene.add(group) + units.set(unit.id, group) + return group +} + +function getOrCreateBossUnit(scene: THREE.Scene, units: Map, boss: ArenaBossState) { + const existing = units.get(boss.id) + if (existing) return existing + + const group = new THREE.Group() as UnitMesh + group.userData.kind = boss.kind + group.userData.role = 'boss' + group.userData.teamId = boss.teamId + addBossMesh(group, boss) + scene.add(group) + units.set(boss.id, group) + return group +} + +function syncBossTargetRing(mesh: UnitMesh, boss: ArenaBossState, selected: boolean) { + let ring = mesh.getObjectByName('targetRing') as THREE.Mesh | undefined + if (!ring) { + ring = new THREE.Mesh( + new THREE.TorusGeometry(1, 0.035, 8, 48), + new THREE.MeshBasicMaterial({ color: 0xffd86b, transparent: true, opacity: 0.9 }), + ) + ring.name = 'targetRing' + ring.rotation.x = Math.PI / 2 + ring.position.y = 0.07 + mesh.add(ring) + } + ring.visible = selected && boss.hp > 0 + ring.scale.setScalar(Math.max(0.78, boss.radius / 44)) +} + +function syncBossWallFrames(scene: THREE.Scene, wallFrames: Map, state: ArenaBattleState) { + const bosses = [ + { boss: state.playerBoss, z: -7.18, label: 'YOUR BOSS' }, + { boss: state.opponentBoss, z: 7.18, label: 'RIVAL BOSS' }, + ] + for (const { boss, label, z } of bosses) { + const id = `${boss.id}-wall-boss` + const mesh = getOrCreateWallFrame(scene, wallFrames, id) + drawBossWallFrame(mesh, boss, label, state.targetId === boss.id) + mesh.position.set(-0.145, 4.0, z) + mesh.visible = true + } +} + +function syncWallPartyFrames(scene: THREE.Scene, wallFrames: Map, state: ArenaBattleState) { + const frames = getArenaBattleFrames(state).opponentFrames + const active = new Set([ + `${state.playerBoss.id}-wall-boss`, + `${state.opponentBoss.id}-wall-boss`, + ]) + frames.forEach((frame, index) => { + active.add(frame.id) + const mesh = getOrCreateWallFrame(scene, wallFrames, frame.id) + const mana = frame.id === 'opponent-healer' + ? { value: state.opponentHealerResource.mana, max: state.opponentHealerResource.maxMana } + : undefined + drawWallFrame(mesh, frame, mana) + mesh.position.set(-0.145, 3.02, -4.55 + index * 2.28) + mesh.visible = true + }) + pruneObjectMap(wallFrames, active) +} + +function getOrCreateWallFrame(scene: THREE.Scene, wallFrames: Map, id: string) { + const existing = wallFrames.get(id) + if (existing) return existing + + const canvas = document.createElement('canvas') + canvas.width = 320 + canvas.height = 116 + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.FrontSide, + }) + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2.28, 0.94), material) as WallFrameMesh + mesh.rotation.y = -Math.PI / 2 + mesh.renderOrder = 4 + mesh.userData.canvas = canvas + mesh.userData.context = canvas.getContext('2d') + mesh.userData.texture = texture + scene.add(mesh) + wallFrames.set(id, mesh) + return mesh +} + +function drawWallFrame(mesh: WallFrameMesh, frame: RaidFrame, mana?: { value: number, max: number }) { + const canvas = mesh.userData.canvas + const context = mesh.userData.context + if (!canvas || !context) return + + const hpPercent = Math.max(0, Math.min(1, frame.hp / frame.maxHp)) + const shieldPercent = Math.max(0, Math.min(1, frame.shield / frame.maxHp)) + const manaPercent = mana ? Math.max(0, Math.min(1, mana.value / mana.max)) : 0 + context.clearRect(0, 0, canvas.width, canvas.height) + context.fillStyle = 'rgba(10, 12, 18, 0.82)' + context.fillRect(0, 0, canvas.width, canvas.height) + context.strokeStyle = 'rgba(94, 199, 255, 0.85)' + context.lineWidth = 3 + context.strokeRect(2, 2, canvas.width - 4, canvas.height - 4) + context.fillStyle = '#f0d06a' + context.font = '700 14px monospace' + context.fillText(frame.role.toUpperCase(), 14, 24) + context.fillStyle = '#f6f2de' + context.font = '700 18px monospace' + context.fillText(truncateWallText(context, frame.name, 220), 14, 48) + if (frame.role === 'melee' || frame.role === 'ranged') { + context.fillStyle = 'rgba(255, 216, 107, 0.16)' + context.fillRect(14, 55, 292, 22) + context.strokeStyle = 'rgba(255, 216, 107, 0.58)' + context.lineWidth = 1 + context.strokeRect(14, 55, 292, 22) + context.fillStyle = '#ffdf7e' + context.font = '700 16px monospace' + context.fillText(`DAMAGE ${Math.round(frame.damageDone ?? 0).toLocaleString()}`, 22, 71) + } + drawFrameBar(context, 14, 82, 292, 12, hpPercent, '#76d39a') + if (frame.shield > 0) drawFrameBar(context, 14, 82, 292, 12, Math.min(1, hpPercent + shieldPercent), '#5ec7ff', hpPercent) + if (mana) drawFrameBar(context, 14, 98, 292, 8, manaPercent, '#247dff') + context.fillStyle = '#c9ccdb' + context.font = '700 13px monospace' + context.textAlign = 'right' + context.fillText(`${Math.ceil(frame.hp)}/${frame.maxHp}`, 306, 48) + context.textAlign = 'left' + if (mesh.userData.texture) mesh.userData.texture.needsUpdate = true +} + +function truncateWallText(context: CanvasRenderingContext2D, text: string, maxWidth: number) { + if (context.measureText(text).width <= maxWidth) return text + let next = text + while (next.length > 4 && context.measureText(`${next}...`).width > maxWidth) next = next.slice(0, -1) + return `${next}...` +} + +function drawBossWallFrame(mesh: WallFrameMesh, boss: ArenaBossState, label: string, selected: boolean) { + const canvas = mesh.userData.canvas + const context = mesh.userData.context + if (!canvas || !context) return + + const hpPercent = Math.max(0, Math.min(1, boss.hp / boss.maxHp)) + context.clearRect(0, 0, canvas.width, canvas.height) + context.fillStyle = 'rgba(10, 12, 18, 0.9)' + context.fillRect(0, 0, canvas.width, canvas.height) + context.strokeStyle = selected ? '#ffd86b' : '#ff6b84' + context.lineWidth = selected ? 5 : 3 + context.strokeRect(3, 3, canvas.width - 6, canvas.height - 6) + context.fillStyle = '#f0d06a' + context.font = '700 13px monospace' + context.fillText(label, 14, 24) + context.fillStyle = '#f6f2de' + context.font = '700 17px monospace' + context.fillText(boss.name, 14, 48) + drawFrameBar(context, 14, 60, 292, 16, hpPercent, '#e84c61') + context.fillStyle = '#c9ccdb' + context.font = '700 13px monospace' + context.textAlign = 'right' + context.fillText(`${Math.ceil(boss.hp)}/${boss.maxHp}`, 306, 49) + context.textAlign = 'left' + if (mesh.userData.texture) mesh.userData.texture.needsUpdate = true +} + +function drawFrameBar( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + percent: number, + color: string, + startPercent = 0, +) { + context.fillStyle = '#05070b' + if (startPercent === 0) context.fillRect(x, y, width, height) + context.fillStyle = color + context.fillRect(x + width * startPercent, y, Math.max(0, width * (percent - startPercent)), height) +} + +function addBossMesh(group: THREE.Group, boss: ArenaBossState) { + const fallback = new THREE.Group() + fallback.name = 'proceduralFallback' + group.add(fallback) + if (boss.kind === 'ysolei' || boss.kind === 'korzul-the-gravewyrm') { + addCyberDragonMesh(fallback) + addClaudeCraftBossModel(group, boss.kind) + return + } + if (boss.kind === 'deacon-varric' || boss.kind === 'grand-necromancer-velkhar' || boss.kind === 'vael-the-mistcaller') { + addHumanoidBossFallback(fallback, boss.kind === 'vael-the-mistcaller' ? 0x48c9b0 : 0x6a5474) + addClaudeCraftBossModel(group, boss.kind) + return + } + addBoarMesh(fallback, { + color: 0x8f6f46, + kind: 'bulldrome', + }) + addClaudeCraftBossModel(group, boss.kind) +} + +function addHumanoidBossFallback(group: THREE.Group, color: number) { + const robe = new THREE.MeshStandardMaterial({ color, roughness: 0.78 }) + const trim = new THREE.MeshStandardMaterial({ color: 0xd7c27a, roughness: 0.55 }) + const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.32, 0.9, 8, 16), robe) + body.position.y = 0.72 + body.castShadow = true + group.add(body) + + const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 10), trim) + head.position.y = 1.45 + head.castShadow = true + group.add(head) + + const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.045, 1.55, 8), trim) + staff.position.set(0.42, 0.86, 0.04) + staff.rotation.z = -0.12 + staff.castShadow = true + group.add(staff) +} + +function addHumanoidMesh(group: THREE.Group, unit: ArenaBattleUnit, playerClassId: ArenaClassId) { + addCombatUnitMesh(group, { + classId: unit.id === 'player' ? playerClassId : unit.classId, + id: unit.id, + role: unit.id === 'player' ? 'player' : unit.role, + teamId: unit.teamId, + }) + const ring = new THREE.Mesh( + new THREE.TorusGeometry(0.43, 0.018, 8, 28), + new THREE.MeshBasicMaterial({ color: unit.teamId === 'player' ? 0x5ec7ff : 0xff6b84, transparent: true, opacity: 0.75 }), + ) + ring.rotation.x = Math.PI / 2 + ring.position.y = 0.035 + group.add(ring) +} + +function syncArenaEffects(scene: THREE.Scene, state: ArenaBattleState, effects: Map, camera: THREE.PerspectiveCamera, deltaSeconds: number) { + const toWorld = (x: number, y: number) => toArenaWorld(state, x, y) + for (const event of state.healEvents) { + if (effects.has(event.id)) continue + const burst = createHealBurstEffect({ simple: true, color: 0x7dff9d }) + burst.userData.targetId = event.targetId + burst.userData.age = 0 + scene.add(burst) + effects.set(event.id, burst) + + const textId = `${event.id}-heal-text` + const text = createFloatingCombatText(`+${Math.round(event.amount)}`, 0x7dff9d) + text.userData.targetId = event.targetId + text.userData.age = 0 + text.userData.combatText = true + text.userData.floatOffset = 0.28 + scene.add(text) + effects.set(textId, text) + } + for (const event of state.damageEvents) { + const textId = `${event.id}-damage-text` + if (effects.has(textId)) continue + const text = createFloatingCombatText(String(Math.round(event.amount)), event.teamId === 'player' ? 0xffe066 : 0xff6b84) + text.userData.targetId = event.targetId + text.userData.age = 0 + text.userData.combatText = true + text.userData.floatOffset = event.teamId === 'player' ? -0.2 : 0.2 + scene.add(text) + effects.set(textId, text) + } + for (const event of state.abilityEvents) { + if (effects.has(event.id)) continue + const burst = createHealBurstEffect({ simple: true, color: getAbilityEventColor(event.school) }) + burst.userData.targetId = event.targetId + burst.userData.age = 0 + burst.userData.ability = true + scene.add(burst) + effects.set(event.id, burst) + + const ability = getArenaAbility(event.abilityId) + const caster = findArenaRenderTarget(state, event.casterId) + const target = findArenaRenderTarget(state, event.targetId) + if (caster && target && (ability.animation === 'ranged' || ability.animation === 'spell')) { + const projectileId = `${event.id}-projectile` + const projectile = createAbilityProjectile(ability.animation, getAbilityEventColor(event.school)) + const start = toWorld(caster.x, caster.y) + const end = toWorld(target.x, target.y) + projectile.userData.age = 0 + projectile.userData.projectile = true + projectile.userData.sourceX = start.x + projectile.userData.sourceZ = start.z + projectile.userData.targetX = end.x + projectile.userData.targetZ = end.z + projectile.userData.arc = ability.animation === 'ranged' ? 0.24 : 0.62 + scene.add(projectile) + effects.set(projectileId, projectile) + } + } + for (const unit of getArenaBattleUnits(state)) { + if (unit.hp <= 0 || unit.stunTimer <= 0 || effects.has(`stun-${unit.id}`)) continue + const effect = createHealBurstEffect({ simple: true, color: 0xffd36d }) + effect.userData.targetId = unit.id + effect.userData.age = 0 + effect.userData.stun = true + scene.add(effect) + effects.set(`stun-${unit.id}`, effect) + } + + const active = new Set() + for (const [id, effect] of effects) { + const age = Number(effect.userData.age ?? 0) + deltaSeconds + effect.userData.age = age + if (effect.userData.projectile) { + const lifetime = 0.62 + if (age > lifetime) continue + active.add(id) + const t = Math.max(0, Math.min(1, age / lifetime)) + const sourceX = Number(effect.userData.sourceX ?? 0) + const sourceZ = Number(effect.userData.sourceZ ?? 0) + const targetX = Number(effect.userData.targetX ?? sourceX) + const targetZ = Number(effect.userData.targetZ ?? sourceZ) + const arc = Number(effect.userData.arc ?? 0.42) + effect.position.set( + THREE.MathUtils.lerp(sourceX, targetX, t), + 0.68 + Math.sin(t * Math.PI) * arc, + THREE.MathUtils.lerp(sourceZ, targetZ, t), + ) + effect.rotation.y = Math.atan2(targetX - sourceX, targetZ - sourceZ) + effect.scale.setScalar(0.72 + Math.sin(t * Math.PI) * 0.28) + setObjectOpacity(effect, Math.max(0, 1 - t)) + continue + } + const target = getArenaBattleUnits(state).find((unit) => unit.id === effect.userData.targetId) + ?? getArenaBattleBosses(state).find((boss) => boss.id === effect.userData.targetId) + const lifetime = effect.userData.combatText ? 1.15 : 0.85 + if (!target || age > lifetime || (effect.userData.stun && (!('stunTimer' in target) || target.stunTimer <= 0))) continue + active.add(id) + const point = toWorld(target.x, target.y) + if (effect.userData.combatText) { + const offset = Number(effect.userData.floatOffset ?? 0) + effect.position.set(point.x + offset, 1.65 + age * 0.75, point.z) + effect.lookAt(camera.position) + setObjectOpacity(effect, Math.max(0, 1 - age / lifetime)) + continue + } + effect.position.set(point.x, effect.userData.stun ? 1.42 : 0.08, point.z) + effect.scale.setScalar(0.6 + age * 1.5) + effect.rotation.y += deltaSeconds * 4 + setObjectOpacity(effect, Math.max(0, 1 - age / lifetime)) + } + pruneObjectMap(effects, active) +} + +function createFloatingCombatText(text: string, color: number) { + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 96 + const context = canvas.getContext('2d') + if (context) { + context.clearRect(0, 0, canvas.width, canvas.height) + context.font = '700 44px monospace' + context.textAlign = 'center' + context.textBaseline = 'middle' + context.lineWidth = 8 + context.strokeStyle = 'rgba(0, 0, 0, 0.9)' + context.strokeText(text, canvas.width / 2, canvas.height / 2) + context.lineWidth = 3 + context.strokeStyle = 'rgba(255, 255, 255, 0.55)' + context.strokeText(text, canvas.width / 2, canvas.height / 2) + context.fillStyle = `#${color.toString(16).padStart(6, '0')}` + context.fillText(text, canvas.width / 2, canvas.height / 2) + } + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + const material = new THREE.MeshBasicMaterial({ + depthTest: false, + map: texture, + side: THREE.DoubleSide, + transparent: true, + }) + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.35, 0.5), material) + mesh.renderOrder = 12 + const group = new THREE.Group() + group.add(mesh) + return group +} + +function createAbilityProjectile(kind: 'ranged' | 'spell', color: number) { + const group = new THREE.Group() + if (kind === 'ranged') { + const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.78 }) + const metal = new THREE.MeshStandardMaterial({ color: 0xf3f5ff, emissive: color, emissiveIntensity: 0.28, roughness: 0.32, metalness: 0.42 }) + const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.035, 0.74), wood) + group.add(shaft) + const tip = new THREE.Mesh(new THREE.ConeGeometry(0.065, 0.16, 8), metal) + tip.position.z = -0.45 + tip.rotation.x = -Math.PI / 2 + group.add(tip) + return group + } + + const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.92 }) + const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 14, 10), material) + group.add(core) + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.014, 8, 24), material) + ring.rotation.x = Math.PI / 2 + group.add(ring) + const trail = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.34, 10), material) + trail.position.z = 0.25 + trail.rotation.x = Math.PI / 2 + group.add(trail) + return group +} + +function getAbilityEventColor(school: string) { + if (school === 'fire') return 0xff7043 + if (school === 'frost') return 0x8edcff + if (school === 'arcane') return 0xb184ff + if (school === 'shadow') return 0x8e68c4 + if (school === 'nature') return 0x7ed957 + if (school === 'holy') return 0xfff1a8 + return 0xe5b95f +} diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index 5931f4d..325eccc 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -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 + +

{message || serverMessage || ( mode === 'register' diff --git a/src/components/BulldromeBossSlice.tsx b/src/components/BulldromeBossSlice.tsx index 1ec47ee..3a98c6c 100644 --- a/src/components/BulldromeBossSlice.tsx +++ b/src/components/BulldromeBossSlice.tsx @@ -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(null) - const gameRef = useRef(null) - const sceneRef = useRef(null) + const combatProfile = useMemo(() => createActionCombatProfile(character), [character]) + const threeSceneRef = useRef(null) const completionSentRef = useRef(false) const rewardedBossKillsRef = useRef(0) - const [state, setState] = useState(() => 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(() => createBulldromeState(difficulty, dungeonId, runMode, combatProfile.classId, combatProfile.talentModifiers)) + const audioUnlockedRef = useRef(false) + const seenHealEventIdsRef = useRef(new Set()) + const previousLastHitRef = useRef(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 ( -

+
-
-
-

Action Boss Prototype

-

{getRunTitle(dungeonId, difficulty, runMode)}

-
- -
- -
- +
-
- {encounterTitle} - {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp} - - - -
- {state.player.currentCast && ( -
-
- {SPELLS[state.player.currentCast.spell].name} - {state.player.currentCast.remaining.toFixed(1)}s -
+
+
+ { + playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 }) + threeSceneRef.current?.selectTarget(id) + }} + /> +
+
+ {encounterTitle} + {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp} - +
- )} -
-
-
- -
-
-
Boss
-
{state.boss.phase}
-
-
-
Time
-
{state.elapsed.toFixed(1)}s
-
-
-
Stun
-
{state.player.stunTimer > 0 ? `${state.player.stunTimer.toFixed(1)}s` : 'Clear'}
-
-
-
Target
-
{raidFrames.find((frame) => frame.selected)?.name ?? 'None'}
-
-
- -
- Controls - WASD: move - Up / Down: target frame - 1-5: healing spells - R: reset -
- +
) } -function EnemyRow({ enemy }: { enemy: EnemyFrame }) { - const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100)) - - return ( -
-
- {enemy.name} - {Math.ceil(enemy.hp)} / {enemy.maxHp} -
- - - -
- ) +function createDungeonActionSlots(state: BulldromeState, targetKind: 'ally' | 'enemy'): Array> { + 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 ( - - ) -} - -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 ( - - ) -} - -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 ( -
-
- {label} - {Math.ceil(value)} / {max} -
- - - -
- ) + 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', + } } diff --git a/src/components/CombatAudio.ts b/src/components/CombatAudio.ts new file mode 100644 index 0000000..987371f --- /dev/null +++ b/src/components/CombatAudio.ts @@ -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. + }) +} diff --git a/src/components/CombatHud.tsx b/src/components/CombatHud.tsx new file mode 100644 index 0000000..91d029b --- /dev/null +++ b/src/components/CombatHud.tsx @@ -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 = { + 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 + onSelect?: (id: string) => void +}) { + return ( +
+ {frames.map((frame) => ( + onSelect(frame.id) : undefined} + /> + ))} +
+ ) +} + +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 ( + + ) +} + +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 ( +
+
+ {kindLabel} + {label} + {detail} +
+ + + + {action && } +
+ ) +} + +export function CombatActionBar({ + className = '', + columns, + globalCooldown, + onCast, + slots, +}: { + className?: string + columns: number + globalCooldown: number + onCast: (slot: Slot) => void + slots: CombatActionSlot[] +}) { + const [tooltipSlot, setTooltipSlot] = useState(null) + const tooltip = slots.find((slot) => slot.id === tooltipSlot) + + return ( +
+ {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 ( + + ) + })} + {tooltip && ( +
+
+ +
+ {tooltip.name} + {tooltip.tooltip.rank} +
+
+
+
School
{tooltip.tooltip.school}
+
Target
{tooltip.tooltip.target}
+
Cost
{tooltip.tooltip.cost}
+
Cast
{tooltip.tooltip.cast}
+ {tooltip.tooltip.cooldown &&
Cooldown
{tooltip.tooltip.cooldown}
} +
+

{tooltip.tooltip.description}

+ {tooltip.cooldown > 0 && {tooltip.cooldown.toFixed(1)}s remaining} +
+ )} +
+ ) +} diff --git a/src/components/CombatPauseMenu.tsx b/src/components/CombatPauseMenu.tsx new file mode 100644 index 0000000..1012d29 --- /dev/null +++ b/src/components/CombatPauseMenu.tsx @@ -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 ( +
+
+
+

Paused

+

Settings

+
+
+ + + + +
+
+ {onOpenCharacter && } + {onOpenInventory && } + {onOpenTalents && } + + +
+
+
+ ) +} diff --git a/src/components/ThreeActionScene.tsx b/src/components/ThreeActionScene.tsx new file mode 100644 index 0000000..bebc909 --- /dev/null +++ b/src/components/ThreeActionScene.tsx @@ -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(function ThreeActionScene({ + difficulty, + dungeonId, + onRenderUnavailable, + paused = false, + playerClassId = 'priest', + playerTalentModifiers, + runMode, + onStateChange, +}, ref) { + const mountRef = useRef(null) + const stateRef = useRef(createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers)) + const queuedTargetRef = useRef(null) + const queuedSpellRef = useRef(null) + const pausedRef = useRef(paused) + const actionQueueRef = useRef({ reset: false }) + const keysRef = useRef(new Set()) + 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() + const fireballs = new Map() + const fireSpots = new Map() + const spinningBlades = new Map() + const telegraphs = new Map() + const arcTelegraphs = new Map() + const slamTelegraphs = new Map() + const partyEffects = new Map() + const healEffects = new Map() + const stunEffects = new Map() + + 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 ( +
+
+
+
WASD move | drag camera
+
+
+ ) +}) + +export default ThreeActionScene + +function syncWorld( + scene: THREE.Scene, + state: BulldromeState, + units: Map, + fireballs: Map, + fireSpots: Map, + spinningBlades: Map, + telegraphs: Map, + arcTelegraphs: Map, + slamTelegraphs: Map, + partyEffects: Map, + healEffects: Map, + stunEffects: Map, + 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() + 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() + 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() + 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() + 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, 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, + color: number, + height: number, + toWorld: (x: number, y: number) => { x: number, z: number }, +) { + const active = new Set() + 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, + toWorld: (x: number, y: number) => { x: number, z: number }, +) { + const active = new Set() + 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, + 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, + 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() + 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, + toWorld: (x: number, y: number) => { x: number, z: number }, +) { + const active = new Set() + 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) +} diff --git a/src/styles.css b/src/styles.css index cd84a30..df15830 100644 --- a/src/styles.css +++ b/src/styles.css @@ -55,6 +55,123 @@ body { overflow: hidden; } +.model-viewer-panel { + display: grid; + flex: 1; + gap: 12px; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; +} + +.model-viewer-toolbar { + align-items: end; + display: grid; + gap: 10px; + grid-template-columns: minmax(120px, 0.9fr) minmax(150px, 1.15fr) minmax(150px, 1.15fr) minmax(130px, 1fr) minmax(110px, 0.8fr) auto; +} + +.model-viewer-toolbar label { + display: grid; + gap: 5px; + min-width: 0; +} + +.model-viewer-toolbar span, +.model-viewer-meta span, +.model-viewer-meta strong { + font-family: 'Press Start 2P', monospace; +} + +.model-viewer-toolbar span { + color: var(--muted); + font-size: 7px; +} + +.model-viewer-toolbar select, +.model-viewer-toolbar input, +.model-viewer-toolbar button { + background: #0f1117; + border: 2px solid #07080b; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + height: 36px; + min-width: 0; + outline: 2px solid #3f414c; + padding: 0 9px; +} + +.model-viewer-toolbar button.active, +.model-viewer-toolbar button[aria-pressed='true'] { + color: var(--gold); + outline-color: var(--gold); +} + +.model-viewer-stage { + background: #0d1218; + border: 3px solid #090a0d; + min-height: 280px; + min-width: 0; + outline: 2px solid #3f414c; + overflow: hidden; + touch-action: none; +} + +.model-viewer-stage canvas { + display: block; + height: 100%; + width: 100%; +} + +.model-viewer-meta { + align-items: center; + background: #101319; + border: 2px solid #08090c; + display: grid; + gap: 8px; + grid-template-columns: minmax(100px, 0.8fr) minmax(100px, 0.8fr) minmax(0, 1.6fr) minmax(80px, 0.7fr); + outline: 2px solid #3f414c; + padding: 10px; +} + +.model-viewer-meta strong { + color: var(--gold); + font-size: 8px; +} + +.model-viewer-meta span { + color: var(--muted); + font-size: 7px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 700px) { + .model-viewer-toolbar { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .model-viewer-toolbar button { + width: 100%; + } + + .model-viewer-stage { + min-height: 210px; + } +} + +@media (max-width: 520px) { + .model-viewer-toolbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .model-viewer-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + #root { background: transparent; height: 100dvh; @@ -159,6 +276,20 @@ h2 { gap: 14px; } +.action-screen-actions { + align-items: center; + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.action-main-menu { + align-items: start; + display: grid; + gap: 14px; + grid-template-columns: minmax(0, 1fr) 190px; +} + .action-character-strip { align-items: center; display: flex; @@ -277,6 +408,11 @@ h2 { margin-top: 18px; } +.auth-offline-button { + margin-top: 10px; + width: 100%; +} + .auth-card label { color: var(--muted); display: grid; @@ -434,11 +570,40 @@ h2 { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(0, 1fr)); - margin: 0 auto; - max-width: 930px; + margin: 0; + max-width: none; width: 100%; } +.action-menu-account { + align-items: stretch; + display: grid; + gap: 8px; +} + +.action-menu-account .action-character-strip { + align-items: stretch; + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 6px; + justify-content: stretch; + outline: 2px solid #34343d; + padding: 10px; + text-align: left; +} + +.auth-mini-button { + font-size: 7px; + min-height: 34px; + padding: 8px 10px; + width: 100%; +} + +.action-screen-actions .auth-mini-button { + width: auto; +} + .action-hub-nav .menu-card:first-child { grid-column: auto; } @@ -567,6 +732,20 @@ h2 { font-size: 14px; height: 44px; justify-content: center; + overflow: hidden; + position: relative; +} + +.action-dungeon-glyph img { + display: block; + height: 100%; + image-rendering: auto; + object-fit: cover; + width: 100%; +} + +.action-dungeon-glyph span { + display: none; } .action-dungeon-board .action-dungeon-card > span:last-child { @@ -870,6 +1049,28 @@ h2 { padding: 7px; } +.action-ai-avoidance-fields { + border-top: 2px solid #30313a; + display: grid; + gap: 8px; + grid-column: 1 / -1; + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding-top: 8px; +} + +.action-ai-avoidance-fields .action-toggle-field { + align-items: center; + color: var(--gold); + display: flex; + gap: 8px; + grid-column: 1 / -1; +} + +.action-ai-avoidance-fields .action-toggle-field input { + height: 18px; + width: 18px; +} + .action-disabled-attacks { display: flex; flex-wrap: wrap; @@ -902,9 +1103,906 @@ h2 { margin: 0; } -.action-gear-summary { +.arena-panel { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 12px; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + outline: 2px solid #41404a; + padding: 14px; + position: relative; +} + +.arena-header, +.arena-status-strip, +.arena-team-card header, +.arena-draft-heading { + align-items: center; + display: flex; + justify-content: space-between; + gap: 10px; +} + +.arena-scoreboard { + align-items: center; + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + min-width: min(420px, 100%); + outline: 1px solid #30313a; + padding: 9px 12px; + text-align: center; +} + +.arena-scoreboard span, +.arena-scoreboard strong, +.arena-status-strip h3, +.arena-team-card h3, +.arena-team-card header span, +.arena-draft h3, +.arena-draft-heading > span, +.arena-upgrade-list button, +.arena-loadout span, +.arena-unit { + font-family: 'Press Start 2P', monospace; +} + +.arena-scoreboard span { + color: var(--muted); + font-size: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arena-scoreboard strong { + color: var(--gold); + font-size: 13px; +} + +.arena-layout { + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr); + min-height: 0; +} + +.arena-fight, +.arena-draft { + display: grid; + gap: 10px; + min-height: 0; +} + +.arena-fight { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.arena-draft { + grid-template-rows: minmax(0, 1fr) auto; +} + +.arena-status-strip, +.arena-draft > section, +.arena-loadout { + background: #171922; + border: 2px solid #090a0d; + outline: 1px solid #30313a; + padding: 10px; +} + +.arena-status-strip h3, +.arena-team-card h3, +.arena-draft h3 { + font-size: 9px; + line-height: 1.25; + margin: 0; +} + +.arena-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.arena-teams { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 0; +} + +.arena-team-card { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 9px; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; + outline: 1px solid #41404a; + padding: 10px; +} + +.arena-team-card.player { + outline-color: #38698a; +} + +.arena-team-card.opponent { + outline-color: #7a3f4a; +} + +.arena-team-card header span { + color: var(--gold); + font-size: 8px; +} + +.arena-energy { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; +} + +.arena-energy i, +.arena-unit i { + background: #090a0d; + display: block; + overflow: hidden; + position: relative; +} + +.arena-energy i { + height: 10px; +} + +.arena-energy b { + background: #5ec7ff; + display: block; + height: 100%; +} + +.arena-energy span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 6px; +} + +.arena-unit-list, +.arena-upgrade-list, +.arena-loadout > div:last-child { + display: grid; + gap: 7px; + min-height: 0; + overflow: auto; +} + +.arena-unit { + background: #171922; + border: 1px solid #090a0d; + display: grid; + gap: 4px; + grid-template-columns: auto minmax(0, 1fr) auto; + min-height: 48px; + padding: 6px; +} + +.arena-unit.dead { + opacity: 0.48; +} + +.arena-unit strong { + font-size: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arena-unit small, +.arena-unit em { + color: var(--muted); + font-size: 5px; + font-style: normal; +} + +.arena-unit i, +.arena-unit em { + grid-column: 1 / -1; +} + +.arena-unit i { + height: 8px; +} + +.arena-hp, +.arena-shield { + display: block; + height: 100%; + left: 0; + position: absolute; + top: 0; +} + +.arena-hp { + background: #76d39a; +} + +.arena-shield { + background: #5ec7ff; +} + +.arena-draft > section { + display: grid; + gap: 9px; + min-height: 0; +} + +.arena-draft-heading > span { + color: var(--gold); + font-size: 8px; +} + +.arena-upgrade-list button { + background: #111319; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 5px; + outline: 1px solid #30313a; + padding: 8px; + text-align: left; +} + +.arena-upgrade-list button:hover:not(:disabled) { + outline-color: var(--gold); +} + +.arena-upgrade-list button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.arena-upgrade-list button.owned { + outline-color: #5ec7ff; +} + +.arena-upgrade-list span, +.arena-upgrade-list small, +.arena-upgrade-list em, +.arena-loadout span { + color: var(--muted); + font-size: 5px; + font-style: normal; + line-height: 1.35; +} + +.arena-upgrade-list strong { + font-size: 7px; + line-height: 1.25; +} + +.arena-upgrade-list em { + color: var(--gold); +} + +.arena-loadout { + display: grid; + gap: 8px; +} + +.arena-loadout span { + display: block; +} + +.pvp-roguelike-panel { + grid-template-rows: auto auto minmax(0, 1fr); +} + +.pvp-roguelike-menu { + display: grid; + gap: 12px; + grid-template-columns: minmax(210px, 0.55fr) minmax(0, 1fr); + min-height: 0; +} + +.pvp-compact-header, +.pvp-row-heading { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; +} + +.pvp-compact-header { + justify-content: center; + min-height: 34px; +} + +.pvp-compact-header .arena-scoreboard { + margin-inline: auto; +} + +.pvp-mode-panel, +.pvp-queue-panel, +.pvp-loadout-panel, +.pvp-roguelike-shop, +.pvp-roguelike-result { + background: #171922; + border: 2px solid #090a0d; + outline: 1px solid #30313a; + padding: 12px; +} + +.pvp-roguelike-panel { + position: relative; +} + +.pvp-mode-panel, +.pvp-queue-panel, +.pvp-loadout-panel, +.pvp-roguelike-shop { + display: grid; + gap: 10px; + min-height: 0; +} + +.pvp-mode-panel h3, +.pvp-queue-panel h3, +.pvp-shop-header h3, +.pvp-shop-category-header h3, +.pvp-active-buffs h3, +.pvp-roguelike-result h3, +.pvp-mode-panel dt, +.pvp-mode-panel dd, +.pvp-mode-buttons button, +.pvp-shop-summary span, +.pvp-shop-summary strong, +.pvp-shop-tabs button, +.pvp-shop-spell-grid button, +.pvp-shop-general, +.pvp-shop-category-header > span, +.pvp-active-buffs span, +.pvp-shop-empty strong, +.pvp-shop-empty span, +.pvp-result-emblem { + font-family: 'Press Start 2P', monospace; +} + +.pvp-mode-panel h3, +.pvp-queue-panel h3, +.pvp-shop-header h3, +.pvp-shop-category-header h3, +.pvp-active-buffs h3, +.pvp-roguelike-result h3 { + font-size: 9px; + line-height: 1.3; + margin: 0; +} + +.pvp-mode-buttons { + display: grid; + gap: 7px; +} + +.pvp-mode-buttons button, +.pvp-shop-tabs button { + background: #111319; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + outline: 1px solid #30313a; + padding: 8px; + text-align: left; +} + +.pvp-shop-spell-grid button, +.pvp-shop-general { + background: #111319; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + outline: 1px solid #30313a; +} + +.pvp-mode-buttons button.active, +.pvp-shop-tabs button.active, +.pvp-shop-spell-grid button.active, +.pvp-shop-general.active { + color: var(--gold); + outline-color: var(--gold); +} + +.pvp-mode-buttons button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.pvp-mode-panel dl { + display: grid; + gap: 7px; + margin: 0; +} + +.pvp-mode-panel dl > div { + background: #111319; + border: 1px solid #090a0d; + display: grid; + gap: 4px; + padding: 7px; +} + +.pvp-mode-panel dt, +.pvp-mode-panel dd, +.pvp-mode-buttons button, +.pvp-shop-tabs button, +.pvp-shop-spell-grid button, +.pvp-shop-general, +.pvp-active-buffs span { + font-size: 6px; + line-height: 1.35; +} + +.pvp-mode-panel dt { + color: var(--muted); +} + +.pvp-mode-panel dd { + color: var(--ink); + margin: 0; +} + +.pvp-queue-panel { + align-content: start; + grid-template-columns: 84px minmax(0, 1fr); +} + +.pvp-queue-glyph { + align-items: center; + background: #111319; + border: 2px solid #090a0d; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + justify-content: center; + min-height: 84px; + outline: 1px solid #41404a; +} + +.pvp-queue-panel p:not(.eyebrow), +.pvp-roguelike-result p { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin: 0; +} + +.pvp-queue-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + grid-column: 1 / -1; +} + +.pvp-loadout-panel { + grid-column: 1 / -1; +} + +.pvp-shop-header, +.pvp-shop-category-header, +.pvp-roguelike-result { + align-items: center; + gap: 10px; + justify-content: space-between; +} + +.pvp-shop-category-header, +.pvp-roguelike-result { + display: flex; +} + +.pvp-roguelike-shop { + background: #11141b; + grid-template-rows: auto minmax(0, 1fr); + outline-color: #4a5163; +} + +.pvp-shop-header { + border-bottom: 1px solid #30313a; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + padding-bottom: 10px; +} + +.pvp-shop-header .arena-actions { + justify-self: end; +} + +.pvp-shop-points { + align-items: center; + background: linear-gradient(180deg, #2d4058, #172437); + border: 2px solid #f6d36b; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.16), + 0 0 18px rgba(246, 211, 107, 0.34), + inset 0 0 14px rgba(94, 199, 255, 0.18); + color: #ffffff; + display: grid; + gap: 5px; + justify-items: center; + min-width: 154px; + padding: 9px 14px; +} + +.pvp-shop-points span, +.pvp-shop-points strong { + color: #ffffff; + font-family: 'Press Start 2P', monospace; + text-shadow: 1px 1px 0 #000; +} + +.pvp-shop-points span { + font-size: 6px; +} + +.pvp-shop-points strong { + font-size: 14px; +} + +.pvp-shop-header span { + color: var(--muted); + display: block; + font-size: 12px; + line-height: 1.35; + margin-top: 5px; +} + +.pvp-shop-header .pvp-shop-points span, +.pvp-shop-header .pvp-shop-points strong { + color: #ffffff; + margin: 0; +} + +.pvp-shop-header .pvp-shop-points span { + font-size: 6px; +} + +.pvp-shop-header .pvp-shop-points strong { + font-size: 14px; +} + +.pvp-shop-summary { display: grid; gap: 6px; + grid-template-columns: minmax(0, 1fr); +} + +.pvp-shop-summary > div { + background: #0d0f15; + border: 1px solid #30313a; + display: grid; + gap: 4px; + min-height: 42px; + padding: 7px 8px; +} + +.pvp-shop-summary span { + color: var(--muted); + font-size: 5px; +} + +.pvp-shop-summary strong { + color: var(--gold); + font-size: 9px; +} + +.pvp-shop-layout { + display: grid; + gap: 10px; + grid-template-areas: "spells main active"; + grid-template-columns: 276px minmax(0, 1fr) 180px; + min-height: 0; +} + +.pvp-shop-spells { + display: grid; + gap: 8px; + grid-area: spells; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; +} + +.pvp-shop-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + grid-area: tabs; +} + +.pvp-shop-main, +.pvp-active-buffs { + display: grid; + gap: 9px; + min-height: 0; +} + +.pvp-shop-main { + grid-area: main; + grid-template-rows: auto minmax(0, 1fr); +} + +.pvp-shop-spell-grid { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(5, minmax(0, 1fr)); + min-height: 0; +} + +.pvp-shop-spell-grid button { + display: grid; + gap: 4px; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + overflow: hidden; + padding: 6px; + text-align: left; +} + +.pvp-shop-spell-grid span { + color: var(--gold); + font-size: 7px; +} + +.pvp-shop-spell-grid strong { + color: var(--ink); + font-size: 5px; + line-height: 1.28; + overflow-wrap: anywhere; +} + +.pvp-shop-general { + min-height: 34px; + padding: 8px; + text-align: center; +} + +.pvp-shop-tabs button { + background: #0d0f15; + min-height: 34px; + min-width: 86px; + padding: 8px 10px; + text-align: center; +} + +.pvp-shop-category-header > span { + color: var(--gold); + font-size: 6px; +} + +.pvp-shop-grid { + align-content: start; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + overflow: auto; + padding-right: 2px; +} + +.pvp-shop-grid button { + background: #171b24; + border-color: #08090c; + min-height: 104px; + outline-color: #3a4050; + padding: 10px; +} + +.pvp-shop-grid button:hover:not(:disabled) { + background: #1d2230; +} + +.pvp-shop-grid button.owned { + background: #10202b; + outline-color: #5ec7ff; +} + +.pvp-shop-grid strong { + color: var(--ink); + font-size: 8px; +} + +.pvp-shop-grid small { + color: #a8afc2; + font-size: 6px; +} + +.pvp-shop-grid em { + align-self: end; + justify-self: start; +} + +.pvp-shop-empty { + align-content: center; + background: #171b24; + border: 2px solid #08090c; + display: grid; + gap: 8px; + min-height: 112px; + outline: 1px solid #3a4050; + padding: 14px; +} + +.pvp-shop-empty strong { + color: var(--ink); + font-size: 8px; +} + +.pvp-shop-empty span { + color: var(--muted); + font-size: 6px; + line-height: 1.4; +} + +.pvp-active-buffs { + background: #0d0f15; + border-left: 1px solid #30313a; + grid-area: active; + grid-template-rows: auto minmax(0, 1fr); + padding-left: 12px; +} + +.pvp-active-buffs > div:last-child { + align-content: start; + display: grid; + gap: 6px; + min-height: 0; + overflow: auto; +} + +.pvp-active-buffs span { + background: #171b24; + border: 1px solid #30313a; + color: var(--ink); + padding: 7px; +} + +.pvp-roguelike-result { + justify-content: start; +} + +.pvp-result-emblem { + align-items: center; + background: #111319; + border: 2px solid #090a0d; + color: var(--gold); + display: flex; + font-size: 10px; + height: 64px; + justify-content: center; + outline: 1px solid #41404a; + width: 64px; +} + +.arena-battle-shell { + background: #08090c; + height: 100dvh; + inset: 0; + max-width: none; + padding: 10px; + position: fixed; + width: 100%; + z-index: 60; +} + +.arena-battle-shell .boss-slice-stage { + box-shadow: none; + gap: 10px; + padding: 12px; +} + +.arena-battle-layout { + grid-template-columns: 156px minmax(0, 1fr) minmax(220px, 260px); +} + +.arena-battle-shell .boss-hud { + margin-left: 10px; +} + +.arena-battle-score { + align-items: center; + background: #171922; + border: 2px solid #090a0d; + display: flex; + gap: 10px; + justify-content: center; + outline: 1px solid #41404a; + padding: 8px 12px; +} + +.arena-battle-score span, +.arena-battle-score strong { + font-family: 'Press Start 2P', monospace; +} + +.arena-battle-score span { + color: var(--muted); + font-size: 7px; +} + +.arena-battle-score strong { + color: var(--gold); + font-size: 10px; +} + +.arena-opponent-frames { + display: grid; + gap: 7px; + min-height: 0; + overflow: auto; +} + +.arena-opponent-frames .party-frame:disabled { + cursor: default; + opacity: 1; +} + +.arena-boss-window-party { + display: grid; + gap: 6px; + grid-template-columns: minmax(0, 1fr); + left: 8px; + max-width: 156px; + position: absolute; + top: 8px; + width: min(156px, 32%); + z-index: 5; +} + +.arena-boss-window-party .party-frame { + background: rgba(17, 19, 25, 0.9); + min-height: 46px; + padding: 5px 6px; +} + +.arena-queue-message { + color: var(--gold); + font-size: 14px; + line-height: 1.2; + margin: 0; +} + +.action-gear-summary { + align-items: start; + display: grid; + gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; +} + +.action-talent-summary { + align-items: center; + gap: 10px; +} + +.action-talent-summary .claudecraft-character-meta { + display: grid; + grid-template-columns: max-content max-content 1fr max-content; + justify-content: stretch; + width: 100%; +} + +.action-talent-summary .talent-available-pill { + color: #fff7dc; + justify-self: center; + text-align: center; +} + +.action-talent-summary .action-talent-actions { + justify-content: flex-end; + margin: 0; } .action-customize-layout { @@ -993,6 +2091,18 @@ h2 { justify-content: space-between; } +.action-inventory-panel > header input { + background: #090a0d; + border: 2px solid #05070b; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 32px; + outline: 1px solid #30313a; + padding: 8px; + width: min(160px, 36vw); +} + .action-inventory-panel > header span, .action-gear-detail header span { color: var(--gold); @@ -1074,6 +2184,479 @@ h2 { opacity: 0.65; } +.claudecraft-menu { + min-height: 0; +} + +.claudecraft-character-meta { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.claudecraft-character-meta span { + background: #171922; + border: 2px solid #090a0d; + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 1px solid #30313a; + padding: 8px; + text-transform: capitalize; +} + +.claudecraft-character-layout, +.claudecraft-bag-layout { + display: grid; + gap: 14px; + grid-template-columns: minmax(270px, 0.9fr) minmax(360px, 1.1fr); + min-height: 0; +} + +.claudecraft-bag-layout { + grid-template-columns: minmax(0, 1fr); +} + +.claudecraft-paperdoll { + align-content: start; +} + +.claudecraft-character-sheet { + grid-template-rows: auto auto minmax(0, 1fr); +} + +.claudecraft-stat-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.claudecraft-bag-panel { + grid-template-rows: auto auto minmax(0, 1fr); + position: relative; +} + +.claudecraft-bag-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.claudecraft-bag-filters button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 32px; + outline: 1px solid #4b4855; + padding: 8px; +} + +.claudecraft-bag-filters button[aria-pressed='true'], +.claudecraft-bag-filters button:hover { + color: var(--gold); + outline-color: var(--gold); +} + +.claudecraft-bag-list button { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); +} + +.claudecraft-item-tooltip { + bottom: 18px; + left: auto; + max-width: calc(100% - 28px); + right: 14px; + transform: none; + width: min(430px, calc(100vw - 40px)); +} + +.claudecraft-item-tooltip .item-tooltip-icon { + align-items: center; + background: #171922; + border: 2px solid #05070b; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 16px; + height: 42px; + justify-content: center; + outline: 1px solid #464550; + width: 42px; +} + +.claudecraft-item-tooltip.quality-poor strong { + color: #9a9a9a; +} + +.claudecraft-item-tooltip.quality-uncommon strong { + color: #4dff72; +} + +.claudecraft-item-tooltip.quality-rare strong { + color: #5b9dff; +} + +.claudecraft-item-tooltip.quality-epic strong { + color: #b46cff; +} + +.claudecraft-item-tooltip.quality-legendary strong { + color: #ff9f38; +} + +.claudecraft-item-tooltip .item-tooltip-stats { + border-top: 1px solid rgba(255, 255, 255, 0.14); + padding-top: 8px; +} + +.claudecraft-item-tooltip .item-tooltip-stats dd { + align-items: center; + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.claudecraft-item-tooltip .item-tooltip-stats em { + margin: 0; +} + +.claudecraft-item-tooltip .item-tooltip-stats .better em { + color: #65f58d; +} + +.claudecraft-item-tooltip .item-tooltip-stats .worse em { + color: #ff6f6f; +} + +.claudecraft-item-tooltip .item-tooltip-stats .same em { + color: var(--muted); +} + +.claudecraft-menu-backdrop { + z-index: 30; +} + +.claudecraft-menu-overlay { + background: rgba(12, 14, 20, 0.98); + border: 2px solid #05070b; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55); + color: var(--ink); + display: grid; + gap: 12px; + max-height: calc(100% - 36px); + max-width: min(980px, calc(100% - 36px)); + min-height: 0; + outline: 1px solid rgba(230, 218, 150, 0.5); + overflow: auto; + padding: 14px; + width: 100%; +} + +.claudecraft-menu-overlay > header { + align-items: start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.claudecraft-menu-overlay h2 { + font-family: 'Press Start 2P', monospace; + font-size: 14px; + margin: 0; +} + +.claudecraft-menu-overlay .action-gear-panel { + border-width: 1px; + outline-width: 1px; + padding: 12px; +} + +.character-creation-screen { + gap: 14px; +} + +.character-creator-panel, +.character-roster-panel { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 16px; + min-height: 0; + outline: 2px solid #41404a; + padding: 18px; +} + +.character-creator-panel { + grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.2fr); +} + +.character-creator-preview { + align-content: center; + background: radial-gradient(circle at 50% 30%, rgba(230, 218, 150, 0.18), rgba(9, 10, 13, 0.2) 42%, rgba(9, 10, 13, 0.92)); + border: 2px solid #090a0d; + display: grid; + gap: 16px; + justify-items: center; + min-height: 0; + outline: 2px solid #30313a; + padding: 18px; + text-align: center; +} + +.character-roster-avatar { + align-items: center; + background: linear-gradient(180deg, #2a2d39, #12141c); + border: 3px solid #090a0d; + box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.05); + display: flex; + justify-content: center; + outline: 2px solid #4b4855; +} + +.character-roster-avatar span, +.character-class-grid span, +.character-skin-grid span { + font-family: 'Press Start 2P', monospace; +} + +.character-creator-preview h2, +.character-creator-group h3, +.character-roster-panel h2 { + font-family: 'Press Start 2P', monospace; + margin: 0; +} + +.character-creator-preview h2, +.character-roster-panel h2 { + font-size: 16px; +} + +.character-creator-preview span, +.character-creator-form label span, +.character-creator-error, +.character-class-grid strong, +.character-class-grid small, +.character-roster-list strong, +.character-roster-list small { + font-family: 'Press Start 2P', monospace; +} + +.character-creator-preview > div:last-child > span { + color: var(--gold); + display: block; + font-size: 8px; + margin-top: 8px; +} + +.character-model-preview { + background: #090a0d; + border: 2px solid #05070b; + height: min(44vh, 340px); + min-height: 220px; + outline: 2px solid #30313a; + overflow: hidden; + touch-action: none; + width: 100%; +} + +.character-model-preview canvas { + display: block; + height: 100%; + width: 100%; +} + +.character-creator-form { + display: grid; + gap: 14px; + min-height: 0; +} + +.character-creator-form label, +.character-creator-group { + display: grid; + gap: 9px; +} + +.character-creator-form label span { + color: var(--gold); + font-size: 8px; +} + +.character-creator-form input { + background: #090a0d; + border: 2px solid #05070b; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 10px; + min-height: 42px; + outline: 1px solid #30313a; + padding: 10px; +} + +.character-creator-error { + color: #ff8b98; + font-size: 7px; + margin: 0; +} + +.character-class-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.character-class-grid button, +.character-skin-grid button, +.character-roster-list button { + background: #171922; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + outline: 2px solid #30313a; +} + +.character-class-grid button { + display: grid; + gap: 7px; + min-height: 92px; + padding: 10px; + text-align: left; +} + +.character-class-grid button[aria-pressed='true'], +.character-class-grid button:hover, +.character-skin-grid button[aria-pressed='true'], +.character-skin-grid button:hover, +.character-roster-list button[aria-pressed='true'], +.character-roster-list button:hover { + outline-color: var(--gold); +} + +.character-class-grid span { + color: var(--gold); + font-size: 14px; +} + +.character-class-grid strong { + font-size: 8px; +} + +.character-class-grid small { + color: var(--muted); + font-size: 6px; + line-height: 1.45; +} + +.character-skin-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.character-skin-grid button { + min-height: 42px; + padding: 8px; +} + +.character-skin-grid span { + color: #f6f2de; + font-size: 7px; + text-shadow: 0 2px 0 #05070b; +} + +.character-creator-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.character-roster-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.character-roster-panel > header { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.character-roster-list { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + min-height: 0; + overflow: auto; +} + +.character-roster-list button { + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: 62px minmax(0, 1fr); + min-height: 86px; + padding: 10px; + text-align: left; +} + +.character-roster-avatar { + border-radius: 50%; + height: 54px; + width: 54px; +} + +.character-roster-avatar span { + color: #f6f2de; + font-size: 16px; +} + +.character-roster-list strong, +.character-roster-list small { + display: block; + line-height: 1.45; +} + +.character-roster-list strong { + font-size: 8px; +} + +.character-roster-list small { + color: var(--muted); + font-size: 6px; +} + +.class-priest { + background: linear-gradient(180deg, #f0e9d6, #7a6f8f 58%, #211d2d); +} + +.class-paladin { + background: linear-gradient(180deg, #fff0a3, #b4893f 58%, #2c2110); +} + +.class-shaman { + background: linear-gradient(180deg, #b7d5ff, #4d6f9f 58%, #14263b); +} + +.class-druid { + background: linear-gradient(180deg, #a7f0b2, #4f8b5a 58%, #152816); +} + +.skin-1 { + filter: hue-rotate(24deg) saturate(1.05); +} + +.skin-2 { + filter: hue-rotate(74deg) saturate(1.15); +} + +.skin-3 { + filter: hue-rotate(142deg) saturate(1.25); +} + .menu-card { align-items: center; background: var(--panel-light); @@ -1165,21 +2748,30 @@ h2 { } .back-button, -.text-button { +.text-button, +.secondary-button { background: #15161c; border: 2px solid #090a0d; color: var(--muted); cursor: pointer; + font-family: 'Press Start 2P', monospace; outline: 2px solid #464550; padding: 9px 13px; } .back-button:hover, -.text-button:hover { +.text-button:hover, +.secondary-button:hover:not(:disabled) { color: var(--ink); outline-color: var(--gold); } +.back-button:disabled, +.secondary-button:disabled { + cursor: wait; + opacity: 0.6; +} + .boss-slice-shell { height: 100dvh; margin: 0 auto; @@ -1220,14 +2812,25 @@ h2 { } .boss-playfield-panel { + align-items: center; background: #08090c; border: 2px solid #090a0d; + display: flex; + justify-content: center; min-height: 0; outline: 2px solid #41404a; overflow: hidden; position: relative; } +.boss-playfield-frame { + aspect-ratio: 16 / 9; + max-height: 100%; + max-width: 100%; + position: relative; + width: 100%; +} + .boss-window-bossbar { left: 50%; position: absolute; @@ -1237,6 +2840,32 @@ h2 { z-index: 3; } +.boss-render-switch { + display: flex; + gap: 4px; + position: absolute; + right: 10px; + top: 10px; + z-index: 4; +} + +.boss-render-switch button { + background: rgba(8, 9, 12, 0.84); + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-width: 42px; + outline: 1px solid #41404a; + padding: 7px 8px; +} + +.boss-render-switch button[aria-pressed='true'] { + color: var(--gold); + outline-color: var(--gold); +} + .boss-window-bossbar strong, .boss-window-bossbar span { color: var(--ink); @@ -1300,6 +2929,7 @@ h2 { .party-frame { background: #171922; border: 1px solid #090a0d; + box-sizing: border-box; color: var(--ink); cursor: pointer; display: grid; @@ -1322,25 +2952,87 @@ h2 { .party-frame strong, .party-frame small, .party-frame em, +.party-damage-meter, .role-chip { + display: block; font-family: 'Press Start 2P', monospace; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .party-frame strong { + display: block; font-size: 8px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .party-frame small, -.party-frame em { +.party-frame em, +.party-damage-meter { color: var(--muted); + display: block; font-size: 6px; font-style: normal; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.party-damage-meter { + background: rgba(255, 216, 107, 0.14); + border: 1px solid rgba(255, 216, 107, 0.48); + color: #ffdf7e; + padding: 2px 3px; } .party-frame em { color: #7fd89d; } +.party-frame-effects { + display: flex; + flex-wrap: wrap; + gap: 3px; + min-height: 11px; + overflow: hidden; +} + +.party-frame-effects em { + background: rgba(67, 171, 107, 0.18); + border: 1px solid rgba(127, 216, 157, 0.55); + color: #9ff2b9; + display: inline-block; + line-height: 1; + padding: 2px 3px; + width: auto; +} + +.party-frame-effects em.rejuvenation { + background: rgba(68, 176, 112, 0.2); + border-color: rgba(125, 232, 159, 0.62); + color: #a6ffbd; +} + +.party-frame-effects em.regrowth { + background: rgba(71, 143, 104, 0.25); + border-color: rgba(143, 220, 184, 0.62); + color: #b5f4dc; +} + +.party-frame em.danger { + color: #ff9a55; +} + +.party-frame em.bleed { + color: #ff6b84; +} + .party-frame i { background: #090a0d; display: block; @@ -1350,7 +3042,8 @@ h2 { } .party-health-fill, -.party-shield-fill { +.party-shield-fill, +.party-mana-fill { display: block; height: 100%; left: 0; @@ -1368,6 +3061,15 @@ h2 { box-shadow: inset 0 3px #a7e4ff; } +.party-frame .party-mana-track { + height: 7px; +} + +.party-mana-fill { + background: #247dff; + box-shadow: inset 0 3px #80c8ff; +} + .role-chip { color: var(--gold); font-size: 6px; @@ -1403,6 +3105,56 @@ h2 { display: block; } +.boss-render-loading { + align-items: center; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + height: 100%; + justify-content: center; + width: 100%; +} + +.three-action-root { + height: 100%; + overflow: hidden; + position: relative; + touch-action: none; + width: 100%; +} + +.three-action-canvas { + height: 100%; + width: 100%; +} + +.three-action-canvas canvas { + display: block; + height: 100%; + width: 100%; +} + +.three-action-overlay { + bottom: 10px; + display: grid; + gap: 7px; + left: 10px; + pointer-events: none; + position: absolute; + width: min(330px, calc(100% - 154px)); + z-index: 3; +} + +.three-action-hint { + background: rgba(8, 9, 12, 0.72); + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + line-height: 1.55; + padding: 7px 8px; +} + .boss-hud { background: #111319; border: 2px solid #090a0d; @@ -1472,6 +3224,11 @@ h2 { box-shadow: inset 0 4px #a7e4ff; } +.boss-meter.mana b { + background: #247dff; + box-shadow: inset 0 4px #80c8ff; +} + .boss-meter.boss b { background: var(--red-bright); box-shadow: inset 0 4px #ff8b98; @@ -1554,6 +3311,12 @@ h2 { color: var(--muted); } +.boss-spellbar button.oom { + color: #7d8291; + cursor: not-allowed; + outline-color: #282a33; +} + .boss-spellbar button > strong, .boss-spellbar button > span, .boss-spellbar button > em { @@ -1694,6 +3457,10 @@ h2 { width: min(300px, calc(100% - 24px)); } + .three-action-hint { + display: none; + } + .boss-hud { display: grid; grid-column: 1 / -1; @@ -1768,6 +3535,10 @@ h2 { margin-top: 12px; } + .auth-offline-button { + margin-top: 8px; + } + .auth-tabs button { font-size: 6px; min-height: 34px; @@ -1823,7 +3594,8 @@ h2 { } .back-button, - .text-button { + .text-button, + .secondary-button { font-size: 7px; padding: 7px 9px; } @@ -1876,7 +3648,6 @@ h2 { } .action-dungeon-glyph { - font-size: 11px; height: 36px; } @@ -1897,12 +3668,79 @@ h2 { .action-dungeon-tier, .action-dungeon-start, .action-gear-panel, + .arena-panel, .action-mechanics-admin, .action-placeholder-panel { gap: 8px; padding: 10px; } + .arena-layout { + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(210px, 0.45fr); + } + + .arena-header, + .arena-status-strip, + .arena-team-card header, + .arena-draft-heading { + gap: 7px; + } + + .arena-scoreboard { + min-width: 300px; + padding: 7px; + } + + .arena-scoreboard strong { + font-size: 10px; + } + + .arena-scoreboard span, + .arena-status-strip h3, + .arena-team-card h3, + .arena-draft h3, + .arena-draft-heading > span { + font-size: 6px; + } + + .arena-status-strip, + .arena-draft > section, + .arena-loadout, + .arena-team-card { + padding: 7px; + } + + .arena-teams { + gap: 7px; + } + + .arena-unit-list, + .arena-upgrade-list, + .arena-loadout > div:last-child { + gap: 5px; + } + + .arena-unit { + min-height: 42px; + padding: 5px; + } + + .arena-unit strong, + .arena-upgrade-list strong { + font-size: 6px; + } + + .arena-unit small, + .arena-unit em, + .arena-energy span, + .arena-upgrade-list span, + .arena-upgrade-list small, + .arena-upgrade-list em, + .arena-loadout span { + font-size: 5px; + } + .action-dungeon-selected p:not(.eyebrow), .action-dungeon-start p, .action-gear-panel p:not(.eyebrow), @@ -1948,11 +3786,49 @@ h2 { padding: 9px 10px; } - .action-customize-layout { + .action-customize-layout, + .claudecraft-character-layout { gap: 10px; grid-template-columns: minmax(190px, 0.75fr) minmax(0, 1.25fr); } + .claudecraft-bag-layout { + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .claudecraft-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .character-creator-panel { + gap: 10px; + grid-template-columns: minmax(180px, 0.58fr) minmax(0, 1.42fr); + padding: 10px; + } + + .character-creator-preview { + padding: 10px; + } + + .character-model-preview { + min-height: 180px; + } + + .character-class-grid, + .character-skin-grid { + gap: 7px; + } + + .character-class-grid button { + min-height: 68px; + padding: 7px; + } + + .character-roster-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .action-slot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -2157,6 +4033,24 @@ h2 { width: min(260px, calc(100% - 20px)); } + .boss-render-switch { + right: 7px; + top: 7px; + } + + .boss-render-switch button { + font-size: 5px; + min-height: 34px; + min-width: 48px; + padding: 5px; + } + + .three-action-overlay { + bottom: 7px; + left: 7px; + width: min(180px, calc(100% - 118px)); + } + .boss-window-bossbar strong, .boss-window-bossbar span, .boss-castbar strong, @@ -2218,7 +4112,6 @@ h2 { } .action-dungeon-glyph { - font-size: 9px; height: 30px; } @@ -2238,14 +4131,63 @@ h2 { .action-dungeon-selected dl, .tag-row, .action-dungeon-start p, + .arena-loadout, .action-gear-summary p:not(.eyebrow) { display: none; } - .action-customize-layout { + .arena-header { + align-items: start; + } + + .arena-scoreboard { + min-width: 220px; + } + + .arena-layout { + grid-template-columns: minmax(0, 1fr) minmax(168px, 0.42fr); + } + + .arena-teams { + grid-template-columns: minmax(0, 1fr); + min-height: 250px; + overflow: auto; + } + + .arena-team-card { + min-height: 176px; + } + + .arena-upgrade-list button { + padding: 6px; + } + + .action-customize-layout, + .claudecraft-character-layout { grid-template-columns: minmax(150px, 0.55fr) minmax(0, 1.45fr); } + .claudecraft-bag-layout { + grid-template-columns: minmax(0, 1fr); + } + + .character-creator-panel { + grid-template-columns: minmax(0, 1fr); + } + + .character-model-preview { + height: 180px; + min-height: 180px; + } + + .character-class-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .character-roster-list { + grid-template-columns: minmax(0, 1fr); + } + .action-slot-grid { gap: 6px; } @@ -2294,6 +4236,552 @@ h2 { } } +/* PVP 3D HUD: ClaudeCraft-style low chrome overlay */ +.arena-battle-shell { + padding: 0; +} + +.arena-battle-shell .boss-slice-stage { + background: #08090c; + border: 0; + box-shadow: none; + gap: 0; + outline: 0; + padding: 0; +} + +.arena-battle-shell .arena-battle-layout { + display: block; + height: 100%; +} + +.arena-battle-shell .boss-playfield-panel { + border: 0; + height: 100%; + outline: 0; +} + +.arena-battle-shell .boss-playfield-frame { + aspect-ratio: auto; + height: 100%; + max-height: none; + max-width: none; +} + +.arena-battle-shell .three-action-overlay { + bottom: 18px; + left: 18px; + width: min(310px, calc(50% - 36px)); +} + +.arena-battle-shell .arena-boss-window-party, +.dungeon-combat-shell .arena-boss-window-party { + gap: 5px; + left: 14px; + max-width: none; + top: 14px; + width: 154px; +} + +.arena-battle-shell .arena-boss-window-party .party-frame, +.dungeon-combat-shell .arena-boss-window-party .party-frame { + background: rgba(10, 12, 18, 0.78); + border-color: rgba(0, 0, 0, 0.8); + grid-template-rows: auto auto auto auto 8px auto; + height: 96px; + min-height: 96px; + outline-color: rgba(94, 199, 255, 0.45); + overflow: hidden; + padding: 5px; + width: 154px; +} + +.arena-battle-shell .arena-boss-window-party .party-frame.compact, +.dungeon-combat-shell .arena-boss-window-party .party-frame.compact { + height: 96px; + min-height: 96px; + width: 154px; +} + +.arena-battle-shell .arena-boss-window-party .party-frame > em, +.dungeon-combat-shell .arena-boss-window-party .party-frame > em { + display: none; +} + +.arena-battle-shell .arena-boss-window-party .party-frame .party-frame-effects em, +.dungeon-combat-shell .arena-boss-window-party .party-frame .party-frame-effects em { + display: inline-block; + font-size: 5px; +} + +.arena-battle-shell .arena-boss-window-party .party-frame.selected, +.dungeon-combat-shell .arena-boss-window-party .party-frame.selected { + background: rgba(40, 34, 15, 0.92); + border-color: #ffd86b; + box-shadow: 0 0 0 2px rgba(255, 216, 107, 0.55), 0 0 18px rgba(255, 216, 107, 0.32); + outline-color: #fff0a3; +} + +.arena-battle-shell .arena-boss-window-party .party-frame.selected strong, +.dungeon-combat-shell .arena-boss-window-party .party-frame.selected strong { + color: #fff0a3; +} + +.arena-target-chip { + background: rgba(10, 12, 18, 0.86); + border: 2px solid #05070b; + color: var(--ink); + display: grid; + gap: 6px; + left: 50%; + min-width: min(330px, calc(100% - 36px)); + outline: 1px solid rgba(230, 218, 150, 0.42); + padding: 8px 10px; + pointer-events: auto; + position: absolute; + top: 14px; + transform: translateX(-50%); + z-index: 6; +} + +.arena-target-chip > div { + align-items: center; + display: grid; + gap: 7px; + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.arena-target-chip span, +.arena-target-chip strong, +.arena-target-chip small, +.arena-target-chip button { + font-family: 'Press Start 2P', monospace; +} + +.arena-target-chip span, +.arena-target-chip small { + color: var(--muted); + font-size: 7px; +} + +.arena-target-chip strong { + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arena-target-chip i { + background: #05070b; + display: block; + height: 8px; + overflow: hidden; +} + +.arena-target-chip b { + background: #e84c61; + box-shadow: inset 0 3px #ff8b98; + display: block; + height: 100%; + transition: width 120ms linear; +} + +.combat-target-frame.ally-target b { + background: #4ade80; + box-shadow: inset 0 3px #bbf7d0; +} + +.arena-target-chip button { + background: #15161c; + border: 1px solid #05070b; + color: var(--gold); + cursor: pointer; + font-size: 7px; + padding: 6px 8px; +} + +.arena-pause-backdrop { + align-items: center; + background: rgba(3, 5, 8, 0.68); + display: flex; + inset: 0; + justify-content: center; + padding: 18px; + position: absolute; + z-index: 20; +} + +.arena-pause-menu { + background: rgba(12, 14, 20, 0.96); + border: 2px solid #05070b; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55); + color: var(--ink); + display: grid; + gap: 14px; + max-width: min(380px, calc(100% - 36px)); + outline: 1px solid rgba(230, 218, 150, 0.5); + padding: 16px; + width: 100%; +} + +.arena-pause-menu h2 { + font-family: 'Press Start 2P', monospace; + font-size: 16px; + margin: 0; +} + +.arena-pause-options { + display: grid; + gap: 8px; +} + +.arena-pause-options label { + align-items: center; + background: rgba(5, 7, 11, 0.78); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + justify-content: space-between; + min-height: 34px; + padding: 8px; +} + +.arena-pause-options span, +.arena-pause-options strong, +.arena-pause-actions button { + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.arena-pause-options strong { + color: var(--gold); +} + +.arena-sound-toggle { + align-items: center; + background: rgba(12, 14, 20, 0.95); + border: 1px solid rgba(230, 218, 150, 0.55); + color: var(--ink); + cursor: pointer; + display: inline-flex; + min-height: 24px; + min-width: 68px; + padding: 4px 8px; + justify-content: center; +} + +.arena-sound-toggle.enabled { + background: rgba(42, 82, 55, 0.95); + border-color: rgba(112, 214, 145, 0.7); +} + +.arena-pause-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.claudecraft-actionbar { + align-items: end; + bottom: 16px; + display: grid; + gap: 5px; + grid-template-columns: repeat(9, 48px); + justify-content: center; + left: 50%; + pointer-events: auto; + position: absolute; + transform: translateX(-50%); + z-index: 7; +} + +.claudecraft-action-slot { + aspect-ratio: 1; + background: #090a0d; + border: 2px solid #05070b; + box-shadow: + inset 0 0 0 2px rgba(255, 255, 255, 0.08), + 0 2px 0 rgba(0, 0, 0, 0.55); + cursor: pointer; + display: block; + outline: 1px solid #464550; + overflow: hidden; + padding: 0; + position: relative; +} + +.claudecraft-action-slot:hover:not(:disabled), +.claudecraft-action-slot:focus-visible { + outline-color: var(--gold); +} + +.claudecraft-action-slot:disabled { + cursor: not-allowed; +} + +.claudecraft-action-slot .icon-label { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + display: block; + height: 100%; + image-rendering: auto; + width: 100%; +} + +.claudecraft-action-slot.oom .icon-label, +.claudecraft-action-slot.no-target .icon-label { + filter: grayscale(0.8) brightness(0.55); +} + +.claudecraft-action-slot .keybind { + background: rgba(0, 0, 0, 0.82); + border-right: 1px solid rgba(255, 255, 255, 0.14); + border-bottom: 1px solid rgba(255, 255, 255, 0.14); + color: #f6f2de; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + left: 0; + line-height: 1; + padding: 3px 4px; + position: absolute; + top: 0; + z-index: 2; +} + +.claudecraft-action-slot .cd-overlay { + background: rgba(0, 0, 0, 0.68); + bottom: 0; + display: block; + left: 0; + position: absolute; + width: 100%; + z-index: 1; +} + +.claudecraft-action-slot .cdtext { + color: #fff4ba; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + left: 50%; + position: absolute; + text-shadow: 1px 1px 0 #000; + top: 50%; + transform: translate(-50%, -50%); + z-index: 3; +} + +.claudecraft-action-tooltip { + background: rgba(10, 12, 18, 0.96); + border: 2px solid #05070b; + bottom: 58px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55); + color: var(--ink); + left: 50%; + outline: 1px solid rgba(255, 216, 107, 0.48); + padding: 10px; + pointer-events: none; + position: absolute; + transform: translateX(-50%); + width: min(360px, calc(100vw - 24px)); + z-index: 9; +} + +.claudecraft-action-tooltip .tooltip-heading { + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.14); + display: grid; + gap: 10px; + grid-template-columns: 42px minmax(0, 1fr); + padding-bottom: 8px; +} + +.claudecraft-action-tooltip img { + border: 2px solid #05070b; + display: block; + height: 42px; + outline: 1px solid #464550; + width: 42px; +} + +.claudecraft-action-tooltip strong, +.claudecraft-action-tooltip span, +.claudecraft-action-tooltip dt, +.claudecraft-action-tooltip dd, +.claudecraft-action-tooltip p, +.claudecraft-action-tooltip em { + font-family: 'Press Start 2P', monospace; +} + +.claudecraft-action-tooltip strong { + display: block; + font-size: 10px; + line-height: 1.35; +} + +.claudecraft-action-tooltip span, +.claudecraft-action-tooltip dt, +.claudecraft-action-tooltip dd, +.claudecraft-action-tooltip p, +.claudecraft-action-tooltip em { + font-size: 7px; + line-height: 1.55; +} + +.claudecraft-action-tooltip span, +.claudecraft-action-tooltip dt, +.claudecraft-action-tooltip em { + color: var(--gold); +} + +.claudecraft-action-tooltip dl { + display: grid; + gap: 5px; + margin: 9px 0 0; +} + +.claudecraft-action-tooltip dl > div { + display: flex; + gap: 10px; + justify-content: space-between; +} + +.claudecraft-action-tooltip dd { + color: var(--ink); + margin: 0; + text-align: right; +} + +.claudecraft-action-tooltip p { + color: #d7d9e4; + margin: 9px 0 0; +} + +.claudecraft-action-tooltip em { + display: block; + font-style: normal; + margin-top: 7px; +} + +/* Dungeon 3D HUD: same low-chrome combat-window treatment as PVP */ +.dungeon-combat-shell { + padding: 0; +} + +.dungeon-combat-shell .boss-slice-stage { + background: #08090c; + border: 0; + box-shadow: none; + gap: 0; + outline: 0; + padding: 0; +} + +.dungeon-combat-shell .dungeon-combat-layout { + display: block; + height: 100%; +} + +.dungeon-combat-shell .boss-playfield-panel { + border: 0; + height: 100%; + outline: 0; +} + +.dungeon-combat-shell .boss-playfield-frame { + aspect-ratio: auto; + height: 100%; + max-height: none; + max-width: none; +} + +.dungeon-combat-shell .boss-render-switch { + right: 14px; + top: 14px; + z-index: 8; +} + +.dungeon-encounter-frame { + left: 50%; + min-width: min(420px, calc(100% - 360px)); + top: 14px; + transform: translateX(-50%); + z-index: 6; +} + +.dungeon-target-chip { + bottom: auto; + left: auto; + min-width: 0; + right: 14px; + top: 122px; + transform: none; + width: min(300px, 28%); +} + +.dungeon-target-chip b { + background: #4ade80; + box-shadow: inset 0 3px #bbf7d0; +} + +.dungeon-actionbar { + grid-template-columns: repeat(5, 48px); +} + +.arena-battle-shell .boss-field-castbar { + bottom: 136px; + left: 50%; + top: auto; + transform: translateX(-50%); + width: min(360px, calc(100% - 36px)); + z-index: 6; +} + +@media (max-width: 760px), (max-height: 560px) { + .arena-battle-shell .arena-boss-window-party, + .dungeon-combat-shell .arena-boss-window-party { + left: 8px; + max-width: none; + top: 8px; + width: 128px; + } + + .arena-battle-shell .arena-boss-window-party .party-frame, + .dungeon-combat-shell .arena-boss-window-party .party-frame { + height: 84px; + min-height: 84px; + padding: 4px; + width: 128px; + } + + .arena-battle-shell .arena-boss-window-party .party-frame.compact, + .dungeon-combat-shell .arena-boss-window-party .party-frame.compact { + width: 128px; + } + + .claudecraft-actionbar { + bottom: 10px; + gap: 4px; + grid-template-columns: repeat(9, 40px); + } + + .arena-target-chip { + min-width: min(300px, calc(100% - 24px)); + padding: 7px 8px; + top: 8px; + } + + .arena-target-chip strong { + font-size: 8px; + } + + .arena-battle-shell .three-action-overlay { + bottom: 12px; + left: 10px; + width: min(160px, 30%); + } +} + .tag-row { display: flex; gap: 7px; @@ -2348,3 +4836,683 @@ h2 { cursor: wait; opacity: 0.6; } + +.arena-battle-shell .arena-battle-layout { + grid-template-columns: minmax(0, 1fr) minmax(220px, 260px); + grid-template-rows: minmax(0, 1fr); +} + +.arena-battle-shell .boss-hud { + display: flex; + flex-direction: column; + grid-column: auto; + margin-left: 10px; + max-height: none; +} + +@media (max-width: 980px), (max-height: 560px) { + .pvp-roguelike-menu { + gap: 8px; + grid-template-columns: minmax(190px, 0.48fr) minmax(0, 1fr); + } + + .pvp-mode-panel, + .pvp-queue-panel, + .pvp-loadout-panel, + .pvp-roguelike-shop, + .pvp-roguelike-result { + gap: 7px; + padding: 8px; + } + + .pvp-mode-panel h3, + .pvp-queue-panel h3, + .pvp-shop-header h3, + .pvp-shop-category-header h3, + .pvp-active-buffs h3, + .pvp-roguelike-result h3 { + font-size: 7px; + } + + .pvp-queue-panel { + grid-template-columns: 58px minmax(0, 1fr); + } + + .pvp-queue-glyph { + font-size: 7px; + min-height: 58px; + } + + .pvp-queue-panel p:not(.eyebrow), + .pvp-shop-header span, + .pvp-roguelike-result p { + font-size: 10px; + } + + .pvp-mode-buttons button, + .pvp-shop-tabs button, + .pvp-shop-spell-grid button, + .pvp-shop-general, + .pvp-mode-panel dl > div, + .pvp-active-buffs, + .pvp-shop-summary > div { + padding: 6px; + } + + .pvp-shop-layout { + gap: 7px; + grid-template-columns: 240px minmax(0, 1fr) 140px; + } + + .pvp-shop-grid { + gap: 5px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + + .pvp-shop-spell-grid { + gap: 5px; + grid-template-rows: repeat(5, minmax(0, 1fr)); + } + + .pvp-shop-summary { + gap: 6px; + } + + .pvp-shop-summary strong { + font-size: 7px; + } + + .pvp-shop-tabs, + .pvp-shop-spell-grid, + .pvp-active-buffs > div:last-child { + gap: 5px; + } + + .pvp-result-emblem { + font-size: 7px; + height: 48px; + width: 48px; + } + + .arena-battle-shell { + padding: 6px; + } + + .arena-battle-shell .boss-slice-stage { + gap: 6px; + padding: 8px; + } + + .arena-battle-shell .arena-battle-layout { + gap: 6px; + grid-template-columns: minmax(0, 1fr) 164px; + grid-template-rows: minmax(0, 1fr); + } + + .arena-battle-shell .boss-hud { + gap: 7px; + margin-left: 0; + overflow: auto; + padding: 7px; + } + + .arena-battle-shell .party-frame { + height: 84px; + min-height: 84px; + padding: 5px 4px; + } + + .arena-boss-window-party { + gap: 4px; + left: 6px; + max-width: none; + top: 6px; + width: 132px; + } + + .arena-boss-window-party .party-frame { + height: 84px; + min-height: 84px; + padding: 4px 3px; + width: 132px; + } + + .arena-battle-shell .arena-boss-window-party .party-frame.compact, + .dungeon-combat-shell .arena-boss-window-party .party-frame.compact { + width: 132px; + } + + .arena-battle-shell .boss-spellbar button { + min-height: 38px; + } + +.arena-battle-shell .boss-hud-status p:not(.eyebrow) { + font-size: 12px; +} +} + +.action-talents-menu { + gap: 12px; + min-height: 0; + overflow: hidden; +} + +.action-talent-actions { + justify-content: flex-end; +} + +.claudecraft-talent-tabs { + display: flex; + gap: 6px; + margin: 0; +} + +.claudecraft-talent-tabs button { + align-items: center; + background: #1c1609; + border: 1px solid #463a1c; + border-radius: 4px; + color: #a89f87; + cursor: pointer; + display: flex; + flex: 1; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + gap: 7px; + justify-content: center; + min-height: 34px; + padding: 6px 8px; + position: relative; + text-align: center; +} + +.claudecraft-talent-tabs button.active { + background: #3a2d10; + border-color: #ffd100; + box-shadow: inset 0 0 0 1px rgba(255, 209, 0, 0.34), 0 0 8px rgba(184, 144, 42, 0.34); + color: #fff7dc; +} + +.claudecraft-talent-tabs button.active::after { + background: #ffd100; + border-radius: 2px; + bottom: -1px; + content: ""; + height: 2px; + left: 10px; + position: absolute; + right: 10px; +} + +.claudecraft-talent-tabs b { + align-items: center; + background: #0f0b06; + border: 1px solid #5f4b1a; + border-radius: 9px; + color: #f4df9f; + display: inline-flex; + font-size: 7px; + height: 18px; + justify-content: center; + min-width: 20px; + padding: 0 5px; +} + +.claudecraft-talent-specs { + display: flex; + gap: 8px; + min-height: 0; +} + +.claudecraft-talent-specs button { + background: #1a1409; + border: 1px solid #463a1c; + border-radius: 6px; + color: var(--ink); + cursor: pointer; + display: grid; + flex: 1; + gap: 3px; + justify-items: center; + min-width: 0; + padding: 8px 6px; + position: relative; + text-align: center; +} + +.claudecraft-talent-specs button.selected { + background: #2c2410; + border-color: #ffd100; + box-shadow: inset 0 0 0 1px rgba(255, 209, 0, 0.34), 0 0 10px rgba(184, 144, 42, 0.4); +} + +.claudecraft-talent-specs button.selected::after { + align-items: center; + background: #ffd100; + border: 1px solid #332403; + border-radius: 50%; + color: #120d04; + content: "✓"; + display: flex; + font-size: 10px; + height: 17px; + justify-content: center; + position: absolute; + right: 6px; + top: 6px; + width: 17px; +} + +.claudecraft-talent-specs button span { + align-items: center; + background: #15110a; + border: 1px solid #5a4a22; + border-radius: 6px; + color: #fff; + display: flex; + font-size: 24px; + height: 42px; + justify-content: center; + text-shadow: 0 1px 2px #000; + width: 42px; +} + +.claudecraft-talent-specs button.selected span { + border-color: #ffd100; + box-shadow: 0 0 8px rgba(255, 209, 0, 0.4); +} + +.claudecraft-talent-specs button strong, +.claudecraft-talent-specs button small { + font-family: 'Press Start 2P', monospace; + line-height: 1.35; + min-width: 0; +} + +.claudecraft-talent-specs button strong { + font-size: 8px; +} + +.claudecraft-talent-specs button small { + color: var(--gold); + font-size: 6px; + text-transform: uppercase; +} + +.claudecraft-talent-mastery { + background: #14100a; + border: 1px solid #463a1c; + border-radius: 4px; + color: #c8b888; + display: grid; + gap: 4px; + padding: 6px 8px; +} + +.claudecraft-talent-mastery strong, +.claudecraft-talent-mastery span { + font-family: 'Press Start 2P', monospace; + line-height: 1.45; +} + +.claudecraft-talent-mastery strong { + color: var(--gold); + font-size: 8px; +} + +.claudecraft-talent-mastery span { + font-size: 7px; +} + +.claudecraft-talent-body { + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(210px, 260px); + min-height: 0; +} + +.claudecraft-talent-tree-scroll { + background: #100c07; + border: 1px solid #463a1c; + border-radius: 6px; + min-height: 330px; + min-width: 0; + overflow: auto; + padding: 10px 8px; +} + +.claudecraft-talent-tree { + margin: 0 auto; + position: relative; +} + +.claudecraft-talent-arrows { + left: 0; + pointer-events: none; + position: absolute; + top: 0; + z-index: 0; +} + +.claudecraft-talent-node { + align-items: center; + background: #15110a; + border: 2px solid #5a4a22; + box-sizing: border-box; + color: #e8dcb0; + cursor: pointer; + display: flex; + height: 46px; + justify-content: center; + padding: 0; + position: absolute; + user-select: none; + width: 46px; + z-index: 1; +} + +.claudecraft-talent-node.circle, +.claudecraft-talent-node.circle .claudecraft-talent-icon { + border-radius: 50%; +} + +.claudecraft-talent-node.square { + border-radius: 7px; +} + +.claudecraft-talent-node.square .claudecraft-talent-icon { + border-radius: 5px; +} + +.claudecraft-talent-node.octagon { + border-radius: 8px; +} + +.claudecraft-talent-node.octagon .claudecraft-talent-icon { + border-radius: 6px; + clip-path: polygon(24% 0, 76% 0, 100% 24%, 100% 76%, 76% 100%, 24% 100%, 0 76%, 0 24%); + inset: 3px; +} + +.claudecraft-talent-icon { + background-color: #15110a; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + display: block; + inset: 2px; + position: absolute; + z-index: 1; +} + +.claudecraft-talent-node.avail { + border-color: #b98b27; + box-shadow: 0 0 0 1px #0b0804, 0 0 7px rgba(154, 119, 40, 0.34); +} + +.claudecraft-talent-node.avail::after { + align-items: center; + background: #f5c843; + border: 1px solid #332403; + border-radius: 50%; + color: #120d04; + content: "+"; + display: flex; + font-size: 12px; + height: 15px; + justify-content: center; + left: -6px; + min-width: 15px; + position: absolute; + top: -7px; + z-index: 3; +} + +.claudecraft-talent-node.filled { + background: #2c2410; + border-color: #f5c843; + box-shadow: inset 0 0 0 2px #120d04, 0 0 10px rgba(184, 144, 42, 0.6); + color: #fff; +} + +.claudecraft-talent-node.filled::after, +.claudecraft-talent-node.maxed::after { + align-items: center; + background: #ffd100; + border: 1px solid #332403; + border-radius: 50%; + color: #120d04; + content: "✓"; + display: flex; + font-size: 11px; + height: 16px; + justify-content: center; + left: -6px; + min-width: 16px; + position: absolute; + top: -7px; + z-index: 3; +} + +.claudecraft-talent-node.maxed { + border-color: #ffe27a; + box-shadow: inset 0 0 0 2px #120d04, 0 0 13px rgba(255, 209, 0, 0.8); +} + +.claudecraft-talent-node.maxed::after { + content: "★"; + font-size: 10px; +} + +.claudecraft-talent-node.locked { + filter: grayscale(0.75) brightness(0.72); + opacity: 0.48; +} + +.claudecraft-talent-node.selected { + outline: 2px solid #fff7dc; + outline-offset: 2px; +} + +.claudecraft-talent-rank { + background: #171006; + border: 1px solid #ffd100; + border-radius: 8px; + bottom: -7px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.65); + color: #fff6d5; + font-family: 'Press Start 2P', monospace; + font-size: 6px; + line-height: 14px; + min-width: 20px; + padding: 0 4px; + position: absolute; + right: -6px; + text-align: center; + z-index: 4; +} + +.claudecraft-talent-detail, +.claudecraft-talent-empty { + background: #14100a; + border: 1px solid #463a1c; + border-radius: 6px; + color: #c8b888; + min-height: 0; + padding: 10px; +} + +.claudecraft-talent-empty { + align-items: center; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + justify-content: center; +} + +.claudecraft-talent-detail { + align-content: start; + display: grid; + gap: 10px; +} + +.claudecraft-talent-detail header { + align-items: center; + display: grid; + gap: 9px; + grid-template-columns: 48px minmax(0, 1fr); +} + +.claudecraft-talent-detail-icon { + background-color: #15110a; + background-position: center; + background-size: cover; + border: 2px solid #5a4a22; + border-radius: 7px; + display: block; + height: 48px; + width: 48px; +} + +.claudecraft-talent-detail strong, +.claudecraft-talent-detail small, +.claudecraft-talent-detail p, +.claudecraft-talent-choice-list button { + font-family: 'Press Start 2P', monospace; +} + +.claudecraft-talent-detail strong { + color: #fff7dc; + display: block; + font-size: 9px; + line-height: 1.35; +} + +.claudecraft-talent-detail small { + color: #a99b76; + font-size: 7px; + line-height: 1.45; +} + +.claudecraft-talent-detail p { + color: #d5c899; + font-size: 7px; + line-height: 1.5; + margin: 0; +} + +.claudecraft-talent-warning { + color: #ff8a78 !important; +} + +.claudecraft-talent-choice-list { + display: grid; + gap: 6px; +} + +.claudecraft-talent-choice-list button { + background: #1a140a; + border: 1px solid transparent; + border-radius: 4px; + color: #d5c899; + cursor: pointer; + display: grid; + gap: 4px 8px; + grid-template-columns: 32px minmax(0, 1fr); + padding: 7px; + text-align: left; +} + +.claudecraft-talent-choice-list button.selected { + background: #2c2410; + border-color: var(--gold); + box-shadow: inset 0 0 0 1px rgba(255, 209, 0, 0.2); +} + +.claudecraft-talent-choice-list button span { + background-position: center; + background-size: cover; + border: 1px solid #5a4a22; + border-radius: 4px; + display: block; + grid-row: span 2; + height: 32px; + width: 32px; +} + +.claudecraft-talent-choice-list button strong { + font-size: 7px; +} + +.claudecraft-talent-choice-list button small { + font-size: 6px; +} + +@media (max-width: 680px) { + .pvp-roguelike-menu, + .pvp-shop-layout { + grid-template-columns: minmax(0, 1fr); + } + + .pvp-shop-layout { + grid-template-areas: + "spells" + "main" + "active"; + } + + .pvp-shop-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .pvp-active-buffs { + border-left: 0; + border-top: 1px solid #30313a; + padding-left: 0; + padding-top: 10px; + } + + .pvp-shop-grid, + .pvp-shop-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .claudecraft-talent-body, + .action-main-menu { + grid-template-columns: minmax(0, 1fr); + } + + .claudecraft-talent-specs { + gap: 6px; + } + + .claudecraft-talent-specs button { + padding: 7px 4px; + } + + .claudecraft-talent-specs button span { + height: 34px; + width: 34px; + } + + .pvp-shop-header, + .pvp-roguelike-result { + align-items: stretch; + flex-direction: column; + } +} + +@media (max-height: 560px) { + .arena-battle-shell .arena-boss-window-party, + .dungeon-combat-shell .arena-boss-window-party { + width: 128px; + } + + .arena-battle-shell .arena-boss-window-party .party-frame, + .arena-battle-shell .arena-boss-window-party .party-frame.compact, + .dungeon-combat-shell .arena-boss-window-party .party-frame, + .dungeon-combat-shell .arena-boss-window-party .party-frame.compact { + width: 128px; + } +} diff --git a/vite.admin.config.ts b/vite.admin.config.ts new file mode 100644 index 0000000..e61f5d7 --- /dev/null +++ b/vite.admin.config.ts @@ -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()], +})