Compare commits

...
10 Commits
Author SHA1 Message Date
Warren H 99e97e5208 Android build v1.1.15 2026-07-04 12:43:59 -04:00
Warren H a849cd2c69 Android build v1.1.14 2026-07-03 12:58:53 -04:00
Warren H a121b2ef74 Android build v1.1.13 2026-07-03 01:01:32 -04:00
Warren H b7cb572631 Android build v1.1.12 2026-07-03 00:28:38 -04:00
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
99 changed files with 12075 additions and 1360 deletions
+43 -1
View File
@@ -1,9 +1,51 @@
# Project Notes
## Target Hardware: AYN Thor
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- 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.
## Input Requirements
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
## Architecture Requirements
- Keep systems modular and scoped by responsibility.
- Prefer small reusable modules/components over large files with mixed concerns.
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
- Apply game changes to both web version and mobile app version.
## I Want To Heal 1 vs I Want To Heal 2
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
- Prefer event-driven updates and cached calculations for UI and game systems.
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
## Build Requirements
- User rebuilds app; do not rebuild APK unless explicitly requested.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 84
versionName "1.1.5"
versionCode 94
versionName "1.1.15"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -7,19 +7,27 @@ import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import com.getcapacitor.BridgeActivity;
import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final long DPAD_THROTTLE_MS = 220;
private long lastDpadDispatchAt = 0;
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long DPAD_THROTTLE_MS = 55;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
@@ -139,24 +147,104 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
private boolean shouldThrottleDpad() {
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
"Button14",
"Button15"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
"Button12",
"Button13"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_X),
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y),
"Axis1-",
"Axis1+"
);
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now;
for (String token : currentTokens) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
if (currentTokens.isEmpty() && !hadMotionTokens) {
return super.dispatchGenericMotionEvent(event);
}
return true;
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(
Set<String> tokens,
float value,
String negativeToken,
String positiveToken
) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt[buttonIndex] = now;
return false;
}
@@ -170,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0";
case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1";
case KeyEvent.KEYCODE_BUTTON_X:
return "Button2";
+16 -1
View File
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS account_ip_allowances (
@@ -254,6 +255,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
PRIMARY KEY (character_id, item_id)
);
CREATE TABLE IF NOT EXISTS character_boss_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
PRIMARY KEY (character_id, encounter_id)
);
CREATE TABLE IF NOT EXISTS character_pvp_stats (
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
);
CREATE TABLE IF NOT EXISTS action_characters (
id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+80
View File
@@ -10,12 +10,14 @@
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -801,6 +803,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -2013,6 +2031,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2952,6 +2976,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/phaser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz",
"integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2972,6 +3005,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+2
View File
@@ -28,12 +28,14 @@
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+6
View File
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
migrateCharacterAccountConstraint()
+139 -1
View File
@@ -1,15 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
@@ -23,6 +26,24 @@ GITEA_REPO = "i-want-to-heal"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
PLAYER_DATA_TABLES = [
"encounter_loot_roll_items",
"encounter_loot_rolls",
"dungeon_runs",
"character_pvp_stats",
"character_boss_stats",
"character_inventory",
"character_talents",
"character_ability_slots",
"action_encounter_loot_rolls",
"action_dungeon_runs",
"action_character_inventory",
"action_gear_items",
"action_characters",
"sessions",
"characters",
"accounts",
]
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
@@ -30,6 +51,29 @@ def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> Non
subprocess.run(args, cwd=cwd, env=env, check=True)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
parser.add_argument(
"--reset-player-data",
action="store_true",
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
)
parser.add_argument(
"--reset-db",
type=Path,
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
)
parser.add_argument(
"--yes-reset",
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
return args
def prompt_version() -> str:
current = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', current)
@@ -151,13 +195,107 @@ def update_truenas() -> None:
print("Then restart app in TrueNAS UI.")
def main() -> int:
def default_reset_database_path() -> Path:
truenas_database = TRUENAS_PATH / "data" / "game.db"
if truenas_database.exists():
return truenas_database
return REPO_ROOT / "data" / "game.db"
def table_exists(database: sqlite3.Connection, table: str) -> bool:
return database.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone() is not None
def count_rows(database: sqlite3.Connection, table: str) -> int:
if not table_exists(database, table):
return 0
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
def backup_database(database_path: Path) -> Path:
backup_dir = database_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
source = sqlite3.connect(database_path)
try:
destination = sqlite3.connect(backup_path)
try:
source.backup(destination)
finally:
destination.close()
finally:
source.close()
return backup_path
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
database_path = database_path.expanduser().resolve()
if not database_path.exists():
raise SystemExit(f"Database not found: {database_path}")
if not assume_yes:
print(f"Reset target: {database_path}")
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
if confirmation != "RESET":
raise SystemExit("Player data reset cancelled.")
backup_path = backup_database(database_path)
database = sqlite3.connect(database_path)
try:
before = {
"accounts": count_rows(database, "accounts"),
"characters": count_rows(database, "characters"),
"action_characters": count_rows(database, "action_characters"),
"dungeon_runs": count_rows(database, "dungeon_runs"),
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
}
database.execute("PRAGMA foreign_keys = ON")
database.execute("BEGIN")
try:
for table in PLAYER_DATA_TABLES:
if table_exists(database, table):
database.execute(f'DELETE FROM "{table}"')
database.execute("COMMIT")
except Exception:
database.execute("ROLLBACK")
raise
after_accounts = count_rows(database, "accounts")
after_characters = count_rows(database, "characters")
if after_accounts or after_characters:
raise SystemExit(
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
)
print(f"Database backup: {backup_path}")
print(
"Player data reset: "
f"{before['accounts']} accounts, "
f"{before['characters']} characters, "
f"{before['action_characters']} action characters, "
f"{before['dungeon_runs']} dungeon runs, "
f"{before['encounter_loot_rolls']} loot rolls removed."
)
finally:
database.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
create_gitea_release(version, apk)
update_truenas()
if args.reset_player_data:
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
print("Done.")
return 0
+101 -1
View File
@@ -1,4 +1,4 @@
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path'
@@ -10,6 +10,7 @@ const port = Number(process.env.ADMIN_PORT ?? 4174)
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
const iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', import.meta.url))
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
@@ -248,6 +249,89 @@ function saveDungeonImage(database, dungeonId, payload) {
return imageUrl
}
function readIwt2BalanceOverrides() {
if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} }
const source = readFileSync(iwt2BalanceOverridesPath, 'utf8')
const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES')
const objectStart = source.indexOf('{', exportIndex)
const objectEnd = source.lastIndexOf('}')
if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} }
try {
const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1))
return sanitizeIwt2BalanceOverrides(parsed)
} catch {
return { bosses: {} }
}
}
function sanitizeIwt2BalanceOverrides(value) {
const overrides = { bosses: {} }
const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object'
? value.bosses
: {}
for (const bossId of ['bulldrome', 'yian-kut-ku']) {
const boss = bosses[bossId]
if (!boss || typeof boss !== 'object') continue
const nextBoss = {}
if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth)))
if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) {
nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth)))
}
if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss
}
return overrides
}
function writeIwt2BalanceOverrides(overrides) {
const source = [
"import type { Iwt2BalanceOverrides } from './bosses'",
'',
'// Generated by local admin panel. Commit this file with intended IWT2 balance changes.',
`export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`,
'',
].join('\n')
writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 })
}
function updateIwt2EntityHp(entityId, payload) {
const maxHealth = Number(payload.maxHealth)
if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.')
const roundedHealth = Math.round(maxHealth)
const overrides = readIwt2BalanceOverrides()
const bosses = { ...(overrides.bosses ?? {}) }
if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') {
bosses[entityId] = {
...(bosses[entityId] ?? {}),
maxHealth: roundedHealth,
}
} else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') {
bosses['yian-kut-ku'] = {
...(bosses['yian-kut-ku'] ?? {}),
birdHealth: roundedHealth,
}
} else {
throw new Error('IWT2 entity not found.')
}
const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses })
writeIwt2BalanceOverrides(nextOverrides)
return nextOverrides
}
function listIwt2BossAssets() {
if (!existsSync(bossImageDirectory)) return []
return readdirSync(bossImageDirectory, { withFileTypes: true })
.filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()])
.map((entry) => {
const imagePath = resolve(bossImageDirectory, entry.name)
return {
filename: entry.name,
size: statSync(imagePath).size,
url: `/api/boss-images/${entry.name}`,
}
})
.sort((a, b) => a.filename.localeCompare(b.filename))
}
function sendFile(response, filePath) {
const contentTypes = {
'.css': 'text/css; charset=utf-8',
@@ -308,6 +392,22 @@ const server = createServer(async (request, response) => {
return
}
if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') {
sendJson(response, 200, {
balanceOverrides: readIwt2BalanceOverrides(),
bossAssets: listIwt2BossAssets(),
})
return
}
const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/)
if (iwt2HpMatch && request.method === 'PUT') {
const payload = await readJson(request)
const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload)
sendJson(response, 200, { ok: true, balanceOverrides })
return
}
if (!existsSync(databasePath)) {
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
return
+6
View File
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
})),
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
dungeons: profile.dungeons.map(normalizeDungeon),
hunterStats: {
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
},
}
}
+155 -24
View File
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
const pvpQueueTtlMs = 15 * 1000
const pvpMatchTtlMs = 60 * 60 * 1000
function addColumnIfMissing(database, table, column, definition) {
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
if (!columns.some((candidate) => candidate.name === column)) {
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
}
function ensureRuntimeMigrations(database) {
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
}
function sqliteDateFromMs(value) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return new Date().toISOString()
}
return new Date(timestamp).toISOString()
}
function epochMsFromSqliteDate(value) {
const timestamp = Date.parse(String(value ?? ''))
return Number.isFinite(timestamp) ? timestamp : 0
}
function sendJson(response, status, body, headers = {}) {
response.statusCode = status
response.setHeader('Content-Type', 'application/json')
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
response.end(JSON.stringify(body))
}
function touchAccountSave(database, accountId) {
database.prepare(`
UPDATE accounts
SET last_saved_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(accountId)
}
function sendMutatingResult(response, database, accountId, body) {
touchAccountSave(database, accountId)
sendJson(response, 200, body)
}
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
.split(',')
@@ -815,6 +857,21 @@ export function getProfile(database, characterId, accountId) {
AND completed_parts >= 1
GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count]))
const bossStatRows = database.prepare(`
SELECT
encounter_id AS encounterId,
kills,
pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(characterId)
const pvpStats = database.prepare(`
SELECT
matches_played AS matchesPlayed,
matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(characterId)
const settings = Object.fromEntries(
database.prepare('SELECT key, value FROM game_settings').all()
@@ -930,6 +987,12 @@ export function getProfile(database, characterId, accountId) {
.map((run) => ({ ...run, rank: Number(run.rank) })),
},
})),
hunterStats: {
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
},
}
}
@@ -1006,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const account = database.prepare(`
SELECT
completed_dungeon_parts AS completedDungeonParts,
completed_raid_phases AS completedRaidPhases
completed_raid_phases AS completedRaidPhases,
last_saved_at AS lastSavedAt
FROM accounts
WHERE id = ?
`).get(accountId)
@@ -1025,8 +1089,19 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
?? characters[0]?.name
?? 'Mira'
const bossStats = database.prepare(`
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(activeCharacterId)
const pvpStats = database.prepare(`
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(activeCharacterId)
return {
version: 3,
version: 4,
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
characterName,
activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1038,6 +1113,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
]),
),
lootRolls: {},
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
}
}
@@ -1052,7 +1131,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
if (
!save
|| typeof save !== 'object'
|| Number(save.version) !== 3
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|| typeof save.characterName !== 'string'
|| !save.characters
|| typeof save.characters !== 'object'
@@ -1129,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
database.prepare(`
UPDATE accounts
SET completed_dungeon_parts = ?, completed_raid_phases = ?
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
WHERE id = ?
`).run(
clampInteger(save.completedDungeonParts, 0, 0, 3),
clampInteger(save.completedRaidPhases, 0, 0, 3),
sqliteDateFromMs(save.updatedAt),
accountId,
)
@@ -1149,6 +1229,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?)
`)
const insertBossStats = database.prepare(`
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) {
const local = save.characters[classId]
@@ -1213,6 +1297,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
DELETE FROM character_inventory
WHERE character_id = ?
`).run(characterId)
database.prepare(`
DELETE FROM character_boss_stats
WHERE character_id = ?
`).run(characterId)
database.prepare(`
DELETE FROM character_pvp_stats
WHERE character_id = ?
`).run(characterId)
const inventoryByItemId = new Map()
const equippedSlots = new Set()
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
@@ -1235,6 +1327,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
for (const [itemId, itemState] of inventoryByItemId) {
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
}
if (classId === Number(save.activeClassId)) {
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(
characterId,
id,
clampInteger(rawKills, 0, 0, 1000000),
clampInteger(bossPets[encounterId], 0, 0, 1000000),
)
}
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
if (Object.hasOwn(bossKills, encounterId)) continue
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
}
database.prepare(`
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
VALUES (?, ?, ?)
`).run(
characterId,
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
)
}
}
let syncedClassId = clampInteger(
@@ -2929,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try {
const ip = requestIp(request)
@@ -2985,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try {
const ip = requestIp(request)
@@ -3023,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/profile' && request.method === 'PUT') {
const payload = await readJson(request)
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId))
sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
return
}
@@ -3073,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
if (dungeonCompletion && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
completeDungeon(
database,
session.characterId,
@@ -3090,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
completeRoguelike(database, session.characterId, session.accountId, payload),
)
return
@@ -3100,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
if (talentAllocation && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
)
return
}
if (request.url === '/api/talents/reset' && request.method === 'POST') {
sendJson(response, 200, resetTalents(database, session.characterId))
sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
return
}
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
if (itemEquip && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
equipItem(database, session.characterId, Number(itemEquip[1])),
)
return
@@ -3125,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
if (itemDiscard && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
)
return
@@ -3135,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
if (itemBreakdown && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
)
return
@@ -3145,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
if (recipeCraft && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
craftItem(database, session.characterId, Number(recipeCraft[1])),
)
return
@@ -3155,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
if (itemUpgrade && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
)
return
@@ -3166,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
rollEncounterLoot(
database,
session.characterId,
+2282 -29
View File
File diff suppressed because it is too large Load Diff
+70 -1107
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import './App.css'
import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AdminScreen onBack={() => window.close()} />
<InputProvider>
<AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>,
)
+360 -1
View File
@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react'
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
import {
IWT2_BOSS_METADATA,
type Iwt2BalanceOverrides,
type Iwt2BossId,
type Iwt2BossMetadata,
} from '../modes/iwt2/content/bosses'
type AdminItem = {
id: number
@@ -130,7 +136,38 @@ type AdminData = {
classes: AdminClass[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes'
type Iwt2AdminAsset = {
filename: string
size: number
url: string
}
type Iwt2AdminData = {
balanceOverrides: Iwt2BalanceOverrides
bossAssets: Iwt2AdminAsset[]
}
type Iwt2AdminAttack = {
name: string
frequency: string
damage: string
details: string
}
type Iwt2AdminEntityRow = {
id: string
bossId: Iwt2BossId
kind: 'boss' | 'mob'
name: string
slug: string
icon: string
color: string
maxHealth: number
attacks: Iwt2AdminAttack[]
assets: Iwt2AdminAsset[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' | 'iwt2'
type SavingState = Record<string, boolean>
type SetData = Dispatch<SetStateAction<AdminData | null>>
type SetSaving = Dispatch<SetStateAction<SavingState>>
@@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [
{ id: 'crafting', label: 'Crafting' },
{ id: 'upgrades', label: 'Upgrades' },
{ id: 'classes', label: 'Classes' },
{ id: 'iwt2', label: 'IWT2' },
]
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -189,6 +227,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) {
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'classes' && <ClassesTab data={data} />}
{tab === 'iwt2' && <Iwt2AdminTab />}
</section>
)
}
@@ -1155,6 +1194,196 @@ function ClassesTab({ data }: { data: AdminData }) {
)
}
function Iwt2AdminTab() {
const [metadata, setMetadata] = useState<Record<Iwt2BossId, Iwt2BossMetadata>>(IWT2_BOSS_METADATA)
const [adminData, setAdminData] = useState<Iwt2AdminData | null>(null)
const [error, setError] = useState('')
const [selectedEntityId, setSelectedEntityId] = useState<string>('bulldrome')
const [draftHp, setDraftHp] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchJson<Iwt2AdminData>(`${API}/iwt2/data`)
.then((result) => {
setAdminData(result)
setMetadata(applyIwt2AdminOverrides(IWT2_BOSS_METADATA, result.balanceOverrides))
})
.catch((e: unknown) => {
setError(e instanceof Error ? e.message : 'Failed to load IWT2 admin data')
setAdminData({ balanceOverrides: { bosses: {} }, bossAssets: [] })
})
}, [])
const rows = createIwt2AdminRows(metadata, adminData?.bossAssets ?? [])
const selectedRow = rows.find((row) => row.id === selectedEntityId) ?? rows[0]
async function saveHp(row: Iwt2AdminEntityRow) {
const value = Number(draftHp[row.id] ?? row.maxHealth)
if (!Number.isFinite(value) || value <= 0) {
alert('HP must be greater than zero.')
return
}
const nextHealth = Math.round(value)
setSaving((prev) => ({ ...prev, [row.id]: true }))
try {
await fetchJson(`${API}/iwt2/entities/${encodeURIComponent(row.id)}/hp`, jsonRequest('PUT', { maxHealth: nextHealth }))
setMetadata((current) => {
const boss = current[row.bossId]
return {
...current,
[row.bossId]: row.kind === 'boss'
? { ...boss, maxHealth: nextHealth }
: { ...boss, birdHealth: nextHealth },
}
})
setDraftHp((prev) => {
const next = { ...prev }
delete next[row.id]
return next
})
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving((prev) => ({ ...prev, [row.id]: false }))
}
}
return (
<div className="admin-panel iwt2-admin-panel">
{error && <p className="error-message">{error}</p>}
<div className="iwt2-admin-summary">
<span>{rows.length} mobs / bosses</span>
<span>{adminData?.bossAssets.length ?? 0} uploaded boss assets</span>
<span>HP edits write to IWT2 balance overrides</span>
</div>
<div className="iwt2-admin-layout">
<aside className="iwt2-admin-entity-list" aria-label="IWT2 mobs and bosses">
{rows.map((row) => (
<button
className={selectedRow?.id === row.id ? 'active' : ''}
key={row.id}
onClick={() => setSelectedEntityId(row.id)}
type="button"
>
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<span>
<strong>{row.name}</strong>
<small>{row.kind} · {row.maxHealth} hp · {row.attacks.length} attacks</small>
</span>
</button>
))}
</aside>
{selectedRow && (
<Iwt2AdminDetail
draftHp={draftHp}
row={selectedRow}
saveHp={saveHp}
saving={saving}
setDraftHp={setDraftHp}
/>
)}
</div>
</div>
)
}
function Iwt2AdminDetail({
draftHp,
row,
saveHp,
saving,
setDraftHp,
}: {
draftHp: Record<string, string>
row: Iwt2AdminEntityRow
saveHp: (row: Iwt2AdminEntityRow) => void
saving: Record<string, boolean>
setDraftHp: Dispatch<SetStateAction<Record<string, string>>>
}) {
const value = draftHp[row.id] ?? String(row.maxHealth)
const changed = Number(value) !== row.maxHealth
return (
<section className="iwt2-admin-detail">
<header className="iwt2-admin-detail-hero">
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<div>
<p className="eyebrow">{row.kind}</p>
<h2>{row.name}</h2>
<small>{row.slug} · source boss: {row.bossId}</small>
</div>
</header>
<label className="iwt2-admin-hp">
HP
<input
min="1"
step="1"
type="number"
value={value}
onChange={(event) => setDraftHp((prev) => ({ ...prev, [row.id]: event.target.value }))}
/>
<button
className="primary-button"
disabled={!changed || saving[row.id]}
onClick={() => saveHp(row)}
type="button"
>
{saving[row.id] ? 'Saving...' : 'Save HP'}
</button>
</label>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Assets</h3>
<span>{row.assets.length} uploaded</span>
</div>
<div className="iwt2-admin-assets">
<div className="iwt2-admin-swatch" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>
<span>{row.icon}</span>
<strong>Runtime Token</strong>
<small>Icon and color used by current arena renderer.</small>
</div>
{row.assets.length > 0 ? row.assets.map((asset) => {
const label = iwt2AssetLabel(asset.filename)
return (
<a href={asset.url} key={asset.filename} target="_blank" rel="noreferrer">
<img src={asset.url} alt={`${row.name} ${label}`} />
<strong>{label}</strong>
<small>{asset.filename}</small>
</a>
)
}) : (
<p className="admin-item-desc">No uploaded raster asset matched. Runtime uses shape, icon, and color.</p>
)}
</div>
</section>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Attacks</h3>
<span>{row.attacks.length} mechanics</span>
</div>
<div className="iwt2-admin-attacks">
<div className="iwt2-admin-attack-head">
<span>Attack</span>
<span>Frequency</span>
<span>Damage</span>
</div>
{row.attacks.map((attack) => (
<div className="iwt2-admin-attack-row" key={`${row.id}-${attack.name}`}>
<strong>{attack.name}</strong>
<span>{attack.frequency}</span>
<span>{attack.damage}</span>
<small>{attack.details}</small>
</div>
))}
</div>
</section>
</section>
)
}
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
return {
method,
@@ -1163,6 +1392,136 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
}
}
function applyIwt2AdminOverrides(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
overrides: Iwt2BalanceOverrides,
): Record<Iwt2BossId, Iwt2BossMetadata> {
return {
bulldrome: {
...metadata.bulldrome,
...overrides.bosses?.bulldrome,
},
'yian-kut-ku': {
...metadata['yian-kut-ku'],
...overrides.bosses?.['yian-kut-ku'],
},
}
}
function createIwt2AdminRows(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
bossAssets: Iwt2AdminAsset[],
): Iwt2AdminEntityRow[] {
const bosses = Object.values(metadata).map((boss): Iwt2AdminEntityRow => ({
assets: assetsForIwt2Boss(boss.id, bossAssets),
attacks: bossAttacks(boss),
bossId: boss.id,
color: boss.color,
icon: boss.icon,
id: boss.id,
kind: 'boss',
maxHealth: boss.maxHealth,
name: boss.name,
slug: boss.id,
}))
const yian = metadata['yian-kut-ku']
if (!yian.birdHealth) return bosses
return [
...bosses,
{
assets: [],
attacks: yianBirdAttacks(yian),
bossId: yian.id,
color: '#f0b84f',
icon: 'v',
id: `${yian.id}:yian-bird`,
kind: 'mob',
maxHealth: yian.birdHealth,
name: 'Yian Kut Ku Hatchling',
slug: 'yian-bird',
},
]
}
function bossAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
const attacks: Iwt2AdminAttack[] = [{
damage: `${boss.meleeDamage}`,
details: `Range ${boss.meleeRange}px`,
frequency: `${formatSeconds(boss.meleeCooldown)} cooldown`,
name: 'Melee',
}]
if (boss.chargeCooldown > 0) {
attacks.push({
damage: `${boss.chargeDamage}`,
details: `${formatSeconds(boss.chargeWindup)} windup, ${formatSeconds(boss.chargeStunSeconds)} stun / knockdown`,
frequency: `${formatSeconds(boss.chargeCooldown)} cooldown`,
name: 'Charge',
})
}
if (boss.slamDamage > 0) {
attacks.push({
damage: `${boss.slamDamage}`,
details: `${boss.slamRadius}px radius, ${formatSeconds(boss.slamStunSeconds)} stun / knockdown`,
frequency: 'Every 3rd charge',
name: 'Ground Slam',
})
}
if (boss.fireballCooldown && boss.fireballDamage) {
attacks.push({
damage: `${boss.fireballDamage} hit + ${boss.firePuddleDamage ?? 0}/tick puddle`,
details: `${boss.firePuddleRadius ?? 0}px puddle for ${formatSeconds(boss.firePuddleSeconds ?? 0)}`,
frequency: `${formatSeconds(boss.fireballCooldown)} cooldown`,
name: 'Fireball',
})
}
if (boss.birdWaveThresholds?.length) {
attacks.push({
damage: `${boss.birdContactDamage ?? 0} contact`,
details: `${boss.birdWaveThresholds.map((threshold) => `${Math.round(threshold * 100)}%`).join(', ')} HP thresholds`,
frequency: 'Health threshold',
name: 'Bird Wave',
})
}
return attacks
}
function yianBirdAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
return [
{
damage: `${boss.birdContactDamage ?? 0}`,
details: `Contact radius ${boss.birdRadius ?? 0}px`,
frequency: '1.15s cooldown',
name: 'Peck',
},
{
damage: '10',
details: `${formatSeconds(boss.birdFlightWindup ?? 0)} windup, ${formatSeconds(boss.birdStunSeconds ?? 0)} stun`,
frequency: `${formatSeconds(boss.birdFlightCooldown ?? 0)} cooldown`,
name: 'Flight Lane',
},
]
}
function assetsForIwt2Boss(bossId: Iwt2BossId, bossAssets: Iwt2AdminAsset[]): Iwt2AdminAsset[] {
const terms = bossId.split('-').filter((part) => part.length > 2)
return bossAssets.filter((asset) => {
const filename = asset.filename.toLowerCase()
return filename.includes(bossId) || terms.some((term) => filename.includes(term))
})
}
function iwt2AssetLabel(filename: string) {
const normalized = filename.toLowerCase()
if (normalized.includes('approach')) return 'Approach Illustration'
if (normalized.includes('guardians')) return 'Party Encounter Art'
if (normalized.includes('boss')) return 'Boss Portrait'
return 'Primary Boss Art'
}
function formatSeconds(value: number) {
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0$/, '')}s`
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
+34
View File
@@ -3,6 +3,7 @@ import {
completeDungeon,
completeRoguelike,
loadProfile,
recordBossKill,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
@@ -371,6 +372,7 @@ export function CombatScreen({
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
@@ -516,6 +518,12 @@ export function CombatScreen({
: `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system',
)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
@@ -527,6 +535,29 @@ export function CombatScreen({
[addLog, difficulty.id],
)
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
if (!isRoguelike || !encounter.isBoss) return
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
if (recordedBossKillIdsRef.current.has(key)) return
recordedBossKillIdsRef.current.add(key)
recordBossKill(encounter.id, { petVariant: 'purple' })
.then((result) => {
onProfileUpdated(result.profile)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
'danger',
)
})
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
const resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
@@ -558,6 +589,7 @@ export function CombatScreen({
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent
@@ -991,6 +1023,7 @@ export function CombatScreen({
requestLootRoll(encounter.id, rollIndex)
}
}
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({
@@ -1100,6 +1133,7 @@ export function CombatScreen({
maxResource,
gameClass.resourceName,
requestLootRoll,
recordRoguelikeBossKill,
profile.character.name,
setCombat,
startPart,
+263 -20
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,20 +15,121 @@ type Props = {
onSaved: (profile: CharacterProfile) => void
}
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
type CustomizeNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
| { kind: 'class'; key: string; row: number; column: number; classId: number }
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
| { kind: 'clear'; key: string; row: number; column: number }
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
| { kind: 'save'; key: string; row: number; column: number }
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
]
const CLASS_PICKER_COLUMN = 0
const CLASS_CONTENT_COLUMN = 2
const ABILITY_LIBRARY_COLUMNS = 5
const ABILITY_LIBRARY_PAGE_SIZE = 10
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
const [abilityPage, setAbilityPage] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
const visibleAbilities = useMemo(
() => gameClass.spells.slice(
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
),
[currentAbilityPage, gameClass.spells],
)
const classNavActive = activeTab === 'class'
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
const entries: CustomizeNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...CUSTOMIZE_TABS.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab.key}`,
row: 0,
column: index + 1,
tab: tab.key,
})),
]
if (activeTab !== 'class') return entries
entries.push(
...profile.classes.map((candidate, index) => ({
kind: 'class' as const,
key: `class:${candidate.id}`,
row: index + 1,
column: CLASS_PICKER_COLUMN,
classId: candidate.id,
})),
...slots.map((_, index) => ({
kind: 'slot' as const,
key: `slot:${index}`,
row: 1,
column: CLASS_CONTENT_COLUMN + index,
slotIndex: index,
})),
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
...(abilityPageCount > 1 && currentAbilityPage > 0
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
: []),
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
: []),
...visibleAbilities
.filter((ability) => ability.unlockLevel <= profile.character.level)
.map((ability, index) => ({
kind: 'ability' as const,
key: `ability:${ability.id}`,
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
abilityId: ability.id,
})),
)
if (!saving) {
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
}
return entries
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
?? navEntries[0]
function selected(entryKey: string) {
return classNavActive && activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
useEffect(() => {
window.scrollTo(0, scrollRef.current)
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setAbilityPage(0)
setMessage('')
}
function selectTab(tab: CustomizeTab) {
setActiveTab(tab)
setSelectedNavKey(`tab:${tab}`)
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== activeIndex)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < activeEntry.column
if (action === 'navigateRight') return entry.column > activeEntry.column
if (action === 'navigateUp') return entry.row < activeEntry.row
return entry.row > activeEntry.row
})
if (candidates.length === 0) return
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - activeEntry.row)
: Math.abs(a.entry.column - activeEntry.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - activeEntry.row)
: Math.abs(b.entry.column - activeEntry.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
}
function openEntry(entry: CustomizeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'class') {
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
if (nextClass) chooseClass(nextClass)
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
else if (entry.kind === 'clear') clearSlot()
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
else if (entry.kind === 'save') void persistChanges()
}
useEffect(() => {
if (!classNavActive) return
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry, classNavActive])
useGameAction((action, device) => {
if (device !== 'controller' || !classNavActive) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
}
return (
<section className="content-screen customize-screen">
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
type="button"
>
Back
</button>
{CUSTOMIZE_TABS.map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
onClick={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab"
type="button"
>
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
ref={navRef(`class:${candidate.id}`)}
style={{ '--class-color': candidate.themeColor } as CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
key={index}
onClick={() => setSelectedSlot(index)}
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
ref={navRef(`slot:${index}`)}
type="button"
>
<kbd>{index + 1}</kbd>
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
<div className="ability-library-actions">
{abilityPageCount > 1 && (
<div className="ability-library-pager">
<button
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
disabled={currentAbilityPage === 0}
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
ref={navRef('ability-page-prev')}
type="button"
>
Prev
</button>
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
<button
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
disabled={currentAbilityPage >= abilityPageCount - 1}
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
onPointerDown={() => setSelectedNavKey('ability-page-next')}
ref={navRef('ability-page-next')}
type="button"
>
Next
</button>
</div>
)}
<button
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('clear-slot') ? 'true' : undefined}
onClick={clearSlot}
onPointerDown={() => setSelectedNavKey('clear-slot')}
ref={navRef('clear-slot')}
type="button"
>
Clear Selected Slot
</button>
</div>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
{visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
ref={navRef(`ability:${ability.id}`)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('save') ? 'true' : undefined}
disabled={saving}
onClick={persistChanges}
onPointerDown={() => setSelectedNavKey('save')}
ref={navRef('save')}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
type HunterProfileTab = 'stats' | 'collection'
type HunterProfileNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
| { kind: 'stat'; key: string; row: number; column: number; index: number }
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
const HUNTER_PROFILE_DROP_COLUMNS = 6
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const statTiles = useMemo(() => [
{
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
},
{
key: 'stat:pvp-matches',
label: 'PvP Matches',
value: matchesPlayed,
},
{
key: 'stat:pvp-wins',
label: 'PvP Wins',
value: matchesWon,
},
{
key: 'stat:pvp-win-rate',
label: 'PvP Win Rate',
value: `${winRate}%`,
},
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
const entries: HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
kind: 'stat' as const,
key: tile.key,
row: 1,
column: index,
index,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
kind: 'boss' as const,
key: `boss:${boss.encounter.id}`,
row: index + 1,
column: 0,
bossId: boss.encounter.id,
})),
...collectionItems.map((item, index) => ({
kind: 'item' as const,
key: `item:${item.key}`,
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: number) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
key={boss.encounter.id}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</strong>
</button>
)
})}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
+1 -1
View File
@@ -109,7 +109,7 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox',
iconStyle: controllerIconStyle ?? 'playstation',
} : null
return (
+208 -10
View File
@@ -9,7 +9,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -152,6 +152,14 @@ type LivePvpMatch = {
opponentClassName: string
}
type PvpOverlayNavEntry =
| { kind: 'queueBack'; row: number; column: number }
| { kind: 'pauseResume'; row: number; column: number }
| { kind: 'pauseLeave'; row: number; column: number }
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
@@ -356,12 +364,14 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
@@ -538,6 +548,12 @@ export function PvPRoguelikeScreen({
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
setRewardError(
@@ -654,6 +670,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -687,6 +704,7 @@ export function PvPRoguelikeScreen({
setStartStage(setup.startStage)
setStage(setup.startStage)
setStatus('queueing')
setOverlaySelectedIndex(0)
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id)
@@ -711,6 +729,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -1016,6 +1035,7 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedDebuff(null)
setOverlaySelectedIndex(0)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
@@ -1096,6 +1116,20 @@ export function PvPRoguelikeScreen({
})
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
useEffect(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
@@ -1289,13 +1323,140 @@ export function PvPRoguelikeScreen({
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
if (paused) {
return [
{ kind: 'pauseResume', row: 0, column: 0 },
{ kind: 'pauseLeave', row: 1, column: 0 },
]
}
if (status !== 'upgrade-choice') return []
const entries: PvpOverlayNavEntry[] = [
...playerBuffChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
...playerDebuffChoices.map((_, index) => ({
kind: 'upgradeDebuff' as const,
index,
row: index,
column: 1,
})),
]
entries.push({
kind: 'upgradeContinue',
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
column: 1,
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
})
return entries
}
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
const active = entries[bounded]
if (active && !pvpOverlayEntryDisabled(active)) return active
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
}
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
const active = activePvpOverlayEntry()
if (!active || active.kind !== kind) return false
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
return true
}
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
const entries = pvpOverlayEntries()
const nextIndex = entries.findIndex((entry) => {
if (entry.kind !== kind) return false
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
return true
})
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
}
function movePvpOverlaySelection(action: string) {
const entries = pvpOverlayEntries()
const enabledEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
if (enabledEntries.length === 0) return
setOverlaySelectedIndex((current) => {
const candidate = entries[Math.min(current, entries.length - 1)]
const active = candidate && !pvpOverlayEntryDisabled(candidate)
? candidate
: enabledEntries[0]?.entry
if (!active) return current
const candidates = enabledEntries.filter(({ entry }) => {
if (entry === active) return false
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || a.index - b.index
})
return candidates[0]?.index ?? current
})
}
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (paused) {
if (action === 'back' || action === 'pause') {
setPaused(false)
return
}
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value)
if (status === 'playing') {
setOverlaySelectedIndex(0)
setPaused((value) => !value)
}
return
}
if (paused || status !== 'playing') return
@@ -1411,9 +1572,18 @@ export function PvPRoguelikeScreen({
>
<section className="content-screen pvp-match-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-panel pvp-match-queue-panel" data-game-nav-active="true">
<div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('queueBack')}
type="button"
>
Back
</button>
</div>
)}
@@ -1509,6 +1679,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle}
onCast={castPlayerSpell}
resource={playerSide.resource}
@@ -1528,7 +1699,7 @@ export function PvPRoguelikeScreen({
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="result-screen" data-game-nav-active="true">
<div className="pvp-upgrade-dialog">
<div className="pvp-upgrade-header">
<div>
@@ -1543,9 +1714,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedBuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1559,9 +1732,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerDebuffChoices.map((choice) => (
<button
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedDebuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1572,7 +1747,14 @@ export function PvPRoguelikeScreen({
</div>
</div>
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
onClick={() => confirmUpgradeChoices()}
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
type="button"
>
{liveUpgradePending ? 'Waiting' : 'Continue'}
</button>
</div>
@@ -1580,12 +1762,28 @@ export function PvPRoguelikeScreen({
)}
{paused && (
<div className="pause-screen">
<div className="pause-screen" data-game-nav-active="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
<button
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
data-controller-nav="skip"
onClick={() => setPaused(false)}
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
</button>
</div>
</div>
)}
+34 -17
View File
@@ -7,7 +7,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike } from '../profile'
import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -391,6 +391,7 @@ export function PvpStadiumScreen({
const nextLogId = useRef(2)
const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>())
const matchStatsRecordedRef = useRef(false)
const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('')
@@ -505,6 +506,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -541,6 +543,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -882,6 +885,20 @@ export function PvpStadiumScreen({
return () => window.clearInterval(timer)
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
const startNextRound = useCallback(() => {
const nextRound = roundIndex + 1
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
@@ -927,24 +944,23 @@ export function PvpStadiumScreen({
const finishShop = useCallback(() => {
if (shopReady || status !== 'shop') return
setShopReady(true)
submittedShopRef.current = true
setPlayerSide((current) => {
const next = { ...current, shopReady: true }
playerRef.current = next
return next
})
if (liveMatchRef.current) {
submitPvpUpgradeChoice(liveMatchRef.current.id, {
encounterIndex: roundIndex,
buffId: 'stadium-shop',
debuffId: '',
purchases: playerRef.current.buffs,
shopReady: true,
}).catch(() => undefined)
const liveMatch = liveMatchRef.current
if (!liveMatch) {
startNextRound()
return
}
startNextRound()
setShopReady(true)
submittedShopRef.current = true
const readyPlayer = { ...playerRef.current, shopReady: true }
playerRef.current = readyPlayer
setPlayerSide(readyPlayer)
submitPvpUpgradeChoice(liveMatch.id, {
encounterIndex: roundIndex,
buffId: 'stadium-shop',
debuffId: '',
purchases: playerRef.current.buffs,
shopReady: true,
}).catch(() => undefined)
}, [roundIndex, shopReady, startNextRound, status])
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
@@ -1289,6 +1305,7 @@ export function PvpStadiumScreen({
bindings={activeBindings}
canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle}
onCast={castPlayerSpell}
resource={playerSide.resource}
+7 -6
View File
@@ -100,14 +100,15 @@ export function LootRollList({
return (
<div className="run-loot-rolls">
{rolls.map((roll) => (
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
<div className={roll.dropped || roll.petAwarded ? 'dropped' : 'empty'} key={roll.encounterId}>
<strong>{roll.encounterName}</strong>
<span>
{roll.items.length > 0
? roll.items
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
.join(', ')
: 'No components dropped'}
{[
...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
...(roll.petAwarded
? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
: []),
].join(', ') || 'No components dropped'}
</span>
</div>
))}
+278 -55
View File
@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ACTION_LABELS,
INPUT_ACTIONS,
useInput,
useGameAction,
type InputAction,
type InputDevice,
} from '../input'
import {
ControllerBindingLabel,
ControllerStylePreview,
} from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen'
import {
getNativeDisplays,
@@ -22,11 +18,50 @@ import {
type AndroidDisplay,
} from '../nativeDualScreen'
type SettingsTab = 'display' | 'input' | 'bindings'
type SettingsNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab }
| { kind: 'dualToggle'; key: string; row: number; column: number }
| { kind: 'openCompanion'; key: string; row: number; column: number }
| { kind: 'directTargeting'; key: string; row: number; column: number }
| { kind: 'touchLock'; key: string; row: number; column: number }
| { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS }
| { kind: 'device'; key: string; row: number; column: number; device: InputDevice }
| { kind: 'binding'; key: string; row: number; column: number; action: InputAction }
| { kind: 'resetBindings'; key: string; row: number; column: number }
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const
const SETTINGS_TAB_LABELS: Record<SettingsTab, string> = {
display: 'Display',
input: 'Input',
bindings: 'Bindings',
}
const SETTINGS_BINDING_COLUMNS = 2
const DIRECT_TARGET_ACTIONS = new Set<InputAction>([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [settingsTab, setSettingsTab] = useState<SettingsTab>('display')
const [selectedIndex, setSelectedIndex] = useState(1)
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const {
bindings,
capture,
@@ -47,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
openTopDisplay,
} = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget'
: !directTargetActions.has(action)
))
: !DIRECT_TARGET_ACTIONS.has(action)
)), [directPartyTargeting])
const navEntries = useMemo<SettingsNavEntry[]>(() => {
const entries: SettingsNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...SETTINGS_TAB_ORDER.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab}`,
row: 0,
column: index + 1,
tab,
})),
]
if (settingsTab === 'display') {
entries.push(
{ kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 },
{ kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 },
)
} else if (settingsTab === 'input') {
entries.push(
{ kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 },
{ kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 },
{ kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' },
{ kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' },
{ kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' },
)
} else {
entries.push(
{ kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' },
{ kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' },
...visibleActions.map((action, index) => ({
kind: 'binding' as const,
key: `bindings:action:${action}`,
row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2,
column: index % SETTINGS_BINDING_COLUMNS,
action,
})),
{
kind: 'resetBindings',
key: 'bindings:reset',
row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3,
column: 1,
},
)
}
return entries
}, [settingsTab, visibleActions])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: SettingsTab) {
setSettingsTab(tab)
const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab)
setSelectedIndex(tabIndex + 1)
}
function firstContentKey(tab: SettingsTab) {
if (tab === 'display') return 'display:dual-toggle'
if (tab === 'input') return 'input:direct-targeting'
return 'bindings:device:controller'
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
if (action === 'navigateDown' && active.row === 0) {
const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab))
if (nextIndex >= 0) return nextIndex
}
if (action === 'navigateUp' && active.row === 1) {
const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`)
if (tabIndex >= 0) return tabIndex
}
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: SettingsNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'dualToggle') {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
} else if (entry.kind === 'openCompanion') {
void launchTopDisplay()
} else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting)
else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked)
else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style)
else if (entry.kind === 'device') setDevice(entry.device)
else if (entry.kind === 'binding') beginCapture(device, entry.action)
else if (entry.kind === 'resetBindings') resetBindings(device)
}
async function refreshNativeDisplays() {
if (!nativeDualScreen) return
@@ -79,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
.catch(() => setAndroidDisplays([]))
}, [nativeDualScreen])
useEffect(() => {
if (!activeEntry) return
navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller' || capture) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
async function launchTopDisplay() {
const opened = await openTopDisplay()
setDisplayMessage(opened
@@ -90,34 +264,39 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
}
return (
<section className="content-screen settings-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Game Options</p>
<h1>Settings</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
<div className="settings-nav">
<button
className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{SETTINGS_TAB_ORDER.map((tab) => (
<button
aria-selected={settingsTab === tab}
className={`${settingsTab === tab ? 'selected' : ''} ${selected(`tab:${tab}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`tab:${tab}`) ? 'true' : undefined}
key={tab}
onClick={() => selectTab(tab)}
onPointerDown={() => selectKey(`tab:${tab}`)}
ref={navRef(`tab:${tab}`)}
role="tab"
type="button"
>
{SETTINGS_TAB_LABELS[tab]}
</button>
))}
</nav>
</div>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
{settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel">
<div>
@@ -130,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
className={`${dualScreenEnabled ? 'selected' : ''} ${selected('display:dual-toggle') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('display:dual-toggle') ? 'true' : undefined}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
onPointerDown={() => selectKey('display:dual-toggle')}
ref={navRef('display:dual-toggle')}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
<button
className={selected('display:open-companion') ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selected('display:open-companion') ? 'true' : undefined}
onClick={launchTopDisplay}
onPointerDown={() => selectKey('display:open-companion')}
ref={navRef('display:open-companion')}
type="button"
>
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
@@ -176,16 +367,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
className={`${directPartyTargeting ? 'selected' : ''} ${selected('input:direct-targeting') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:direct-targeting') ? 'true' : undefined}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
onPointerDown={() => selectKey('input:direct-targeting')}
ref={navRef('input:direct-targeting')}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<button
aria-pressed={combatTouchLocked}
className={combatTouchLocked ? 'selected' : ''}
className={`${combatTouchLocked ? 'selected' : ''} ${selected('input:touch-lock') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:touch-lock') ? 'true' : undefined}
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
onPointerDown={() => selectKey('input:touch-lock')}
ref={navRef('input:touch-lock')}
type="button"
>
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
@@ -195,9 +394,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
className={`${controllerIconStyle === style ? 'selected' : ''} ${selected(`input:icons:${style}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`input:icons:${style}`) ? 'true' : undefined}
key={style}
onClick={() => setControllerIconStyle(style)}
onPointerDown={() => selectKey(`input:icons:${style}`)}
ref={navRef(`input:icons:${style}`)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
@@ -220,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
className={`${device === 'controller' ? 'selected' : ''} ${selected('bindings:device:controller') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:controller') ? 'true' : undefined}
onClick={() => setDevice('controller')}
onPointerDown={() => selectKey('bindings:device:controller')}
ref={navRef('bindings:device:controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
className={`${device === 'pc' ? 'selected' : ''} ${selected('bindings:device:pc') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:pc') ? 'true' : undefined}
onClick={() => setDevice('pc')}
onPointerDown={() => selectKey('bindings:device:pc')}
ref={navRef('bindings:device:pc')}
type="button"
>
PC
@@ -238,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
className={`${capture?.device === device && capture.action === action ? 'listening' : ''} ${selected(`bindings:action:${action}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`bindings:action:${action}`) ? 'true' : undefined}
key={action}
onClick={() => beginCapture(device, action)}
onPointerDown={() => selectKey(`bindings:action:${action}`)}
ref={navRef(`bindings:action:${action}`)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
@@ -260,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
<button
className={`text-button ${selected('bindings:reset') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:reset') ? 'true' : undefined}
onClick={() => resetBindings(device)}
onPointerDown={() => selectKey('bindings:reset')}
ref={navRef('bindings:reset')}
type="button"
>
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
+7
View File
@@ -40,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle,
resourceName,
disabled,
focusable = true,
onCast,
emptyKeyPrefix = 'empty',
}: {
@@ -48,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle
resourceName: string
disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void
emptyKeyPrefix?: string
}) {
@@ -61,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return (
<button
className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled}
key={spell.id}
onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description}
type="button"
>
@@ -95,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast,
onCast,
className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: {
spells: SpellSlot[]
bindings: Record<string, string>
@@ -104,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean
onCast: (spell: Spell) => void
className?: string
focusable?: boolean
}) {
return (
<div className={className}>
@@ -119,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton
binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle}
key={spell.id}
onCast={onCast}
+144 -2
View File
@@ -19,6 +19,7 @@ import {
dispatchExternalGameAction,
type ControllerIconStyle,
type InputAction,
useGameAction,
} from './input'
import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames'
@@ -83,7 +84,7 @@ export type DualScreenCombatState = {
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
title: string
subtitle: string
summary?: string
@@ -96,14 +97,34 @@ export type DualScreenWorkshopState = {
}>
}
export type DualScreenSetupState = {
contentType: 'dungeon' | 'raid'
title: string
subtitle: string
description: string
initials: string
difficultyName: string
itemLevel: number
experience: number
lockedReason?: string
stats: {
health: string
damage: string
xp: string
loot: string
}
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'setup-state'; state: DualScreenSetupState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
| { type: 'setup-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -149,6 +170,24 @@ function formatDualTime(seconds: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
function shouldRelayBottomAction(
action: InputAction,
state: DualScreenCombatState | null,
) {
if (!state) return false
if (action === 'confirm' || action === 'toggleTouchLock') return false
if (state.status === 'playing') return true
return action === 'pause' || action === 'back'
}
function shouldRelaySetupAction(
action: InputAction,
state: DualScreenSetupState | null,
) {
if (!state) return false
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -350,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
@@ -366,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
}, [enabled, state])
}
export function useDualScreenSetupPublisher(
state: DualScreenSetupState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'setup-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
const channel = createChannel()
if (!enabled || !channel) return
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
useEffect(() => {
const channel = createChannel()
@@ -378,13 +464,21 @@ export function DualScreenBottomDisplay() {
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
setSetupState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
setSetupState(null)
}
if (event.data.type === 'setup-state') {
setSetupState(event.data.state)
setState(null)
setWorkshopState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
if (event.data.type === 'setup-ended') setSetupState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -402,6 +496,14 @@ export function DualScreenBottomDisplay() {
channel?.close()
}
useGameAction((action) => {
if (
!shouldRelayBottomAction(action, state)
&& !shouldRelaySetupAction(action, setupState)
) return
sendAction(action)
})
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
@@ -436,6 +538,42 @@ export function DualScreenBottomDisplay() {
)
}
if (!state && setupState) {
return (
<main className="dual-bottom-display setup-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{setupState.contentType}</p>
<h1>{setupState.title}</h1>
<small>{setupState.subtitle}</small>
</div>
<div className="dual-controls-progress">
<span>{setupState.difficultyName}</span>
<span>iLvl {setupState.itemLevel}</span>
<span>{setupState.experience} XP</span>
</div>
</header>
<section className="setup-bottom-summary">
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
{setupState.initials}
</span>
<div>
<p className="eyebrow">Selected Run</p>
<h2>{setupState.title}</h2>
<p>{setupState.description}</p>
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
</div>
</section>
<dl className="setup-bottom-stats">
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
</dl>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
@@ -449,7 +587,10 @@ export function DualScreenBottomDisplay() {
}
return (
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
<main
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
>
<header className="dual-controls-header">
<div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -649,6 +790,7 @@ export function DualScreenTopCombat({
return (
<button
className="dual-top-spell"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
+257 -22
View File
@@ -17,6 +17,7 @@ import type {
LootRoll,
Item,
EquipmentSlot,
PetAward,
} from './profile'
export type GameMode = 'online' | 'offline'
@@ -64,6 +65,14 @@ export interface GameRepository {
difficultyId: number,
runToken: string,
): Promise<LootRoll>
recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}>
recordPvpMatch(won: boolean): Promise<CharacterProfile>
}
type CharacterData = {
@@ -76,7 +85,8 @@ type CharacterData = {
}
type OfflineSave = {
version: 3
version: 4
updatedAt: number
characterName: string
activeClassId: number
completedDungeonParts: number
@@ -84,6 +94,10 @@ type OfflineSave = {
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
type OnlineCache = {
@@ -104,6 +118,22 @@ export type CloudSyncStatus = {
dirty: boolean
}
export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown'
export type CloudSaveSummary = {
updatedAt: number | null
characterName: string
activeClassId: number
}
export type CloudSyncComparison = {
relation: CloudSaveAge
local: CloudSaveSummary
server: CloudSaveSummary
}
export type CloudSyncChoice = 'local' | 'server'
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
type NetworkError = Error & {
@@ -124,12 +154,37 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null
function clone<T>(value: T): T {
return structuredClone(value)
}
function normalizedSaveTimestamp(value: unknown): number {
const timestamp = Number(value)
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
}
function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave {
save.updatedAt = updatedAt
return save
}
function saveSummary(save: OfflineSave): CloudSaveSummary {
return {
updatedAt: save.updatedAt > 0 ? save.updatedAt : null,
characterName: save.characterName,
activeClassId: save.activeClassId,
}
}
function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge {
if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown'
if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same'
return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer'
}
function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline'
}
@@ -175,7 +230,8 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
}
}
return {
version: 3,
version: 4,
updatedAt: 0,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
@@ -185,14 +241,35 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
),
characters,
lootRolls: v1.lootRolls ?? {},
bossKills: p.hunterStats?.bossKills ?? {},
bossPets: p.hunterStats?.bossPets ?? {},
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
}
}
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
return normalizeSaveAbilitySlots({
...v2,
version: 3,
version: 4,
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
completedRaidPhases: 0,
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
})
}
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
return normalizeSaveAbilitySlots({
...v3,
version: 4,
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
})
}
@@ -211,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
}
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
}
@@ -224,9 +302,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile
lootRolls?: Record<string, LootRoll>
}
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 3) {
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
}
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
@@ -241,7 +322,7 @@ function readSaveKey(key: string): OfflineSave | null {
const raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw)
if (!save) return null
if (raw.version !== 3) {
if (raw.version !== 4) {
localStorage.setItem(key, JSON.stringify(save))
}
return save
@@ -284,7 +365,7 @@ function readOnlineCache(): OnlineCache | null {
save,
dirty: Boolean(raw.dirty),
}
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
writeOnlineCache(cache)
}
return cache
@@ -380,6 +461,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
}))
static_.hunterStats = {
bossKills: clone(save.bossKills ?? {}),
bossPets: clone(save.bossPets ?? {}),
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
pvpMatchesWon: save.pvpMatchesWon ?? 0,
}
return static_
}
@@ -547,7 +634,8 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
inventory: clone(profile.inventory),
}
return {
version: 3,
version: 4,
updatedAt: existingSave?.updatedAt ?? Date.now(),
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
@@ -557,6 +645,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
),
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
}
}
@@ -570,6 +662,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1]
}
function bossPetName(encounterName: string) {
return `${encounterName} Pet`
}
function purpleBossPetName(encounterName: string) {
return `Purple ${encounterName} Pet`
}
function maybeAwardBossPet(
save: OfflineSave,
encounter: { id: number; enemyName: string },
variant: 'normal' | 'purple' = 'normal',
): PetAward | null {
if (Math.random() >= BOSS_PET_DROP_RATE) return null
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
const previousQuantity = save.bossPets?.[key] ?? 0
save.bossPets = {
...(save.bossPets ?? {}),
[key]: previousQuantity + 1,
}
return {
encounterId: encounter.id,
petName: variant === 'purple'
? purpleBossPetName(encounter.enemyName)
: bossPetName(encounter.enemyName),
quantity: 1,
duplicate: previousQuantity > 0,
quantityAfter: previousQuantity + 1,
}
}
function recordBossKillInSave(
save: OfflineSave,
encounter: { id: number; enemyName: string },
options?: { petVariant?: 'normal' | 'purple' },
): PetAward | null {
const key = String(encounter.id)
save.bossKills = {
...(save.bossKills ?? {}),
[key]: (save.bossKills?.[key] ?? 0) + 1,
}
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
}
function awardRoguelikeCoin(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
@@ -885,6 +1021,10 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
}
function emptyCharacterData(classId: number): CharacterData {
@@ -914,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
return save
}
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
store.writeSave(stampSave(save))
}
function createLocalRepository(store: LocalSaveStore): GameRepository {
return {
async loadSession() {
@@ -961,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
@@ -1067,7 +1211,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
}
store.writeSave(save)
writeStoreSave(store, save)
const updatedProfile = buildProfile(save)
return {
@@ -1172,6 +1316,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
let petAwarded: PetAward | null = null
if (options?.lootSourceEncounterId) {
const sourceEncounter = profile.dungeons
.flatMap((candidate) => candidate.encounters)
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
if (sourceEncounter) {
petAwarded = recordBossKillInSave(save, sourceEncounter, {
petVariant: 'purple',
})
}
}
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
@@ -1179,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
)
cd.inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
const updatedProfile = buildProfile(save)
return {
@@ -1196,6 +1351,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
petAwarded,
profile: updatedProfile,
}
},
@@ -1233,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
cd.talentRanks[String(talentId)] = 1
}
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
}
if (cd.talentPoints <= 0) {
@@ -1261,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async resetTalents() {
@@ -1284,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
cd.talentPoints + refunded,
)
}
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async equipItem(itemId) {
@@ -1296,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
@@ -1307,7 +1463,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async breakdownItem(itemId) {
@@ -1350,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async craftItem(recipeId) {
@@ -1378,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async upgradeItem(itemId) {
@@ -1421,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (upgraded) upgraded.equipped = true
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
@@ -1446,6 +1602,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const petAwarded = encounter.isBoss
? recordBossKillInSave(save, encounter)
: null
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
@@ -1497,12 +1656,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
petAwarded,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return clone(result)
},
async recordBossKill(encounterId, options) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
if (!encounter) throw new Error('Boss encounter not found.')
const petAwarded = recordBossKillInSave(save, encounter, {
petVariant: options?.petVariant ?? 'normal',
})
writeStoreSave(store, save)
return {
profile: buildProfile(save),
petAwarded,
}
},
async recordPvpMatch(won) {
const save = requireStoredSave(store)
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
writeStoreSave(store, save)
return buildProfile(save)
},
}
}
@@ -1581,6 +1764,10 @@ const cachedOnlineRepository: GameRepository = {
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
}
export function getGameMode(): GameMode {
@@ -1613,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
return buildProfile(synced.save)
}
export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
await refreshCatalogFromServer()
const serverSave = await loadServerSyncSave()
return {
relation: compareSaveAge(cache.save, serverSave),
local: saveSummary(cache.save),
server: saveSummary(serverSave),
}
}
export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise<CharacterProfile> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
await refreshCatalogFromServer()
if (choice === 'server') {
const serverSave = await loadServerSyncSave()
writeOnlineCache({
version: 1,
account: cache.account,
save: serverSave,
dirty: false,
})
writeMode('online')
return buildProfile(serverSave)
}
const synced = await pushServerSyncSave(cache.save)
await refreshCatalogFromServer()
writeOnlineCache({
version: 1,
account: cache.account,
save: synced.save,
dirty: false,
})
writeMode('online')
return buildProfile(synced.save)
}
export function selectOnlineMode() {
writeMode('online')
}
@@ -1627,13 +1857,18 @@ export function createOfflineCharacter(characterName: string): AuthSession {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
version: 4,
updatedAt: Date.now(),
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
}
writeOfflineSave(save)
writeMode('offline-local')
+236 -40
View File
@@ -13,6 +13,10 @@ import { Capacitor } from '@capacitor/core'
export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export type MovementVector = {
x: number
y: number
}
export const INPUT_ACTIONS = [
'navigateUp',
@@ -130,10 +134,20 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null
@@ -225,13 +239,13 @@ function loadPreferences() {
combatTouchLocked?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
controllerIconStyle: 'playstation' as ControllerIconStyle,
directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
}
@@ -256,6 +270,16 @@ function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0
}
function focusableDescendants(scope: ParentNode) {
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function uniqueElements(elements: HTMLElement[]) {
return elements.filter((element, index) => elements.indexOf(element) === index)
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
@@ -264,10 +288,22 @@ function focusableElements() {
'.result-screen, .binding-capture, .dual-startup-prompt',
),
).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
const mainContent = Array.from(
document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
).find(isVisible)
const overlay = keyboard ?? pauseMenu ?? dialog
if (overlay) return focusableDescendants(overlay)
const visibleHeaders = Array.from(
document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
).filter(isVisible)
if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
const headerControls = visibleHeaders.flatMap(
(header) => focusableDescendants(header),
)
return uniqueElements([
...focusableDescendants(mainContent),
...headerControls,
])
}
function rememberFocusableControl(element: HTMLElement) {
@@ -277,12 +313,20 @@ function rememberFocusableControl(element: HTMLElement) {
function focusControl(element: HTMLElement) {
rememberFocusableControl(element)
element.focus({ preventScroll: true })
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
function currentFocusableControl(candidates = focusableElements()) {
function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) {
if (
preferControllerFocus
&& lastControllerFocus
&& candidates.includes(lastControllerFocus)
&& isVisible(lastControllerFocus)
) {
return lastControllerFocus
}
const active = document.activeElement
if (active instanceof HTMLElement && candidates.includes(active)) {
rememberFocusableControl(active)
return active
}
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
@@ -291,16 +335,34 @@ function currentFocusableControl(candidates = focusableElements()) {
return null
}
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
if (endA < startB) return startB - endA
if (endB < startA) return startA - endB
return 0
}
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
const options = Array.from(select.options).filter((option) => !option.disabled)
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
const nextOption = options[currentIndex + direction]
if (!nextOption) return false
select.selectedIndex = nextOption.index
select.dispatchEvent(new Event('input', { bubbles: true }))
select.dispatchEvent(new Event('change', { bubbles: true }))
return true
}
export function focusFirstControl() {
if (hasDedicatedGameNavigation()) return null
const first = focusableElements()[0]
if (first) focusControl(first)
return first
}
function moveFocus(action: InputAction) {
function moveFocus(action: InputAction, preferControllerFocus = false) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = currentFocusableControl(candidates)
const current = currentFocusableControl(candidates, preferControllerFocus)
if (!current) {
focusFirstControl()
return
@@ -311,21 +373,48 @@ function moveFocus(action: InputAction) {
const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const currentIndex = candidates.indexOf(current)
const adjacent = candidates[currentIndex + direction]
const fallbackToAdjacent = () => {
if (adjacent) focusControl(adjacent)
}
const ranked = candidates
.filter((candidate) => candidate !== current)
.map((candidate) => {
.map((candidate, index) => {
const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
const primary = vertical
? direction > 0
? rect.top - currentRect.bottom
: currentRect.top - rect.bottom
: direction > 0
? rect.left - currentRect.right
: currentRect.left - rect.right
const secondary = vertical
? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right)
: rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom)
return {
candidate,
index,
primary,
secondary,
score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1,
}
})
.filter(({ primary }) => Math.sign(primary) === direction)
.sort((a, b) => a.score - b.score)
.filter(({ primary }) => primary >= -4)
const sameRowHorizontal = !vertical
? ranked.filter(({ secondary }) => secondary === 0)
: ranked
if (sameRowHorizontal.length === 0) {
fallbackToAdjacent()
return
}
sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index)
const next = ranked[0]?.candidate
const next = sameRowHorizontal[0]?.candidate
if (!next) return
focusControl(next)
}
@@ -338,6 +427,18 @@ function hasUiOverlay() {
).some(isVisible)
}
function hasDedicatedGameNavigation() {
return Array.from(
document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
).some(isVisible)
}
function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}
const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
@@ -433,6 +534,10 @@ function isCombatActive() {
return Boolean(document.querySelector('[data-combat-active="true"]'))
}
function hasContinuousMovementActive() {
return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
}
function firstConnectedGamepad() {
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
}
@@ -508,10 +613,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
}
if (action === 'toggleTouchLock') {
setPreferences((current) => ({
...current,
@@ -524,17 +637,34 @@ export function InputProvider({ children }: { children: ReactNode }) {
setTouchLockMessage('')
}, 1400)
} else if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) moveFocus(action)
if (uiOverlay || !combatActive) {
const active = currentFocusableControl(focusableElements(), controllerUiInput)
if (
active instanceof HTMLSelectElement
&& (action === 'navigateUp' || action === 'navigateDown')
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
) {
if (controllerUiInput) focusControl(active)
return
}
moveFocus(action, controllerUiInput)
}
} else if (action === 'confirm') {
const active = currentFocusableControl()
const active = currentFocusableControl(focusableElements(), controllerUiInput)
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLSelectElement) {
if (controllerUiInput) focusControl(active)
const select = active as HTMLSelectElement & { showPicker?: () => void }
if (select.showPicker) select.showPicker()
else active.click()
} else if (
active
&& active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active)
) {
if (controllerUiInput) focusControl(active)
active.click()
} else {
focusFirstControl()
@@ -550,9 +680,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
}
}
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
dispatchGameAction(action, device)
}, [closeKeyboard])
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
@@ -566,12 +694,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'),
)
const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
if (
combatActive
&& !uiOverlay
&& hasContinuousMovementActive()
&& (token.startsWith('Axis0') || token.startsWith('Axis1'))
) return
const uiPriority = [
'navigateUp',
'navigateDown',
@@ -608,18 +736,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
? menuDpadActions[token]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? DPAD_NAV_ACTIONS[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
: combatActive && menuDpadActions[token]
? menuDpadActions[token]
: !combatActive && menuDpadActions[token]
? menuDpadActions[token]
: combatActive && DPAD_NAV_ACTIONS[token]
? DPAD_NAV_ACTIONS[token]
: !combatActive && DPAD_NAV_ACTIONS[token]
? DPAD_NAV_ACTIONS[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
@@ -658,6 +786,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
if (document.documentElement.dataset.inputDevice !== 'controller') return
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
@@ -668,7 +797,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
if (!control || !isVisible(control)) return
rememberFocusableControl(control)
if (event.pointerType !== 'touch') rememberFocusableControl(control)
}
document.addEventListener('focusin', onFocusIn)
document.addEventListener('pointerdown', onPointerDown, { capture: true })
@@ -697,6 +826,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
if (hasDedicatedGameNavigation()) return
const candidates = focusableElements()
const activeControl = currentFocusableControl(candidates)
if (
@@ -768,11 +898,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
const canRepeat = action?.startsWith('navigate') ?? false
const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token])
const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125)
repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
}
})
@@ -930,6 +1060,74 @@ export function InputProvider({ children }: { children: ReactNode }) {
)
}
function normalizedVector(x: number, y: number): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude <= 1) return { x, y }
return { x: x / magnitude, y: y / magnitude }
}
function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude < deadzone) return { x: 0, y: 0 }
const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
return {
x: (x / magnitude) * scaled,
y: (y / magnitude) * scaled,
}
}
export function useMovementVectorRef(enabled = true) {
const movementRef = useRef<MovementVector>({ x: 0, y: 0 })
useEffect(() => {
if (!enabled) {
movementRef.current = { x: 0, y: 0 }
return undefined
}
let frame = 0
const pressedKeys = new Set<string>()
const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
)
const updateMovement = () => {
const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const keyboard = keyboardVector()
movementRef.current = Math.hypot(stick.x, stick.y) > 0
? stick
: keyboard
frame = window.requestAnimationFrame(updateMovement)
}
const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.add(event.code)
document.documentElement.dataset.inputDevice = 'pc'
event.preventDefault()
}
const onKeyUp = (event: KeyboardEvent) => {
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.delete(event.code)
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 }
}
}, [enabled])
return movementRef
}
export function useInput() {
const context = useContext(InputContext)
if (!context) throw new Error('useInput must be used inside InputProvider')
@@ -957,7 +1155,5 @@ export function dispatchExternalGameAction(
action: InputAction,
device: InputDevice,
) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
dispatchGameAction(action, device)
}
+12 -2
View File
@@ -2,14 +2,24 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core'
import './index.css'
import './App.css'
import App from './App.tsx'
import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
import { Iwt2BottomDisplay } from './modes/iwt2/Iwt2BottomDisplay.tsx'
const displayMode = new URLSearchParams(window.location.search).get('display')
createRoot(document.getElementById('root')!).render(
<StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<DualScreenBottomDisplay />
{displayMode === 'iwt2-bottom' ? (
<InputProvider>
<Iwt2BottomDisplay />
</InputProvider>
) : displayMode === 'bottom' ? (
<InputProvider>
<DualScreenBottomDisplay />
</InputProvider>
) : (
<DualScreenProvider>
<DualScreenStartupPrompt />
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useState } from 'react'
import { useGameAction } from '../../input'
import { BossArenaScreen } from './screens/BossArenaScreen'
import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2HunterProfileScreen,
Iwt2ModePlaceholderScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
loadIwt2Save,
writeIwt2Save,
type Iwt2Save,
} from './save/iwt2Repository'
import type { Iwt2BossId } from './content/bosses'
type Iwt2Screen =
| 'menu'
| 'arena'
| 'cloud-save'
| 'dungeons'
| 'raids'
| 'roguelike'
| 'pvp'
| 'hunter-profile'
| 'customize-character'
| 'settings'
const IWT2_MENU_COLUMNS = 4
const MENU_ITEMS: Array<{
screen: Iwt2Screen
title: string
description: string
glyph: string
}> = [
{
screen: 'cloud-save',
title: 'Cloud Save',
description: 'Account sync shell for IWT2 progression and local save status.',
glyph: 'C',
},
{
screen: 'dungeons',
title: 'Dungeons',
description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.',
glyph: 'D',
},
{
screen: 'raids',
title: 'Raids',
description: 'Large-party encounter shell for future multi-group boss fights.',
glyph: 'R',
},
{
screen: 'roguelike',
title: 'Roguelike',
description: 'Run-based IWT2 progression shell for room chains and reward drafts.',
glyph: 'G',
},
{
screen: 'pvp',
title: 'PvP',
description: 'Competitive healing shell with controller targeting preserved.',
glyph: 'P',
},
{
screen: 'hunter-profile',
title: 'Hunter Profile',
description: 'IWT2 level, inventory summary, and collection log.',
glyph: 'H',
},
{
screen: 'customize-character',
title: 'Customize Character',
description: 'IWT2-only character identity and cosmetic setup shell.',
glyph: 'K',
},
{
screen: 'settings',
title: 'Settings',
description: 'IWT2 targeting preference and controller icon style.',
glyph: 'S',
},
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
useEffect(() => {
writeIwt2Save(save)
}, [save])
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
return
}
if (action === 'confirm') {
setScreen(MENU_ITEMS[selectedIndex].screen)
return
}
if (action === 'navigateUp' || action === 'navigateLeft') {
const offset = action === 'navigateUp' ? IWT2_MENU_COLUMNS : 1
setSelectedIndex((current) => Math.max(0, current - offset))
} else if (action === 'navigateDown' || action === 'navigateRight') {
const offset = action === 'navigateDown' ? IWT2_MENU_COLUMNS : 1
setSelectedIndex((current) => Math.min(MENU_ITEMS.length - 1, current + offset))
}
})
if (screen === 'arena') {
return (
<BossArenaScreen
bossId={selectedBossId}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setSelectedBossId(bossId)
setScreen('arena')
}}
/>
</main>
)
}
if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') {
const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP'
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModePlaceholderScreen mode={modeName} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'hunter-profile') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CustomizeCharacterScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'settings') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
</main>
)
}
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
<div className="iwt2-menu-heading">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Mode Select</h1>
</div>
<div className="iwt2-menu-grid">
{MENU_ITEMS.map((item, index) => (
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={item.screen}
onClick={() => setScreen(item.screen)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.title}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
</main>
)
}
function Iwt2Header({
onBackToGameSelect,
save,
}: {
onBackToGameSelect: () => void
save: Iwt2Save
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
</button>
<div className="character-summary">
<strong>{save.character.name}</strong>
<small>IWT2 Level {save.character.level}</small>
<small>{save.character.experience} XP</small>
</div>
</header>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { ControllerBindingLabel } from '../../components/ControllerIcons'
import { DEFAULT_BINDINGS, useInput } from '../../input'
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
import { abilitiesForHealer } from './content/healerAbilities'
export function Iwt2BottomDisplay() {
const {
bindings,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = lastDevice === 'controller'
? bindings.controller
: DEFAULT_BINDINGS.controller
const abilities = abilitiesForHealer('field_medic')
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
return (
<main className="dual-bottom-display iwt2-bottom-display">
<section className="dual-controls-resource iwt2-bottom-resource">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<strong>Field Medic</strong>
</div>
<div className="dual-controls-mana">
<span>Mana 100 / 100</span>
<div className="bar mana-bar"><span style={{ width: '100%' }} /></div>
</div>
</section>
<section className={`dual-controls-targets ${directPartyTargeting ? 'direct' : ''}`} aria-label="Party targets">
{directPartyTargeting ? (
partyTargets.map((target, index) => {
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
return (
<button type="button" key={target.id}>
<ControllerBindingLabel
binding={activeBindings[action]}
iconStyle="playstation"
/>{' '}
{label}
</button>
)
})
) : (
<>
<button type="button">
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
</button>
<button type="button">
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
</button>
</>
)}
</section>
<section className="dual-controls-spells iwt2-bottom-spell-grid" aria-label="Spells and cooldowns">
{abilities.map((ability, index) => {
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
return (
<button className="spell iwt2-bottom-spell" key={ability.id} type="button">
<kbd>
<ControllerBindingLabel
binding={activeBindings[action]}
compact
iconStyle="playstation"
/>
</kbd>
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
<strong>{ability.name}</strong>
<small>{ability.manaCost} Mana</small>
</button>
)
})}
</section>
</main>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
export function AbilityBar({
abilities,
cooldowns,
mana,
onCast,
}: {
abilities: Iwt2HealerAbility[]
cooldowns: Record<string, number>
mana: number
onCast: (ability: Iwt2HealerAbility) => void
}) {
return (
<div className="iwt2-ability-bar">
{abilities.map((ability) => {
const remaining = cooldowns[ability.id] ?? 0
const disabled = remaining > 0 || mana < ability.manaCost
return (
<button
aria-label={`${ability.slot}. ${ability.name}`}
className="iwt2-ability-button"
disabled={disabled}
key={ability.id}
onClick={() => onCast(ability)}
title={`${ability.name} - ${ability.manaCost} MP`}
type="button"
>
<span>{ability.icon}</span>
<strong>{ability.slot}</strong>
<i>{remaining > 0 ? `${remaining.toFixed(1)}s` : `${ability.manaCost} MP`}</i>
</button>
)
})}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
export function ArenaBar({
className = '',
current,
max,
shield = 0,
}: {
className?: string
current: number
max: number
shield?: number
}) {
const percent = max > 0 ? Math.max(0, Math.min(100, (current / max) * 100)) : 0
const shieldPercent = max > 0 ? Math.max(0, Math.min(100, (shield / max) * 100)) : 0
return (
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return (
<div className="iwt2-boss-hud">
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { ControllerBindingLabel } from '../../../components/ControllerIcons'
import type { ControllerIconStyle } from '../../../input'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
export function PartyFrames({
party,
selectedPartyId,
targetBindings,
controllerIconStyle,
onTarget,
}: {
party: Iwt2PartyEntityState[]
selectedPartyId: Iwt2EntityId
targetBindings?: Array<string | null>
controllerIconStyle?: ControllerIconStyle
onTarget: (id: Iwt2EntityId) => void
}) {
return (
<aside className="iwt2-party-list" data-game-nav-active="true">
{party.map((member) => {
const meta = IWT2_CLASS_METADATA[member.classId]
const selected = member.id === selectedPartyId
const targetBinding = targetBindings?.[party.indexOf(member)]
return (
<button
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
key={member.id}
onClick={() => onTarget(member.id)}
type="button"
>
{targetBinding && (
<span className="iwt2-party-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={controllerIconStyle ?? 'playstation'}
/>
</span>
)}
<span style={{ background: meta.color }}>{meta.icon}</span>
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
<div className="iwt2-party-effects">
{member.hotEffects.map((effect) => (
<span className="iwt2-effect-badge hot" key={effect.id}>
{effect.label} {Math.ceil(effect.remainingSeconds)}s
</span>
))}
{member.shield > 0 && (
<span className="iwt2-effect-badge shield">
Shield {Math.ceil(member.shield)}
</span>
)}
</div>
<small className="iwt2-party-damage">Damage {Math.round(member.damageDone)}</small>
</div>
</button>
)
})}
</aside>
)
}
@@ -0,0 +1,6 @@
import type { Iwt2BalanceOverrides } from './bosses'
// Generated by local admin panel. Commit this file with intended IWT2 balance changes.
export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = {
bosses: {},
}
+134
View File
@@ -0,0 +1,134 @@
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku'
export type Iwt2BalanceOverrides = {
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
}
export type Iwt2BossMetadata = {
id: Iwt2BossId
name: string
icon: string
color: string
accentColor: string
maxHealth: number
radius: number
moveSpeed: number
meleeRange: number
meleeDamage: number
meleeCooldown: number
chargeCooldown: number
chargeWindup: number
chargeSpeed: number
chargeDamage: number
chargeStunSeconds: number
chargeLength: number
chargeOvershoot: number
chargeWidth: number
slamWindup: number
slamRadius: number
slamDamage: number
slamStunSeconds: number
fireballCooldown?: number
fireballWindup?: number
fireballSpeed?: number
fireballDamage?: number
fireballRadius?: number
firePuddleRadius?: number
firePuddleDamage?: number
firePuddleSeconds?: number
birdWaveThresholds?: number[]
birdFlightCooldown?: number
birdFlightWindup?: number
birdFlightSpeed?: number
birdHealth?: number
birdRadius?: number
birdContactDamage?: number
birdStunSeconds?: number
}
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
id: 'bulldrome',
name: 'Bulldrome',
icon: 'B',
color: '#8f5a3c',
accentColor: '#e6b17f',
maxHealth: 950,
radius: 31,
moveSpeed: 128,
meleeRange: 58,
meleeDamage: 9,
meleeCooldown: 1.05,
chargeCooldown: 4.75,
chargeWindup: 0.55,
chargeSpeed: 510,
chargeDamage: 32,
chargeStunSeconds: 0.75,
chargeLength: 560,
chargeOvershoot: 120,
chargeWidth: 62,
slamWindup: 0.45,
slamRadius: 104,
slamDamage: 24,
slamStunSeconds: 0.75,
}
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'yian-kut-ku',
name: 'Yian Kut Ku',
icon: 'Y',
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
radius: 29,
moveSpeed: 118,
meleeRange: 56,
meleeDamage: 8,
meleeCooldown: 1,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
fireballCooldown: 3.6,
fireballWindup: 0.55,
fireballSpeed: 285,
fireballDamage: 12,
fireballRadius: 9,
firePuddleRadius: 42,
firePuddleDamage: 7,
firePuddleSeconds: 8,
birdWaveThresholds: [0.9, 0.4],
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 58,
birdRadius: 16,
birdContactDamage: 10,
birdStunSeconds: 0.75,
}
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
if (!override) return metadata
return {
...metadata,
...override,
}
}
export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA)
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
bulldrome: BULLDROME_BOSS_METADATA,
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
}
+121
View File
@@ -0,0 +1,121 @@
export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogue' | 'warrior'
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
color: string
accentColor: string
maxHealth: number
moveSpeed: number
radius: number
attackRange: number
attackDamage: number
attackCooldown: number
castTime: number
projectileSpeed: number
}
export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> = {
healer: {
id: 'healer',
name: 'Player Healer',
role: 'healer',
icon: '+',
color: '#2fbf71',
accentColor: '#b7f7cf',
maxHealth: 125,
moveSpeed: 210,
radius: 15,
attackRange: 0,
attackDamage: 0,
attackCooldown: 1,
castTime: 0,
projectileSpeed: 0,
},
paladin: {
id: 'paladin',
name: 'Paladin Tank',
role: 'tank',
icon: '🛡',
color: '#f2c94c',
accentColor: '#fff0a8',
maxHealth: 190,
moveSpeed: 165,
radius: 17,
attackRange: 44,
attackDamage: 7,
attackCooldown: 0.85,
castTime: 0,
projectileSpeed: 0,
},
ranger: {
id: 'ranger',
name: 'Ranger',
role: 'damage',
icon: '🏹',
color: '#4aa3df',
accentColor: '#b9e3ff',
maxHealth: 115,
moveSpeed: 185,
radius: 14,
attackRange: 245,
attackDamage: 8,
attackCooldown: 0.62,
castTime: 0.32,
projectileSpeed: 410,
},
mage: {
id: 'mage',
name: 'Mage',
role: 'damage',
icon: '⚚',
color: '#c56cf0',
accentColor: '#efc4ff',
maxHealth: 100,
moveSpeed: 175,
radius: 14,
attackRange: 220,
attackDamage: 11,
attackCooldown: 0.78,
castTime: 0.42,
projectileSpeed: 330,
},
rogue: {
id: 'rogue',
name: 'Rogue',
role: 'damage',
icon: '††',
color: '#55efc4',
accentColor: '#c8fff2',
maxHealth: 110,
moveSpeed: 230,
radius: 14,
attackRange: 38,
attackDamage: 6,
attackCooldown: 0.38,
castTime: 0,
projectileSpeed: 0,
},
warrior: {
id: 'warrior',
name: 'Warrior',
role: 'damage',
icon: '⚔',
color: '#ff7675',
accentColor: '#ffd0d0',
maxHealth: 150,
moveSpeed: 180,
radius: 16,
attackRange: 42,
attackDamage: 9,
attackCooldown: 0.52,
castTime: 0,
projectileSpeed: 0,
},
}
export const IWT2_PARTY_ORDER: Iwt2PlayerClassId[] = ['healer', 'paladin', 'ranger', 'mage', 'rogue', 'warrior']
+19
View File
@@ -0,0 +1,19 @@
import type { InputAction } from '../../../input'
export const IWT2_ABILITY_ACTIONS: InputAction[] = [
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
]
export const IWT2_TARGET_ACTIONS: InputAction[] = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
]
+125
View File
@@ -0,0 +1,125 @@
import type { Spell } from '../../../game'
import type { Ability } from '../../../profile'
import { toCombatSpell } from '../../../combat/rules'
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
export type Iwt2HealerAbility = {
id: string
healerId: string
slot: number
name: string
icon: string
kind: Spell['kind']
power: number
effectType?: string
manaCost: number
cooldownSeconds: number
target: Iwt2AbilityTarget
}
const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [
{
id: 1,
classId: 1,
slug: 'mend',
name: 'Mend',
spellType: 'direct_heal',
cost: 5,
cooldown: 0.5,
power: 30,
unlockLevel: 1,
glyph: '+',
description: 'A fast, efficient single-target heal.',
},
{
id: 2,
classId: 1,
slug: 'renew',
name: 'Renew',
spellType: 'heal_over_time',
cost: 7,
cooldown: 0.5,
power: 12,
unlockLevel: 1,
glyph: '~',
description: 'Heals now and continues healing over time.',
},
{
id: 3,
classId: 1,
slug: 'radiance',
name: 'Radiance',
spellType: 'party_heal',
cost: 12,
cooldown: 8,
power: 18,
unlockLevel: 1,
glyph: '*',
description: 'Restores health to up to 4 injured party members.',
},
{
id: 4,
classId: 1,
slug: 'sun-ward',
name: 'Sun Ward',
spellType: 'absorb',
cost: 8,
cooldown: 7,
power: 36,
unlockLevel: 1,
glyph: 'O',
description: 'Places a damage-absorbing shield on your target.',
},
{
id: 5,
classId: 1,
slug: 'purify',
name: 'Purify',
spellType: 'cleanse',
cost: 5,
cooldown: 5,
power: 10,
unlockLevel: 1,
glyph: 'x',
description: 'Removes a harmful effect and restores health.',
},
{
id: 6,
classId: 1,
slug: 'dawn-burst',
name: 'Dawn Burst',
spellType: 'party_heal',
cost: 16,
cooldown: 12,
power: 28,
unlockLevel: 5,
glyph: 'D',
description: 'A brilliant wave of healing for up to 4 injured allies.',
},
]
function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility {
const spell = toCombatSpell(ability, String(index + 1))
return {
id: spell.id,
healerId: 'field_medic',
slot: index + 1,
name: spell.name,
icon: spell.glyph,
kind: spell.kind,
power: spell.power,
effectType: spell.effectType,
manaCost: spell.cost,
cooldownSeconds: spell.cooldown,
target: 'party-member',
}
}
export const IWT2_HEALER_ABILITIES: Record<string, Iwt2HealerAbility[]> = {
field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability),
}
export function abilitiesForHealer(healerId: string) {
return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic
}
+18
View File
@@ -0,0 +1,18 @@
export {
IWT2_CLASS_METADATA,
IWT2_PARTY_ORDER,
} from './classes'
export type {
Iwt2ClassMetadata,
Iwt2PlayerClassId,
Iwt2PlayerClassRole,
} from './classes'
export {
BULLDROME_BOSS_METADATA,
IWT2_BOSS_METADATA,
} from './bosses'
export type {
Iwt2BossId,
Iwt2BossMetadata,
} from './bosses'
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef, type MutableRefObject } from 'react'
import Phaser from 'phaser'
import type { MovementVector } from '../../../input'
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
import type { Iwt2ArenaState } from '../sim/arenaState'
type PhaserArenaProps = {
movementRef: MutableRefObject<MovementVector>
selectedPartyIdRef: MutableRefObject<string>
stateRef: MutableRefObject<Iwt2ArenaState>
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!hostRef.current) return undefined
const initialState = stateRef.current
const scene = new BulldromeArenaScene({
getMovement: () => movementRef.current,
getSelectedPartyId: () => selectedPartyIdRef.current,
getState: () => stateRef.current,
step: onStep,
})
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: hostRef.current,
width: initialState.bounds.width,
height: initialState.bounds.height,
backgroundColor: '#10141b',
pixelArt: false,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene,
})
return () => {
game.destroy(true)
}
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
return <div className="iwt2-phaser-host" ref={hostRef} />
}
@@ -0,0 +1,281 @@
import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA } from '../../content/bosses'
import { IWT2_CLASS_METADATA } from '../../content/classes'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from '../../sim'
type SceneDeps = {
getMovement: () => MovementVector
getSelectedPartyId: () => string
getState: () => Iwt2ArenaState
step: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
private arenaGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private labels = new Map<string, Phaser.GameObjects.Text>()
private lastHudPublish = 0
constructor(deps: SceneDeps) {
super('bulldrome-arena')
this.deps = deps
}
create() {
this.cameras.main.setBackgroundColor('#10141b')
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.entityGraphics = this.add.graphics()
this.drawState(this.deps.getState())
}
update(_: number, delta: number) {
const state = this.deps.step(this.deps.getMovement(), Math.min(0.05, delta / 1000))
this.drawState(state)
}
private drawState(state: Iwt2ArenaState) {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.lastHudPublish += 1
}
private drawArena(state: Iwt2ArenaState) {
const graphics = this.arenaGraphics!
graphics.clear()
graphics.lineStyle(3, 0x3c4a59, 1)
graphics.fillStyle(0x171d24, 1)
graphics.fillRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
graphics.strokeRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
graphics.lineStyle(1, 0x26313d, 0.55)
for (let x = 80; x < state.bounds.width; x += 80) {
graphics.lineBetween(x, 0, x, state.bounds.height)
}
for (let y = 80; y < state.bounds.height; y += 80) {
graphics.lineBetween(0, y, state.bounds.width, y)
}
}
private drawTelegraphs(state: Iwt2ArenaState) {
const graphics = this.telegraphGraphics!
graphics.clear()
state.indicators.forEach((indicator) => drawIndicator(graphics, indicator))
}
private drawEntities(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
const liveIds = new Set<string>(entities.map((entity) => entity.id))
for (const entity of entities.sort(entitySort)) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
const color = entityColor(entity)
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
if (entity.kind === 'boss') {
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
} else if (entity.kind === 'hostileAdd') {
const wing = entity.radius * 1.55
graphics.fillTriangle(
entity.position.x + entity.facing.x * wing,
entity.position.y,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y - entity.radius,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y + entity.radius,
)
graphics.strokeTriangle(
entity.position.x + entity.facing.x * wing,
entity.position.y,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y - entity.radius,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y + entity.radius,
)
} else {
if (entity.id === this.deps.getSelectedPartyId()) {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
}
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
this.drawLabel(entity)
this.drawHealthBar(graphics, entity)
}
for (const [id, label] of this.labels) {
if (!liveIds.has(id)) {
label.destroy()
this.labels.delete(id)
}
}
}
private drawProjectiles(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
for (const projectile of state.projectiles) {
const color = Number.parseInt(projectile.color.slice(1), 16)
graphics.fillStyle(color, 0.95)
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
}
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entityIcon(entity)
if (!label) {
label = this.add.text(entity.position.x, entity.position.y, icon, {
align: 'center',
color: '#fff7df',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px',
fontStyle: '900',
}).setOrigin(0.5)
this.labels.set(entity.id, label)
}
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
label.setText(stunned ? '!' : icon)
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
}
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
const width = entity.kind === 'boss' ? 92 : 34
const height = entity.kind === 'boss' ? 8 : 7
const y = entity.position.y + entity.radius + 8
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
graphics.fillStyle(0x050608, 0.9)
graphics.fillRect(entity.position.x - width / 2, y, width, height)
graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1)
graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height)
if (entity.kind === 'party' && entity.shield > 0) {
const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth))
graphics.fillStyle(0x58a8ff, 0.92)
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
}
}
}
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
return rank[a.kind] - rank[b.kind]
}
function entityColor(entity: DrawableEntity): string {
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
if (entity.kind === 'hostileAdd') return '#f0b84f'
return IWT2_CLASS_METADATA[entity.classId].color
}
function entityIcon(entity: DrawableEntity): string {
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon
if (entity.kind === 'hostileAdd') return 'v'
return IWT2_CLASS_METADATA[entity.classId].icon
}
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
graphics.fillStyle(0xff7a2f, alpha)
graphics.lineStyle(2, 0xffc15a, 0.55)
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
}
function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) {
const color = Number.parseInt(indicator.color.slice(1), 16)
const active = indicator.phase === 'active'
graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5))
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
if (indicator.kind === 'lane') {
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
if (indicator.kind === 'circle') {
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius)
return
}
if (indicator.kind === 'cone') {
const points = coneDangerPolygon(
indicator.origin,
indicator.direction,
indicator.range,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
}
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.max(1, Math.hypot(dx, dy))
const normal = {
x: (-dy / length) * halfWidth,
y: (dx / length) * halfWidth,
}
return [
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y),
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y),
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y),
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y),
]
}
function coneDangerPolygon(
origin: Iwt2Vec2,
direction: Iwt2Vec2,
range: number,
angleRadians: number,
) {
const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
const segmentCount = 14
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
origin.x + Math.cos(angle) * range,
origin.y + Math.sin(angle) * range,
))
}
return points
}
+115
View File
@@ -0,0 +1,115 @@
export type Iwt2InventoryItem = {
id: string
name: string
quantity: number
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
}
export type Iwt2CollectionLog = {
bossKills: Record<string, number>
dropsFound: Record<string, number>
}
export type Iwt2Save = {
version: 1
updatedAt: number
character: {
name: string
level: number
experience: number
}
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
}
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
export function createDefaultIwt2Save(): Iwt2Save {
return {
version: 1,
updatedAt: Date.now(),
character: {
name: 'Healer',
level: 1,
experience: 0,
},
inventory: [],
collectionLog: {
bossKills: {},
dropsFound: {},
},
}
}
function normalizeSave(value: unknown): Iwt2Save {
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
const candidate = value as Partial<Iwt2Save>
if (candidate.version !== 1) return createDefaultIwt2Save()
return {
version: 1,
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
character: {
name: candidate.character?.name || 'Healer',
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
},
}
}
export function loadIwt2Save(): Iwt2Save {
try {
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
} catch {
return createDefaultIwt2Save()
}
}
export function writeIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
...save,
updatedAt: Date.now(),
}))
}
export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
const drop = bossDropFor(bossId)
return {
...save,
updatedAt: Date.now(),
character: {
...save.character,
experience: save.character.experience + 125,
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)),
},
inventory: [
...save.inventory,
{
id: `${drop.id}-${Date.now()}`,
name: drop.name,
quantity: 1,
rarity: 'common',
},
],
collectionLog: {
...save.collectionLog,
bossKills: {
...save.collectionLog.bossKills,
[bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1,
},
dropsFound: {
...save.collectionLog.dropsFound,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
},
},
}
}
function bossDropFor(bossId: string): { id: string, name: string } {
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
}
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
createInitialIwt2ArenaState,
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKill,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'menu'
type BossArenaScreenProps = {
bossId: Iwt2BossId
save: Iwt2Save
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
}
export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState)
const statusRef = useRef(status)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const killRecordedRef = useRef(false)
const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({})
const movementRef = useMovementVectorRef(status === 'playing')
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
statusRef.current = status
}, [status])
useEffect(() => {
selectedPartyIdRef.current = selectedPartyId
}, [selectedPartyId])
useEffect(() => {
saveRef.current = save
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId)
killRecordedRef.current = false
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
setArenaState(next)
setAbilityCooldowns({})
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
setStatus(nextStatus)
}, [])
const abilities = useMemo(
() => abilitiesForHealer('field_medic'),
[],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
if (statusRef.current !== 'playing') return
if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return
const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current)
if (!result.cast) return
const nextCooldowns = {
...abilityCooldownsRef.current,
[ability.id]: ability.cooldownSeconds,
}
abilityCooldownsRef.current = nextCooldowns
stateRef.current = result.state
lastHudSignatureRef.current = arenaHudSignature(result.state)
setAbilityCooldowns(nextCooldowns)
setArenaState(result.state)
}, [])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (action.startsWith('navigate')) {
setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary')
return
}
if (action === 'confirm') {
if (selectedOverlayAction === 'menu') onBack()
else if (statusRef.current === 'paused') setStatus('playing')
else resetArena()
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
else onBack()
return
}
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
return
}
if (
device === 'controller'
&& statusRef.current === 'playing'
&& !directPartyTargeting
&& (
action === 'navigateUp'
|| action === 'navigateLeft'
|| action === 'previousTarget'
|| action === 'navigateDown'
|| action === 'navigateRight'
|| action === 'nextTarget'
)
) {
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
return
}
if (action.startsWith('targetParty')) {
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
if (statusRef.current !== 'playing') return stateRef.current
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
const next = tickIwt2Arena(stateRef.current, {
moveX: movement.x,
moveY: movement.y,
}, dtSeconds)
stateRef.current = next
if (next.boss.health <= 0 && !killRecordedRef.current) {
killRecordedRef.current = true
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
saveRef.current = updatedSave
onSaveUpdated(updatedSave)
showOverlay('victory')
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
const hudSignature = arenaHudSignature(next)
if (
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.boss.health <= 0
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
setArenaState(next)
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [bossId, onSaveUpdated, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
: undefined
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart'
const overlayTitle = status === 'victory'
? `${bossMetadata.name} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
const overlayEyebrow = status === 'victory'
? 'Hunt Complete'
: status === 'defeat'
? 'Recovery'
: 'Paused'
const overlayTone = status === 'victory'
? 'is-victory'
: status === 'defeat'
? 'is-defeat'
: 'is-paused'
return (
<main
className="game-shell iwt2-arena-shell"
data-combat-active={status === 'playing' ? 'true' : 'false'}
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
>
<section className="iwt2-arena-layout">
<div className="iwt2-arena-stage">
<BossHud boss={arenaState.boss} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
party={arenaState.party}
selectedPartyId={selectedPartyId}
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status !== 'playing' && (
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
<span>{bossMetadata.icon}</span>
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
{status !== 'paused' && (
<div className="iwt2-result-summary" aria-label="Arena result summary">
<span>
<strong>{formatArenaTime(arenaState.time)}</strong>
Clear
</span>
<span>
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
)}
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+125 XP</span>
<span>Common carve</span>
<span>Log +1</span>
</div>
)}
<div className="iwt2-overlay-actions">
<button
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
type="button"
>
{overlayPrimaryLabel}
</button>
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={onBack}
type="button"
>
Menu
</button>
</div>
{lastDevice === 'controller' && (
<small className="iwt2-result-hint">D-pad selects, A confirms, B exits</small>
)}
</div>
</div>
)}
<AbilityBar
abilities={abilities}
cooldowns={abilityCooldowns}
mana={playerMana}
onCast={castAbility}
/>
</div>
</section>
</main>
)
}
function formatArenaTime(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainder = safeSeconds % 60
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
state.boss.id,
Math.ceil(state.boss.health),
state.boss.attackPhase,
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
member.id,
Math.ceil(member.health),
Math.ceil(member.shield),
Math.ceil(member.mana),
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
].join(':')),
].join('|')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
const nextIndex = (currentIndex + direction + targets.length) % targets.length
return targets[nextIndex]?.id ?? currentId
}
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
let changed = false
const next: Record<string, number> = {}
for (const [id, remaining] of Object.entries(cooldowns)) {
const value = Math.max(0, remaining - dt)
if (value !== remaining) changed = true
if (value > 0) next[id] = value
}
return changed ? next : cooldowns
}
+337
View File
@@ -0,0 +1,337 @@
import { useMemo, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import type { Iwt2Save } from '../save/iwt2Repository'
import type { Iwt2BossId } from '../content/bosses'
type Iwt2NavAction = {
key: string
label: string
detail?: string
value?: string
onConfirm: () => void
}
type Iwt2ScreenShellProps = {
title: string
eyebrow?: string
onBack: () => void
children: ReactNode
}
type Iwt2ActionListProps = {
actions: Iwt2NavAction[]
}
function useIwt2ActionList(actions: Iwt2NavAction[]) {
const [selectedIndex, setSelectedIndex] = useState(0)
const activeIndex = Math.min(selectedIndex, Math.max(0, actions.length - 1))
useGameAction((action, device) => {
if (device !== 'controller' || actions.length === 0) return
if (action === 'navigateUp' || action === 'navigateLeft') {
setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateDown' || action === 'navigateRight') {
setSelectedIndex((current) => Math.min(actions.length - 1, current + 1))
} else if (action === 'confirm') {
actions[activeIndex]?.onConfirm()
}
})
return { selectedIndex: activeIndex, setSelectedIndex }
}
const MODE_DESCRIPTIONS = {
Raids: 'Large-party boss fights will use the same 2D arena contract with larger rosters and raid-scale mechanics.',
Roguelike: 'Runs will chain rooms, rewards, healer builds, and IWT2-only inventory progression.',
PvP: 'Player-vs-player healing scenarios will reuse controller targeting and class identity without IWT1 state bleed.',
} satisfies Record<'Raids' | 'Roguelike' | 'PvP', string>
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
}
export function Iwt2ScreenShell({
children,
eyebrow = 'I Want To Heal 2',
onBack,
title,
}: Iwt2ScreenShellProps) {
useGameAction((action, device) => {
if (device === 'controller' && action === 'back') onBack()
})
return (
<section className="content-screen iwt2-screen-shell" data-game-nav-active="true">
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
</div>
{children}
</section>
)
}
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
return (
<div className="iwt2-action-list">
{actions.map((action, index) => (
<button
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
key={action.key}
onClick={action.onConfirm}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>
<strong>{action.label}</strong>
{action.detail && <small>{action.detail}</small>}
</span>
{action.value && <em>{action.value}</em>}
</button>
))}
</div>
)
}
export function Iwt2DungeonsScreen({
onBack,
onOpenBoss,
}: {
onBack: () => void
onOpenBoss: (bossId: Iwt2BossId) => void
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
onConfirm: () => onOpenBoss('bulldrome'),
},
{
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
onConfirm: () => onOpenBoss('yian-kut-ku'),
},
{
key: 'queue',
label: 'Dungeon Queue',
detail: 'Future matchmaking shell for multi-boss dungeon routes.',
value: 'Planned',
onConfirm: () => {},
},
], [onOpenBoss])
return (
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2ModePlaceholderScreen({
mode,
onBack,
}: {
mode: 'Raids' | 'Roguelike' | 'PvP'
onBack: () => void
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'status',
label: `${mode} Prototype`,
detail: MODE_DESCRIPTIONS[mode],
value: 'Shell',
onConfirm: () => {},
},
], [mode])
return (
<Iwt2ScreenShell title={mode} onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2HunterProfileScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const bulldromeKills = save.collectionLog.bossKills.bulldrome ?? 0
const yianKills = save.collectionLog.bossKills['yian-kut-ku'] ?? 0
const bulldromeDrops = save.collectionLog.dropsFound['raw-bulldrome-coin'] ?? 0
const yianDrops = save.collectionLog.dropsFound['yian-kut-ku-scale'] ?? 0
return (
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
<div className="iwt2-profile-grid">
<div className="iwt2-list-panel">
<div className="iwt2-list-row">
<strong>Name</strong>
<span>{save.character.name}</span>
</div>
<div className="iwt2-list-row">
<strong>Level</strong>
<span>{save.character.level}</span>
</div>
<div className="iwt2-list-row">
<strong>Experience</strong>
<span>{save.character.experience} XP</span>
</div>
</div>
<div className="iwt2-list-panel">
<div className="iwt2-list-row">
<strong>Bulldrome Kills</strong>
<span>{bulldromeKills}</span>
</div>
<div className="iwt2-list-row">
<strong>Raw Bulldrome Coins</strong>
<span>{bulldromeDrops}</span>
</div>
<div className="iwt2-list-row">
<strong>Yian Kut Ku Kills</strong>
<span>{yianKills}</span>
</div>
<div className="iwt2-list-row">
<strong>Yian Kut Ku Scales</strong>
<span>{yianDrops}</span>
</div>
<div className="iwt2-list-row">
<strong>IWT2 Inventory</strong>
<span>{save.inventory.length} slots</span>
</div>
</div>
</div>
</Iwt2ScreenShell>
)
}
export function Iwt2CustomizeCharacterScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'class',
label: 'Healer Style',
detail: 'Current vertical slice uses Field Medic abilities based on IWT1 dungeon spells.',
value: 'Field Medic',
onConfirm: () => {},
},
{
key: 'appearance',
label: 'Armor Palette',
detail: 'Character cosmetics will stay scoped to IWT2 save data.',
value: 'Default',
onConfirm: () => {},
},
{
key: 'name',
label: 'Character Name',
detail: 'Name editing shell for the IWT2 hunter profile.',
value: save.character.name,
onConfirm: () => {},
},
], [save.character.name])
return (
<Iwt2ScreenShell title="Customize Character" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2CloudSaveScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const savedAt = new Date(save.updatedAt).toLocaleString()
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'status',
label: 'Cloud Save Slot',
detail: 'Local IWT2 save is isolated and ready for cloud sync integration.',
value: 'Not linked',
onConfirm: () => {},
},
{
key: 'last-save',
label: 'Last Local Save',
detail: savedAt,
value: 'Local',
onConfirm: () => {},
},
], [savedAt])
return (
<Iwt2ScreenShell title="Cloud Save" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) {
const {
controllerIconStyle,
directPartyTargeting,
setControllerIconStyle,
setDirectPartyTargeting,
} = useInput()
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'direct-targeting',
label: 'Target Party Members With Bindings',
detail: 'When enabled, party frames show direct target button icons instead of D-pad cycling.',
value: directPartyTargeting ? 'On' : 'Off',
onConfirm: () => setDirectPartyTargeting(!directPartyTargeting),
},
...CONTROLLER_ICON_OPTIONS.map((style) => ({
key: `icons-${style}`,
label: `${CONTROLLER_ICON_LABELS[style]} Button Icons`,
detail: 'Changes controller glyphs shown on IWT2 frames and spell controls.',
value: controllerIconStyle === style ? 'Selected' : '',
onConfirm: () => setControllerIconStyle(style),
})),
], [
controllerIconStyle,
directPartyTargeting,
setControllerIconStyle,
setDirectPartyTargeting,
])
return (
<Iwt2ScreenShell title="Settings" onBack={onBack}>
<div className="iwt2-settings-panel">
<Iwt2ActionList actions={actions} />
<div className="iwt2-controller-preview" aria-label="Current controller icon style">
<span>{CONTROLLER_ICON_LABELS[controllerIconStyle]}</span>
<ControllerStylePreview iconStyle={controllerIconStyle} />
</div>
</div>
</Iwt2ScreenShell>
)
}
+646
View File
@@ -0,0 +1,646 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
import { separateCircles } from './collision'
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
const DEFAULT_ARENA_WIDTH = 960
const DEFAULT_ARENA_HEIGHT = 540
const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
type InitialPartyMember = {
id: Iwt2PartyEntityId
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
x: number
y: number
preferredOffset: { x: number; y: number }
decisionOffset: number
}
const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 },
{ id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 },
{ id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 },
{ id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 },
{ id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 },
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
boss: {
id: bossId,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
},
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
nextHazardId: 1,
events: [],
}
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
const step = Math.max(0, Math.min(dt, MAX_DT))
if (step <= 0) return { ...state, events: [...state.events] }
const inputMove = {
x: clampInputAxis(input.moveX),
y: clampInputAxis(input.moveY),
}
const baseState = {
...state,
time: state.time + step,
party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)),
events: [],
}
const separatedState = {
...baseState,
party: separatePartyFromBoss(baseState.party, baseState),
}
const bossResult = tickBoss(separatedState, step)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.boss,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId,
bossResult.nextProjectileId ?? state.nextProjectileId,
separatedState.bounds,
separatedState.time,
step,
)
const hazardResult = tickGroundHazards({
dt: step,
hazards: projectileResult.hazards,
party: projectileResult.party,
time: separatedState.time,
})
const damageResult = applyPartyAttacks(
hazardResult.party,
projectileResult.boss,
projectileResult.hostileAdds,
separatedState.time,
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
...separatedState,
boss: damageResult.boss,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty,
hostileAdds: damageResult.hostileAdds,
hazards: hazardResult.hazards,
projectiles: [...projectileResult.projectiles, ...damageResult.projectiles],
nextProjectileId: damageResult.nextProjectileId,
nextAddId: bossResult.nextAddId ?? state.nextAddId,
nextHazardId: projectileResult.nextHazardId,
nextEventId: state.nextEventId + nextEvents.length,
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
}
}
function tickPartyHotEffects(
party: Iwt2PartyEntityState[],
dt: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const events: Iwt2ArenaEvent[] = []
const nextParty = party.map((member) => {
if (member.hotEffects.length === 0) return member
let health = member.health
const hotEffects = member.hotEffects.flatMap((effect) => {
const remainingSeconds = effect.remainingSeconds - dt
if (remainingSeconds <= 0) return []
let nextTickInSeconds = effect.nextTickInSeconds - dt
if (member.health > 0 && nextTickInSeconds <= 0) {
const before = health
health = Math.min(member.maxHealth, health + effect.power)
const healed = health - before
if (healed > 0) {
events.push({
id: 0,
time,
type: 'partyHealed',
sourceId: 'player-healer',
targetId: member.id,
value: healed,
})
}
nextTickInSeconds += effect.tickIntervalSeconds
}
return [{
...effect,
nextTickInSeconds,
remainingSeconds,
}]
})
return {
...member,
health,
hotEffects,
}
})
return { party: nextParty, events }
}
function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member
return {
...member,
mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt),
}
})
}
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
return {
id: initial.id,
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
shield: 0,
mana: initial.classId === 'healer' ? 100 : 0,
maxMana: initial.classId === 'healer' ? 100 : 0,
damageDone: 0,
attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35,
castSecondsRemaining: 0,
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
decisionSecondsRemaining: initial.decisionOffset,
}
}
function createEmptyStatus(): Iwt2StatusState {
return {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
}
}
function clampInputAxis(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(-1, value))
}
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const position = separateCircles(
{ position: member.position, radius: member.radius },
{ position: state.boss.position, radius: state.boss.radius },
state.bounds,
)
return { ...member, position }
})
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
nextProjectileId: number,
bounds: Iwt2ArenaState['bounds'],
time: number,
dt: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextProjectileId: number
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBoss = boss
let nextParty = party
let nextHostileAdds = hostileAdds
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
const nextProjectiles: Iwt2ProjectileEntityState[] = []
for (const projectile of projectiles) {
if (projectile.owner === 'boss') {
const result = advanceBossProjectile({
bounds,
dt,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile,
time,
})
nextParty = result.party
nextHazards = result.hazards
nextHazardIdValue = result.nextHazardId
events.push(...result.events)
if (result.projectile) nextProjectiles.push(result.projectile)
continue
}
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
const nextPosition = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
const remainingSeconds = projectile.remainingSeconds - dt
const hitAdd = nextHostileAdds.find((add) => (
add.health > 0
&& distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius
))
if (remainingSeconds <= 0) continue
if (hitAdd) {
const damage = Math.min(projectile.damage, hitAdd.health)
nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
value: damage,
})
if (hitAdd.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
})
}
continue
}
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
const damage = Math.min(projectile.damage, nextBoss.health)
nextBoss = {
...nextBoss,
health: Math.max(0, nextBoss.health - damage),
}
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
value: damage,
})
if (nextBoss.health <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
})
}
continue
}
nextProjectiles.push({
...projectile,
position: nextPosition,
remainingSeconds,
})
}
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
nextProjectileId,
projectiles: nextProjectiles,
events,
}
}
function advanceBossProjectile({
bounds,
dt,
hazards,
nextHazardId,
party,
projectile,
time,
}: {
bounds: Iwt2ArenaState['bounds']
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
projectile: Iwt2ProjectileEntityState
time: number
}): {
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
projectile?: Iwt2ProjectileEntityState
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const remainingSeconds = projectile.remainingSeconds - dt
if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events }
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
let bounced = false
const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt)
const canBounce = bounceCooldownSeconds <= 0
let velocity = { ...projectile.velocity }
let position = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) {
velocity = { ...velocity, x: -velocity.x }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) {
velocity = { ...velocity, y: -velocity.y }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
const hitMember = canBounce
? nextParty.find((member) => (
member.health > 0
&& distanceVec2(position, member.position) <= projectile.radius + member.radius
))
: undefined
if (hitMember) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: hitMember.position,
radius: hitMember.radius + projectile.radius,
}, {
damage: projectile.damage,
sourceId: projectile.sourceId,
time,
})
nextParty = result.party
events.push(...result.events)
const away = normalizeVec2(subtractVec2(position, hitMember.position))
const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1))
const direction = away.x === 0 && away.y === 0 ? fallback : away
const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y))
velocity = scaleVec2(direction, speed)
position = clampVec2ToArena({
x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2),
y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2),
}, projectile.radius, bounds)
bounced = true
}
if (bounced) {
const puddle = addFirePuddle({
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: hitMember?.position ?? position,
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
sourceId: projectile.sourceId,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
const bouncesRemaining = bounced
? (projectile.bouncesRemaining ?? 0) - 1
: projectile.bouncesRemaining
if (bouncesRemaining !== undefined && bouncesRemaining < 0) {
return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events }
}
return {
events,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile: {
...projectile,
bouncesRemaining,
bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds,
position,
remainingSeconds,
velocity,
},
}
}
function applyPartyAttacks(
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
nextProjectileId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (!member.attackReady) return member
const direction = normalizeVec2(subtractVec2(target.position, member.position))
projectiles.push({
id: `projectile-${projectileId}`,
sourceId: member.id,
owner: 'party',
classId: member.classId,
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
color: metadata.accentColor,
position: {
x: member.position.x + direction.x * (member.radius + 4),
y: member.position.y + direction.y * (member.radius + 4),
},
velocity: scaleVec2(direction, metadata.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
remainingSeconds: 1.2,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
}
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: member.id,
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: member.id,
targetId: target.id,
})
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
})
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
nextProjectileId: projectileId,
events,
}
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return boss.health > 0 ? boss : undefined
}
function addDamageDone(
party: Iwt2PartyEntityState[],
sourceId: Iwt2EntityId,
damage: number,
): Iwt2PartyEntityState[] {
return party.map((member) => member.id === sourceId
? { ...member, damageDone: member.damageDone + damage }
: member)
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+148
View File
@@ -0,0 +1,148 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import {
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
tickIwt2Arena as tickCoreIwt2Arena,
} from './arena'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
} from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
export type Iwt2ArenaEntity = {
id: string
kind: Iwt2ArenaEntityKind
icon: string
color: string
x: number
y: number
radius: number
health: number
maxHealth: number
stunnedFor: number
}
export type Iwt2ArenaTelegraph =
| {
kind: 'charge'
x: number
y: number
width: number
height: number
active: boolean
}
| {
kind: 'slam'
x: number
y: number
radius: number
active: boolean
}
export type Iwt2ArenaState = Iwt2CoreArenaState & {
arena: {
width: number
height: number
}
entities: Iwt2ArenaEntity[]
telegraphs: Iwt2ArenaTelegraph[]
}
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
}
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
return {
...state,
arena: { ...state.bounds },
entities: [
...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss),
],
telegraphs: createTelegraphs(state.indicators),
}
}
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
return {
id: add.id,
kind: 'hostileAdd',
icon: 'v',
color: '#f0b84f',
x: add.position.x,
y: add.position.y,
radius: add.radius,
health: add.health,
maxHealth: add.maxHealth,
stunnedFor: 0,
}
}
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
kind: member.aiRole === 'player' ? 'player' : 'party',
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
}
}
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
kind: 'boss',
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
stunnedFor: 0,
}
}
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
if (indicator.kind === 'lane') {
return [{
active: indicator.phase === 'active',
height: indicator.width * 2,
kind: 'charge' as const,
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
x: Math.min(indicator.start.x, indicator.end.x),
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
}]
}
if (indicator.kind === 'circle') {
return [{
active: indicator.phase === 'active',
kind: 'slam' as const,
radius: indicator.radius,
x: indicator.position.x,
y: indicator.position.y,
}]
}
return []
})
}
+378
View File
@@ -0,0 +1,378 @@
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2BossTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds?: Iwt2HostileAddState[]
hazards?: Iwt2GroundHazardState[]
projectiles?: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId?: number
nextHazardId?: number
nextProjectileId?: number
}
export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
return tickBulldrome(state, dt)
}
export function tickBulldrome(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
const target = getBossTarget(state.party)
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
}
const wallContactSeconds = isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = { ...boss, wallContactSeconds }
if (boss.health <= 0 || !target) {
return withBulldromeIndicators({ boss: { ...boss, velocity: { x: 0, y: 0 } }, party, events })
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, BULLDROME_BOSS_METADATA.moveSpeed * 1.45 * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(nextPosition, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.2),
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeWindup') {
boss = {
...boss,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'charging' }
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'charging') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, BULLDROME_BOSS_METADATA.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(velocity, boss.facing),
chargeHitEntityIds: hitResult.hitEntityIds,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
const needsSlam = boss.chargeCount % 3 === 0
boss = {
...boss,
attackPhase: needsSlam ? 'slamWindup' : 'chargeRecover',
phaseSecondsRemaining: needsSlam ? BULLDROME_BOSS_METADATA.slamWindup : 0.45,
slamApplied: false,
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'slamWindup') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applySlam(party, boss, state.time + dt)
party = slamResult.party
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: 'slamRecover',
phaseSecondsRemaining: 0.5,
slamApplied: true,
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeRecover' || boss.attackPhase === 'slamRecover') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= 1.1) {
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.8,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
const charge = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: BULLDROME_BOSS_METADATA.chargeLength,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'chargeWindup',
phaseSecondsRemaining: BULLDROME_BOSS_METADATA.chargeWindup,
chargeCooldownRemaining: BULLDROME_BOSS_METADATA.chargeCooldown,
chargeCount: boss.chargeCount + 1,
chargeStart: { ...boss.position },
chargeEnd: charge.end,
chargeHitEntityIds: [],
slamApplied: false,
facing: charge.direction,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(state.nextEventId + events.length, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withBulldromeIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt, state.nextEventId + events.length)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (meleeResult.events.length > 0) return withBulldromeIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, BULLDROME_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withBulldromeIndicators({ boss, party, events })
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState) {
const margin = boss.radius + 10
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.58, y: state.bounds.height * 0.5 }
const awayFromWall = {
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
}
return {
x: Math.min(state.bounds.width - boss.radius, Math.max(boss.radius, center.x + awayFromWall.x * 70)),
y: Math.min(state.bounds.height - boss.radius, Math.max(boss.radius, center.y + awayFromWall.y * 54)),
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
firstEventId: number,
): Omit<Iwt2BossTickResult, 'indicators'> {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const nextParty = party.map((member) => {
if (member.id !== target.id) return member
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
})
const events = [
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
]
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
}
return {
boss: { ...boss, meleeCooldownRemaining: BULLDROME_BOSS_METADATA.meleeCooldown, velocity: { x: 0, y: 0 } },
party: nextParty,
events,
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'lane',
start,
end,
width: boss.radius,
},
{
damage: BULLDROME_BOSS_METADATA.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
time,
},
)
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applySlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'circle',
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
},
{
damage: BULLDROME_BOSS_METADATA.slamDamage,
knockdownSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
time,
},
)
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, BULLDROME_BOSS_METADATA.slamRadius),
...result.events,
],
}
}
function withBulldromeIndicators(
result: Omit<Iwt2BossTickResult, 'indicators'>,
): Iwt2BossTickResult {
return {
...result,
indicators: createBulldromeIndicators(result.boss),
}
}
function createBulldromeIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'chargeWindup'
|| boss.attackPhase === 'charging'
|| boss.attackPhase === 'chargeRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'charging' ? '#f05b4f' : '#f1c663',
end: boss.chargeEnd,
id: `${boss.id}:charge-lane`,
mechanicId: 'bulldrome-charge',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chargeWindup',
boss.attackPhase === 'charging',
),
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover') {
indicators.push(createCircleIndicator({
color: '#ff7f50',
id: `${boss.id}:slam-circle`,
mechanicId: 'bulldrome-slam',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'slamWindup',
boss.attackPhase === 'slamRecover',
),
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
sourceId: boss.id,
}))
}
return indicators
}
+46
View File
@@ -0,0 +1,46 @@
import type { Iwt2ArenaBounds, Iwt2Circle, Iwt2Vec2 } from './types'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export function circlesOverlap(a: Iwt2Circle, b: Iwt2Circle): boolean {
const radiusSum = a.radius + b.radius
return distanceVec2(a.position, b.position) <= radiusSum
}
export function distanceToSegment(point: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): number {
const segment = subtractVec2(end, start)
const segmentLengthSq = lengthSqVec2(segment)
if (segmentLengthSq <= 0.0001) return distanceVec2(point, start)
const pointOffset = subtractVec2(point, start)
const t = clamp(dotVec2(pointOffset, segment) / segmentLengthSq, 0, 1)
const projection = addVec2(start, scaleVec2(segment, t))
return distanceVec2(point, projection)
}
export function circleIntersectsSegment(
circle: Iwt2Circle,
start: Iwt2Vec2,
end: Iwt2Vec2,
width: number,
): boolean {
return distanceToSegment(circle.position, start, end) <= circle.radius + width
}
export function separateCircles(moving: Iwt2Circle, fixed: Iwt2Circle, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
const offset = subtractVec2(moving.position, fixed.position)
const minDistance = moving.radius + fixed.radius
const distanceSq = lengthSqVec2(offset)
if (distanceSq >= minDistance * minDistance) return moving.position
const direction = distanceSq <= 0.0001 ? { x: 1, y: 0 } : normalizeVec2(offset)
const resolved = addVec2(fixed.position, scaleVec2(direction, minDistance))
return clampVec2ToArena(resolved, moving.radius, bounds)
}
+162
View File
@@ -0,0 +1,162 @@
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
} from './mechanics'
const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
export function addFirePuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
if (overlappingIndex >= 0) {
return {
hazards: hazards.map((hazard, index) => index === overlappingIndex
? {
...hazard,
damage: Math.max(hazard.damage, damage),
position: {
x: (hazard.position.x + position.x) / 2,
y: (hazard.position.y + position.y) / 2,
},
radius: Math.max(hazard.radius, radius),
remainingSeconds: Math.max(hazard.remainingSeconds, duration),
}
: hazard),
nextHazardId,
}
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
nextHazardId: nextHazardId + 1,
}
}
export function tickGroundHazards({
dt,
hazards,
party,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
time: number
}): {
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextParty = party
const nextHazards: Iwt2GroundHazardState[] = []
for (const hazard of hazards) {
const remainingSeconds = hazard.remainingSeconds - dt
if (remainingSeconds <= 0) continue
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
nextHazards.push({
...hazard,
nextDamageAt,
remainingSeconds,
})
}
return {
events,
hazards: nextHazards,
party: nextParty,
}
}
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
sourceId: hazard.sourceId,
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
: hazard)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import type {
Iwt2ArenaEvent,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2PartyEntityState,
} from './types'
import { createArenaEvent } from './mechanics'
const HEALER_ID: Iwt2EntityId = 'player-healer'
const MAX_EVENTS = 80
const DEFAULT_HOT_SECONDS = 8
const DEFAULT_HOT_TICK_SECONDS = 1
export type Iwt2HealerCastResult<TState extends Iwt2ArenaState = Iwt2ArenaState> = {
cast: boolean
state: TState
}
export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
state: TState,
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2HealerCastResult<TState> {
if (state.boss.health <= 0) return { cast: false, state }
const healer = state.party.find((member) => member.id === HEALER_ID)
if (!healer || healer.health <= 0 || healer.mana < ability.manaCost) return { cast: false, state }
const targetIds = targetIdsForAbility(state.party, ability, selectedTargetId)
if (targetIds.length === 0) return { cast: false, state }
const events: Iwt2ArenaEvent[] = []
let eventId = state.nextEventId
const targetIdSet = new Set(targetIds)
const nextParty = state.party.map((member) => {
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
if (!targetIdSet.has(member.id)) return { ...member, mana }
const nextMember = applyAbilityToMember(member, ability)
const healed = Math.max(0, nextMember.health - member.health)
if (healed > 0) {
events.push(createArenaEvent(eventId, state.time, 'partyHealed', HEALER_ID, member.id, healed))
eventId += 1
}
return { ...nextMember, mana }
})
return {
cast: true,
state: {
...state,
party: nextParty,
nextEventId: eventId,
events: [...state.events, ...events].slice(-MAX_EVENTS),
} as TState,
}
}
function targetIdsForAbility(
party: Iwt2PartyEntityState[],
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2EntityId[] {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return []
if (ability.kind === 'group') {
return [...living]
.sort((a, b) => healthRatio(a) - healthRatio(b))
.slice(0, 4)
.map((member) => member.id)
}
const selected = living.find((member) => member.id === selectedTargetId)
return [selected?.id ?? living[0].id]
}
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
if (ability.kind === 'hot') {
return {
...healMember(member, ability.power),
hotEffects: [
...member.hotEffects.filter((effect) => effect.id !== ability.id),
{
id: ability.id,
label: ability.name,
power: Math.max(1, Math.round(ability.power * 0.5)),
remainingSeconds: DEFAULT_HOT_SECONDS,
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
},
],
}
}
if (ability.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, ability.power),
}
}
if (ability.kind === 'cleanse') {
return {
...healMember(member, ability.power),
status: {
...member.status,
knockedDownSeconds: 0,
stunnedSeconds: 0,
},
}
}
return healMember(member, ability.power)
}
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
return {
...member,
health: Math.min(member.maxHealth, member.health + amount),
}
}
function healthRatio(member: Iwt2PartyEntityState) {
return member.health / member.maxHealth
}
+67
View File
@@ -0,0 +1,67 @@
export {
createInitialIwt2ArenaState,
tickIwt2Arena,
} from './arena'
export {
circlesOverlap,
circleIntersectsSegment,
distanceToSegment,
separateCircles,
} from './collision'
export {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createConeIndicator,
createDonutIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
isIndicatorActive,
partyMemberIntersectsShape,
} from './mechanics'
export {
castIwt2HealerAbility,
} from './healing'
export {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
lengthVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type {
Iwt2ArenaBounds,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2BossAttackPhase,
Iwt2BossEntityState,
Iwt2Circle,
Iwt2EntityId,
Iwt2EntityKind,
Iwt2GroundHazardState,
Iwt2HotEffectState,
Iwt2HostileAddAttackPhase,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
+308
View File
@@ -0,0 +1,308 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaLaneIndicator,
Iwt2EntityId,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { circleIntersectsSegment } from './collision'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type Iwt2LinearMovementPlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
}
export type Iwt2PartyDamageEffect = {
sourceId: Iwt2EntityId
time: number
damage: number
damageEventType?: Iwt2ArenaEventType
stunSeconds?: number
knockdownSeconds?: number
statusEventType?: Iwt2ArenaEventType
excludedEntityIds?: Iwt2EntityId[]
}
export type Iwt2LaneHitShape = {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2CircleHitShape = {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape
export function createArenaEvent(
id: number,
time: number,
type: Iwt2ArenaEventType,
sourceId: Iwt2EntityId,
targetId?: Iwt2EntityId,
value?: number,
): Iwt2ArenaEvent {
return { id, time, type, sourceId, targetId, value }
}
export function createLinearMovementPlan({
bounds,
from,
length,
radius,
target,
}: {
bounds: Iwt2ArenaBounds
from: Iwt2Vec2
length: number
radius: number
target: Iwt2Vec2
}): Iwt2LinearMovementPlan {
const direction = normalizeVec2(subtractVec2(target, from))
const rawEnd = addVec2(from, scaleVec2(direction, length))
return {
direction,
end: clampVec2ToArena(rawEnd, radius, bounds),
}
}
export function applyPartyDamageInShape(
party: Iwt2PartyEntityState[],
shape: Iwt2HitShape,
effect: Iwt2PartyDamageEffect,
): {
party: Iwt2PartyEntityState[]
hitEntityIds: Iwt2EntityId[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const excluded = new Set(effect.excludedEntityIds ?? [])
const hitEntityIds: Iwt2EntityId[] = []
const stunSeconds = effect.stunSeconds ?? 0
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
const nextParty = party.map((member) => {
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
return member
}
hitEntityIds.push(member.id)
const shieldAbsorbed = Math.min(member.shield, effect.damage)
const healthDamage = effect.damage - shieldAbsorbed
const nextShield = member.shield - shieldAbsorbed
const nextHealth = Math.max(0, member.health - healthDamage)
events.push(createArenaEvent(
0,
effect.time,
effect.damageEventType ?? 'partyDamaged',
effect.sourceId,
member.id,
effect.damage,
))
if (stunSeconds > 0 || knockdownSeconds > 0) {
events.push(createArenaEvent(
0,
effect.time,
effect.statusEventType ?? 'partyStunned',
effect.sourceId,
member.id,
Math.max(stunSeconds, knockdownSeconds),
))
}
if (member.health > 0 && nextHealth <= 0) {
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
}
return {
...member,
health: nextHealth,
shield: nextShield,
status: {
...member.status,
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
knockedDownSeconds: Math.max(member.status.knockedDownSeconds, knockdownSeconds),
},
}
})
return { party: nextParty, hitEntityIds, events }
}
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
if (shape.kind === 'lane') {
return circleIntersectsSegment(
{ position: member.position, radius: member.radius },
shape.start,
shape.end,
shape.width,
)
}
return distanceVec2(member.position, shape.position) <= shape.radius + member.radius
}
export function createLaneIndicator({
color,
end,
id,
mechanicId,
phase,
sourceId,
start,
width,
}: {
color: string
end: Iwt2Vec2
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
sourceId: Iwt2EntityId
start: Iwt2Vec2
width: number
}): Iwt2ArenaLaneIndicator {
return {
color,
end: { ...end },
fillAlpha: phase === 'active' ? 0.18 : 0.11,
id,
kind: 'lane',
lineAlpha: phase === 'active' ? 0.8 : 0.45,
mechanicId,
phase,
sourceId,
start: { ...start },
width,
}
}
export function createCircleIndicator({
color,
id,
mechanicId,
phase,
position,
radius,
sourceId,
}: {
color: string
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
}): Iwt2ArenaCircleIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.18 : 0.12,
id,
kind: 'circle',
lineAlpha: phase === 'active' ? 0.75 : 0.55,
mechanicId,
phase,
position: { ...position },
radius,
sourceId,
}
}
export function createConeIndicator({
angleRadians,
color,
direction,
id,
mechanicId,
origin,
phase,
range,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
mechanicId: string
origin: Iwt2Vec2
phase: Iwt2ArenaIndicatorPhase
range: number
sourceId: Iwt2EntityId
}): Iwt2ArenaConeIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.18 : 0.1,
id,
kind: 'cone',
lineAlpha: phase === 'active' ? 0.72 : 0.48,
mechanicId,
origin: { ...origin },
phase,
range,
sourceId,
}
}
export function createDonutIndicator({
color,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
color: string
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaDonutIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.14 : 0.08,
id,
innerRadius,
kind: 'donut',
lineAlpha: phase === 'active' ? 0.76 : 0.5,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
): Iwt2ArenaIndicatorPhase {
if (active) return 'active'
if (windup) return 'windup'
return 'recover'
}
export function isIndicatorActive(indicator: Iwt2ArenaIndicator): boolean {
return indicator.phase === 'active'
}
+230
View File
@@ -0,0 +1,230 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickPartyMember(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
if (member.health <= 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
y: member.position.y + velocity.y * dt,
},
member.radius,
state.bounds,
)
return {
...member,
position,
velocity,
facing: withFallbackFacing(velocity, member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
}
}
const dangerDestination = getDangerAvoidancePosition(member, state)
const attackTarget = getPriorityAttackTarget(member, state)
const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0
&& !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
return {
...member,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
status,
}
}
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
return {
...member,
position,
velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
decisionSecondsRemaining: decisionSecondsRemaining <= 0
? nextDecisionInterval(member)
: decisionSecondsRemaining,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
}
function getPartyDesiredPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position
return {
x: anchor.x + member.preferredOffset.x + drift.x,
y: anchor.y + member.preferredOffset.y + drift.y,
}
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
}
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
}
}
return null
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
for (const hazard of state.hazards) {
const offset = subtractVec2(member.position, hazard.position)
const distance = distanceVec2(member.position, hazard.position)
const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
const urgency = dangerRadius - distance
escape = addVec2(escape, scaleVec2(away, urgency))
maxNeededDistance = Math.max(maxNeededDistance, urgency)
}
if (maxNeededDistance <= 0) return null
const direction = withFallbackFacing(escape, {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
)
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = state.hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return state.boss.health > 0 ? state.boss : undefined
}
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
const boss = state.boss
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment)
const offset = subtractVec2(position, boss.chargeStart)
const along = dotVec2(offset, segment) / lengthSq
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
const distance = distanceVec2(position, closest)
const width = boss.radius + 22
return {
direction,
inside: along >= -0.08 && along <= 1.08 && distance <= width,
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
}
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17
return {
x: Math.sin(time * 0.7 + seed) * 16,
y: Math.cos(time * 0.9 + seed) * 14,
}
}
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
if (member.aiRole === 'tank') return 0.34
if (member.aiRole === 'ranged') return 0.48
if (member.aiRole === 'flanker') return 0.28
return 0.38
}
+254
View File
@@ -0,0 +1,254 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
export type Iwt2Vec2 = {
x: number
y: number
}
export type Iwt2ArenaBounds = {
width: number
height: number
}
export type Iwt2PartyEntityId =
| 'player-healer'
| 'paladin-tank'
| 'ranger'
| 'mage'
| 'rogue'
| 'warrior'
export type Iwt2BossEntityId = Iwt2BossId
export type Iwt2EntityId = Iwt2PartyEntityId | Iwt2BossEntityId | string
export type Iwt2EntityKind = 'party' | 'boss' | 'hostileAdd'
export type Iwt2PartyAiRole = 'player' | 'tank' | 'ranged' | 'flanker' | 'melee'
export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
}
export type Iwt2HotEffectState = {
id: string
label: string
power: number
remainingSeconds: number
tickIntervalSeconds: number
nextTickInSeconds: number
}
export type Iwt2PartyEntityState = {
id: Iwt2PartyEntityId
kind: 'party'
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
shield: number
mana: number
maxMana: number
damageDone: number
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
status: Iwt2StatusState
hotEffects: Iwt2HotEffectState[]
preferredOffset: Iwt2Vec2
decisionSecondsRemaining: number
}
export type Iwt2ProjectileEntityState = {
id: string
sourceId: Iwt2EntityId
owner: 'party' | 'boss'
classId?: Iwt2PlayerClassId
projectileKind: 'arrow' | 'fireball' | 'magic'
color: string
position: Iwt2Vec2
velocity: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
bouncesRemaining?: number
bounceCooldownSeconds?: number
}
export type Iwt2BossAttackPhase =
| 'idle'
| 'chargeWindup'
| 'charging'
| 'chargeRecover'
| 'slamWindup'
| 'slamRecover'
| 'relocating'
| 'fireballWindup'
| 'fireballRecover'
| 'birdSummonWindup'
| 'birdSummonRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
| 'flightWindup'
| 'flying'
| 'flightRecover'
export type Iwt2BossEntityState = {
id: Iwt2BossEntityId
kind: 'boss'
bossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
attackPhase: Iwt2BossAttackPhase
phaseSecondsRemaining: number
chargeStart: Iwt2Vec2
chargeEnd: Iwt2Vec2
chargeHitEntityIds: Iwt2EntityId[]
slamApplied: boolean
wallContactSeconds: number
relocateTarget: Iwt2Vec2
fireballCooldownRemaining: number
fireballTarget: Iwt2Vec2
birdWaveThresholdsTriggered: number[]
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
addKind: 'yian-bird'
sourceBossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
damageDone: number
attackCooldownRemaining: number
flightCooldownRemaining: number
attackPhase: Iwt2HostileAddAttackPhase
phaseSecondsRemaining: number
flightStart: Iwt2Vec2
flightEnd: Iwt2Vec2
flightHitEntityIds: Iwt2EntityId[]
}
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
fadeSeconds: number
nextDamageAt: number
}
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
| 'bossChargeStart'
| 'bossChargeHit'
| 'bossSlam'
| 'entityDefeated'
export type Iwt2ArenaEvent = {
id: number
time: number
type: Iwt2ArenaEventType
sourceId: Iwt2EntityId
targetId?: Iwt2EntityId
value?: number
}
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
export type Iwt2ArenaIndicatorBase = {
id: string
mechanicId: string
sourceId: Iwt2EntityId
phase: Iwt2ArenaIndicatorPhase
color: string
fillAlpha?: number
lineAlpha?: number
}
export type Iwt2ArenaLaneIndicator = Iwt2ArenaIndicatorBase & {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2ArenaCircleIndicator = Iwt2ArenaIndicatorBase & {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2ArenaConeIndicator = Iwt2ArenaIndicatorBase & {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
kind: 'donut'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
time: number
bounds: Iwt2ArenaBounds
party: Iwt2PartyEntityState[]
projectiles: Iwt2ProjectileEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState
indicators: Iwt2ArenaIndicator[]
nextEventId: number
nextProjectileId: number
nextAddId: number
nextHazardId: number
events: Iwt2ArenaEvent[]
}
export type Iwt2ArenaInput = {
moveX: number
moveY: number
}
export type Iwt2Circle = {
position: Iwt2Vec2
radius: number
}
+60
View File
@@ -0,0 +1,60 @@
import type { Iwt2ArenaBounds, Iwt2Vec2 } from './types'
export const IWT2_ZERO_VECTOR: Iwt2Vec2 = { x: 0, y: 0 }
export function addVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x + b.x, y: a.y + b.y }
}
export function subtractVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x - b.x, y: a.y - b.y }
}
export function scaleVec2(vector: Iwt2Vec2, scale: number): Iwt2Vec2 {
return { x: vector.x * scale, y: vector.y * scale }
}
export function dotVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return a.x * b.x + a.y * b.y
}
export function lengthSqVec2(vector: Iwt2Vec2): number {
return dotVec2(vector, vector)
}
export function lengthVec2(vector: Iwt2Vec2): number {
return Math.sqrt(lengthSqVec2(vector))
}
export function distanceVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return lengthVec2(subtractVec2(a, b))
}
export function normalizeVec2(vector: Iwt2Vec2): Iwt2Vec2 {
const length = lengthVec2(vector)
if (length <= 0.0001) return { ...IWT2_ZERO_VECTOR }
return { x: vector.x / length, y: vector.y / length }
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function clampVec2ToArena(position: Iwt2Vec2, radius: number, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: clamp(position.x, radius, bounds.width - radius),
y: clamp(position.y, radius, bounds.height - radius),
}
}
export function moveToward(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = lengthVec2(offset)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
export function withFallbackFacing(nextFacing: Iwt2Vec2, fallback: Iwt2Vec2): Iwt2Vec2 {
if (lengthSqVec2(nextFacing) <= 0.0001) return { ...fallback }
return normalizeVec2(nextFacing)
}
+707
View File
@@ -0,0 +1,707 @@
import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
partyMemberIntersectsShape,
} from './mechanics'
import { addFirePuddle } from './hazards'
import {
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2YianTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId: number
nextHazardId: number
nextProjectileId: number
}
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
const YIAN_CENTER_CHARGE_SPEED = 430
export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let party = state.party
let hazards = state.hazards
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
let nextProjectileId = state.nextProjectileId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const birdResult = tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = birdResult.party
hazards = birdResult.hazards
nextHazardId = birdResult.nextHazardId
events.push(...birdResult.events)
let hostileAdds = birdResult.hostileAdds
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_SPEED * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing)
projectiles.push({
id: `boss-projectile-${nextProjectileId}`,
sourceId: boss.id,
owner: 'boss',
projectileKind: 'fireball',
color: '#ff8b2b',
position: {
x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
},
velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!),
radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!,
damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!,
remainingSeconds: 7,
bouncesRemaining: YIAN_FIREBALL_BOUNCES,
})
nextProjectileId += 1
boss = {
...boss,
attackPhase: 'fireballRecover',
phaseSecondsRemaining: 0.35,
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (shouldYianRelocate(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextThreshold = nextBirdWaveThreshold(boss)
if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) {
const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold)
hostileAdds = [...hostileAdds, ...spawn.hostileAdds]
nextAddId = spawn.nextAddId
boss = {
...boss,
attackPhase: 'birdSummonRecover',
birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold],
phaseSecondsRemaining: 0.55,
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.fireballCooldownRemaining <= 0) {
if (!isYianAtCenter(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
boss = {
...boss,
attackPhase: 'fireballWindup',
fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!,
fireballTarget: { ...target.position },
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
boss = meleeResult.boss
events.push(...meleeResult.events)
if (meleeResult.events.length > 0) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextPosition = clampVec2ToArena(
moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
function shouldYianRelocate(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): boolean {
return (
boss.position.x < YIAN_SAFE_WALL_MARGIN
|| boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN
|| boss.position.y < YIAN_SAFE_WALL_MARGIN
|| boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN
)
}
function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE
}
function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
return clampVec2ToArena({
x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190),
y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96),
}, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds)
}
function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
hostileAdds: Iwt2HostileAddState[]
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const hostileAdds: Iwt2HostileAddState[] = []
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
for (const add of state.hostileAdds) {
if (add.health <= 0) continue
let nextAdd = {
...add,
attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt),
flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt),
}
if (nextAdd.attackPhase === 'flightWindup') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = {
...nextAdd,
attackPhase: 'flying',
position: { ...nextAdd.flightStart },
velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!),
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flying') {
const previousPosition = nextAdd.position
const nextPosition = moveToward(
nextAdd.position,
nextAdd.flightEnd,
YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt,
)
const shape = {
kind: 'lane' as const,
start: previousPosition,
end: nextPosition,
width: nextAdd.radius,
}
const hitMembers = nextParty.filter((member) => (
!nextAdd.flightHitEntityIds.includes(member.id)
&& partyMemberIntersectsShape(member, shape)
))
const hitResult = applyPartyDamageInShape(nextParty, shape, {
damage: BIRD_FLIGHT_DAMAGE,
excludedEntityIds: nextAdd.flightHitEntityIds,
knockdownSeconds: 0,
sourceId: nextAdd.id,
stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
for (const member of hitMembers) {
const puddle = addFirePuddle({
damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!,
duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: member.position,
radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!,
sourceId: nextAdd.id,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
nextAdd = {
...nextAdd,
flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds],
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0),
facing: { x: 1, y: 0 },
}
if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) {
nextAdd = {
...nextAdd,
attackPhase: 'flightRecover',
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
phaseSecondsRemaining: 0.4,
velocity: { x: 0, y: 0 },
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flightRecover') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.flightCooldownRemaining <= 0) {
hostileAdds.push(startBirdFlight(nextAdd, state, time))
continue
}
const target = getNearestPartyMember(nextParty, nextAdd.position)
if (!target) {
hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } })
continue
}
const nextPosition = clampVec2ToArena(
moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt),
nextAdd.radius,
state.bounds,
)
nextAdd = {
...nextAdd,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing),
}
if (
nextAdd.attackCooldownRemaining <= 0
&& distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE
) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: nextAdd.position,
radius: nextAdd.radius + BIRD_MELEE_RANGE,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!,
sourceId: nextAdd.id,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN }
}
hostileAdds.push(nextAdd)
}
return {
events,
hazards: nextHazards,
hostileAdds,
nextHazardId: nextHazardIdValue,
party: nextParty,
}
}
function spawnBirdWave(
state: Iwt2ArenaState,
nextAddId: number,
sourceBossId: Iwt2BossId,
threshold: number,
): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } {
const hostileAdds: Iwt2HostileAddState[] = []
let nextId = nextAddId
for (let index = 0; index < BIRD_COUNT; index += 1) {
const y = birdLaneY(state, index, threshold * 100)
const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius!
const flightStart = { x: -radius - 8, y }
const flightEnd = { x: state.bounds.width + radius + 8, y }
hostileAdds.push({
id: `yian-bird-${nextId}`,
kind: 'hostileAdd',
addKind: 'yian-bird',
sourceBossId,
position: { ...flightStart },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius,
health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
damageDone: 0,
attackCooldownRemaining: 0,
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
})
nextId += 1
}
return { hostileAdds, nextAddId: nextId }
}
function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState {
const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time)
const flightStart = { x: -add.radius - 8, y }
const flightEnd = { x: state.bounds.width + add.radius + 8, y }
return {
...add,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
velocity: { x: 0, y: 0 },
}
}
function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number {
const laneHeight = state.bounds.height / (BIRD_COUNT + 1)
const base = laneHeight * (index + 1)
const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34
return clamp(base + wobble, 58, state.bounds.height - 58)
}
function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined {
return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds
?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold))
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined {
let nearest: Iwt2PartyEntityState | undefined
let nearestDistance = Number.POSITIVE_INFINITY
for (const member of party) {
if (member.health <= 0) continue
const distance = distanceVec2(member.position, position)
if (distance < nearestDistance) {
nearest = member
nearestDistance = distance
}
}
return nearest
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withYianIndicators(result: Omit<Iwt2YianTickResult, 'indicators'>): Iwt2YianTickResult {
return {
...result,
indicators: createYianIndicators(result.boss, result.hostileAdds),
}
}
function createYianIndicators(
boss: Iwt2BossEntityState,
hostileAdds: Iwt2HostileAddState[],
): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'relocating') {
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: boss.chargeEnd,
id: `${boss.id}:center-charge`,
mechanicId: 'yian-center-charge',
phase: 'active',
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'fireballWindup') {
indicators.push(createCircleIndicator({
color: '#ff8b2b',
id: `${boss.id}:fireball-cast`,
mechanicId: 'yian-fireball-cast',
phase: 'windup',
position: boss.position,
radius: boss.radius + 18,
sourceId: boss.id,
}))
indicators.push(createLaneIndicator({
color: '#ffb347',
end: boss.fireballTarget,
id: `${boss.id}:fireball-line`,
mechanicId: 'yian-fireball-line',
phase: 'windup',
sourceId: boss.id,
start: boss.position,
width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4,
}))
}
for (const add of hostileAdds) {
if (
add.attackPhase !== 'flightWindup'
&& add.attackPhase !== 'flying'
&& add.attackPhase !== 'flightRecover'
) {
continue
}
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: add.flightEnd,
id: `${add.id}:flight-lane`,
mechanicId: 'yian-bird-flight',
phase: indicatorPhaseFromAttack(
add.attackPhase === 'flightWindup',
add.attackPhase === 'flying',
),
sourceId: add.id,
start: add.flightStart,
width: add.radius,
}))
}
return indicators
}
+1 -1
View File
@@ -1 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
+7 -1
View File
@@ -6905,5 +6905,11 @@
"full_run": []
}
}
]
],
"hunterStats": {
"bossKills": {},
"bossPets": {},
"pvpMatchesPlayed": 0,
"pvpMatchesWon": 0
}
}
+30
View File
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
quantityAfter: number
}
export type PetAward = {
encounterId: number
petName: string
quantity: number
duplicate: boolean
quantityAfter: number
}
export type LootRoll = {
encounterId: number
encounterName: string
@@ -157,6 +165,7 @@ export type LootRoll = {
awarded: boolean
duplicate: boolean
quantityAfter: number
petAwarded?: PetAward | null
}
export type Dungeon = {
@@ -232,6 +241,12 @@ export type CharacterProfile = {
craftingRecipes: CraftingRecipe[]
gearUpgradePaths: GearUpgradePath[]
dungeons: Dungeon[]
hunterStats: {
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
}
export type Account = {
@@ -280,6 +295,7 @@ export type DungeonReward = {
glyph: string
}>
bonusItem: BonusItem | null
petAwarded?: PetAward | null
profile: CharacterProfile
}
@@ -365,6 +381,20 @@ export async function completeRoguelike(
)
}
export async function recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}> {
return activeGameRepository().recordBossKill(encounterId, options)
}
export async function recordPvpMatch(won: boolean): Promise<CharacterProfile> {
return activeGameRepository().recordPvpMatch(won)
}
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
return activeGameRepository().allocateTalent(talentId)
}