Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d65bc381 | ||
|
|
bdae0007a1 | ||
|
|
c052b086f8 | ||
|
|
c2a14bac7d | ||
|
|
0d269a6041 | ||
|
|
99e97e5208 | ||
|
|
a849cd2c69 | ||
|
|
a121b2ef74 | ||
|
|
b7cb572631 | ||
|
|
c6e2c61d0a | ||
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a | ||
|
|
f6d408f594 |
@@ -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.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 83
|
||||
versionName "1.1.4"
|
||||
versionCode 99
|
||||
versionName "1.1.20"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.view.Display;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
|
||||
final class AndroidDisplayPerformance {
|
||||
|
||||
private static final float TARGET_REFRESH_RATE = 60.0f;
|
||||
|
||||
private AndroidDisplayPerformance() {}
|
||||
|
||||
static void preferBatteryRefreshRate(Window window, Display display) {
|
||||
if (window == null || display == null) return;
|
||||
Display.Mode currentMode = display.getMode();
|
||||
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
|
||||
if (selectedMode == null) return;
|
||||
|
||||
WindowManager.LayoutParams attributes = window.getAttributes();
|
||||
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
|
||||
attributes.preferredDisplayModeId = selectedMode.getModeId();
|
||||
window.setAttributes(attributes);
|
||||
}
|
||||
|
||||
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
|
||||
Display.Mode selectedMode = null;
|
||||
float selectedScore = Float.MAX_VALUE;
|
||||
|
||||
for (Display.Mode mode : display.getSupportedModes()) {
|
||||
if (
|
||||
currentMode != null
|
||||
&& (
|
||||
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|
||||
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
float refreshRate = mode.getRefreshRate();
|
||||
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
|
||||
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
|
||||
if (score < selectedScore) {
|
||||
selectedMode = mode;
|
||||
selectedScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedMode;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,45 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageInfo;
|
||||
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 long DPAD_THROTTLE_MS = 220;
|
||||
private long lastDpadDispatchAt = 0;
|
||||
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 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) {
|
||||
clearWebViewServiceWorkers();
|
||||
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
|
||||
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
|
||||
super.onCreate(savedInstanceState);
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
|
||||
if (bridge != null) {
|
||||
bridge.getWebView().clearCache(true);
|
||||
if (shouldClearWebViewCache) {
|
||||
bridge.getWebView().clearCache(true);
|
||||
markWebViewCacheClearedForCurrentVersion();
|
||||
}
|
||||
}
|
||||
loadIntentUrl();
|
||||
}
|
||||
@@ -35,7 +54,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enableImmersiveMode();
|
||||
if (hasFocus) {
|
||||
enableImmersiveMode();
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(
|
||||
getWindow(),
|
||||
getWindowManager().getDefaultDisplay()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -74,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
deleteIfExists(new File(webViewData, "Service Worker"));
|
||||
}
|
||||
|
||||
private boolean shouldClearWebViewCacheForCurrentVersion() {
|
||||
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
|
||||
!= currentVersionCode();
|
||||
}
|
||||
|
||||
private void markWebViewCacheClearedForCurrentVersion() {
|
||||
performancePreferences()
|
||||
.edit()
|
||||
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private SharedPreferences performancePreferences() {
|
||||
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private long currentVersionCode() {
|
||||
try {
|
||||
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
return packageInfo.getLongVersionCode();
|
||||
}
|
||||
return packageInfo.versionCode;
|
||||
} catch (PackageManager.NameNotFoundException exception) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteIfExists(File file) {
|
||||
if (!file.exists()) return;
|
||||
if (file.isDirectory()) {
|
||||
@@ -94,24 +147,120 @@ 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<>();
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||
"Button14",
|
||||
"Button15"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
|
||||
"Button12",
|
||||
"Button13"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
leftStickX,
|
||||
"Axis0-",
|
||||
"Axis0+"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
leftStickY,
|
||||
"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 void dispatchNativeControllerMotion(float x, float y) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
|
||||
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -125,10 +274,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";
|
||||
|
||||
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
|
||||
private final class TopDisplayPresentation extends Presentation {
|
||||
|
||||
private final String initialUrl;
|
||||
private final Display display;
|
||||
|
||||
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
||||
super(context, display);
|
||||
this.display = display;
|
||||
this.initialUrl = initialUrl;
|
||||
}
|
||||
|
||||
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
|
||||
|
||||
FrameLayout container = new FrameLayout(getContext());
|
||||
container.setBackgroundColor(Color.BLACK);
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
import type { Spell } from '../game'
|
||||
|
||||
export function cooldownNow() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export function cooldownRemaining(
|
||||
cooldowns: Record<string, number>,
|
||||
spellId: string,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
|
||||
}
|
||||
|
||||
export function hasActiveCooldowns(
|
||||
cooldowns: Record<string, number>,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
|
||||
}
|
||||
|
||||
export function pruneExpiredCooldowns(
|
||||
cooldowns: Record<string, number>,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
let changed = false
|
||||
const nextCooldowns: Record<string, number> = {}
|
||||
for (const id in cooldowns) {
|
||||
if (cooldowns[id] <= now) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
nextCooldowns[id] = cooldowns[id]
|
||||
}
|
||||
return changed ? nextCooldowns : cooldowns
|
||||
}
|
||||
|
||||
export function reduceCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spellId: string,
|
||||
seconds: number,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
const readyAtMs = cooldowns[spellId]
|
||||
if (!readyAtMs || readyAtMs <= now) return cooldowns
|
||||
const nextReadyAtMs = readyAtMs - seconds * 1000
|
||||
const nextCooldowns = { ...cooldowns }
|
||||
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
|
||||
else nextCooldowns[spellId] = nextReadyAtMs
|
||||
return nextCooldowns
|
||||
}
|
||||
|
||||
export function canCastSpell(
|
||||
spell: Spell,
|
||||
resource: number,
|
||||
cooldowns: Record<string, number>,
|
||||
resourceCost: number,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0
|
||||
return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
|
||||
}
|
||||
|
||||
export function putSpellOnCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spell: Spell,
|
||||
cooldownMultiplier = 1,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
|
||||
if (cooldownMs <= 0) {
|
||||
const nextCooldowns = { ...cooldowns }
|
||||
delete nextCooldowns[spell.id]
|
||||
return nextCooldowns
|
||||
}
|
||||
return {
|
||||
...cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier,
|
||||
[spell.id]: now + cooldownMs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,133 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
||||
}
|
||||
}
|
||||
|
||||
function applyIwt2AdminOverrides(
|
||||
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
||||
overrides: Iwt2BalanceOverrides,
|
||||
): Record<Iwt2BossId, Iwt2BossMetadata> {
|
||||
return (Object.keys(metadata) as Iwt2BossId[]).reduce((nextMetadata, bossId) => ({
|
||||
...nextMetadata,
|
||||
[bossId]: {
|
||||
...metadata[bossId],
|
||||
...overrides.bosses?.[bossId],
|
||||
},
|
||||
}), {} as Record<Iwt2BossId, Iwt2BossMetadata>)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
completeDungeon,
|
||||
completeRoguelike,
|
||||
loadProfile,
|
||||
recordBossKill,
|
||||
type DungeonReward,
|
||||
rollEncounterLoot,
|
||||
type LootRoll,
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
spellPowerMultiplier,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
|
||||
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
|
||||
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
|
||||
@@ -58,7 +59,11 @@ import {
|
||||
appendCombatLog,
|
||||
type BasicFloatingCombatText,
|
||||
} from '../combat/combatPresentation'
|
||||
import { canCastSpell } from '../combat/spellCasting'
|
||||
import {
|
||||
canCastSpell,
|
||||
pruneExpiredCooldowns,
|
||||
reduceCooldown,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
applySpellEffectProfile,
|
||||
buildSpellTargetPlan,
|
||||
@@ -69,9 +74,11 @@ import {
|
||||
useInput,
|
||||
} from '../input'
|
||||
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { ResourceBar, SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
@@ -365,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)
|
||||
@@ -510,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(
|
||||
@@ -521,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)
|
||||
@@ -552,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
|
||||
@@ -668,10 +706,10 @@ export function CombatScreen({
|
||||
})
|
||||
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
|
||||
resourceSpentRef.current += effectiveCost
|
||||
const nextCooldowns = { ...current.cooldowns }
|
||||
let nextCooldowns = current.cooldowns
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||
const radiance = spellByName.get('Radiance')
|
||||
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
||||
if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
|
||||
}
|
||||
setCombat(applyCastStateUpdate({
|
||||
current,
|
||||
@@ -855,7 +893,7 @@ export function CombatScreen({
|
||||
const runCombatTick = useCallback(() => {
|
||||
const current = combatRef.current
|
||||
const nextElapsedTicks = current.elapsedTicks + 1
|
||||
const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS))
|
||||
const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
|
||||
let nextResource = regenerateResource(current.resource, 2.4, maxResource)
|
||||
|
||||
const living = current.party.filter((member) => member.health > 0)
|
||||
@@ -985,6 +1023,7 @@ export function CombatScreen({
|
||||
requestLootRoll(encounter.id, rollIndex)
|
||||
}
|
||||
}
|
||||
recordRoguelikeBossKill(encounter)
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
setCombat({
|
||||
@@ -1094,6 +1133,7 @@ export function CombatScreen({
|
||||
maxResource,
|
||||
gameClass.resourceName,
|
||||
requestLootRoll,
|
||||
recordRoguelikeBossKill,
|
||||
profile.character.name,
|
||||
setCombat,
|
||||
startPart,
|
||||
@@ -1161,17 +1201,17 @@ export function CombatScreen({
|
||||
}).reverse(),
|
||||
[enemyCount, enemyHealth, encounter.maxHealth],
|
||||
)
|
||||
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => {
|
||||
const spell = spellByKey.get(String(slotIndex + 1))
|
||||
return abilityId && spell
|
||||
? {
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
||||
slotIndex,
|
||||
remaining: cooldowns[spell.id] ?? 0,
|
||||
}
|
||||
: null
|
||||
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey])
|
||||
const spellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
||||
[freeCastReady, roguelikeUpgradeCounts],
|
||||
)
|
||||
const spellSlots = useSpellSlots({
|
||||
spells,
|
||||
abilitySlots: profile.abilitySlots,
|
||||
cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: spellSlotCost,
|
||||
})
|
||||
const dualScreenState = useMemo(() => buildCombatDualScreenState({
|
||||
difficultyName: difficulty.name,
|
||||
dungeonName: dungeon.name,
|
||||
@@ -1230,9 +1270,9 @@ export function CombatScreen({
|
||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
||||
data-combat-active={paused ? 'false' : 'true'}
|
||||
<main
|
||||
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
||||
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
||||
>
|
||||
{!dualScreenEnabled && <header className="topbar">
|
||||
<div>
|
||||
@@ -1266,13 +1306,13 @@ export function CombatScreen({
|
||||
<div className="hard-enemy-bars">
|
||||
{enemyHealthSegments.map((segment) => (
|
||||
<div className="bar enemy-health" key={segment.index}>
|
||||
<span style={{ width: `${segment.percent}%` }} />
|
||||
<span style={barFillStyle(segment.percent)} />
|
||||
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||
<div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
|
||||
)}
|
||||
<p>{encounter.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
memberHotEffects,
|
||||
} from '../combat/rules'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { barFillStyle } from './barStyles'
|
||||
|
||||
export type FloatingCombatText = {
|
||||
id: number
|
||||
@@ -108,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 (
|
||||
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
|
||||
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
||||
</div>
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
|
||||
<span style={barFillStyle((member.health / maxHealth) * 100)} />
|
||||
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
|
||||
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
|
||||
@@ -9,13 +9,15 @@ 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'
|
||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { focusFirstControl, useGameAction, useInput } from '../input'
|
||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import {
|
||||
@@ -37,10 +39,14 @@ import {
|
||||
spellExtraTargets,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick } from '../combat/combatEngine'
|
||||
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
|
||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||
import {
|
||||
pruneExpiredCooldowns,
|
||||
reduceCooldown,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
nextPvpRoguelikeStage,
|
||||
resolvePvpRoguelikeCombatOutcome,
|
||||
@@ -146,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',
|
||||
@@ -350,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>())
|
||||
@@ -398,12 +414,16 @@ export function PvPRoguelikeScreen({
|
||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
||||
const playerSpellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
|
||||
)
|
||||
const playerSpellSlots = useSpellSlots({
|
||||
spells: starterSpells,
|
||||
cooldowns: playerSide.cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||
[cpuSide.buffs, selfBuffChoicesCatalog],
|
||||
@@ -528,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(
|
||||
@@ -644,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
|
||||
@@ -677,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)
|
||||
@@ -701,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
|
||||
@@ -880,9 +909,9 @@ export function PvPRoguelikeScreen({
|
||||
damageReductionTargets: new Set<string>(),
|
||||
groupTargets,
|
||||
}
|
||||
const nextCooldowns = { ...current.cooldowns }
|
||||
let nextCooldowns = current.cooldowns
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
||||
nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
|
||||
}
|
||||
return applyPvpSpellCast({
|
||||
current,
|
||||
@@ -994,7 +1023,7 @@ export function PvPRoguelikeScreen({
|
||||
...side,
|
||||
party: nextParty,
|
||||
resource: regenerateResource(side.resource, 2.4, maxResource),
|
||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
||||
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||
}
|
||||
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
||||
@@ -1006,6 +1035,7 @@ export function PvPRoguelikeScreen({
|
||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setOverlaySelectedIndex(0)
|
||||
setStatus('upgrade-choice')
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
|
||||
|
||||
@@ -1086,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())
|
||||
@@ -1279,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
|
||||
@@ -1401,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>
|
||||
)}
|
||||
|
||||
@@ -1428,13 +1608,13 @@ export function PvPRoguelikeScreen({
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
<span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1468,12 +1648,12 @@ export function PvPRoguelikeScreen({
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
<span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1499,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}
|
||||
@@ -1518,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>
|
||||
@@ -1533,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>
|
||||
@@ -1549,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>
|
||||
@@ -1562,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>
|
||||
@@ -1570,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>
|
||||
)}
|
||||
|
||||
@@ -7,13 +7,15 @@ 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'
|
||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
||||
@@ -31,10 +33,13 @@ import {
|
||||
spellPowerMultiplier,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick } from '../combat/combatEngine'
|
||||
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||
import {
|
||||
pruneExpiredCooldowns,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
appendCombatLog,
|
||||
STADIUM_COMBAT_LOG_LIMIT,
|
||||
@@ -386,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('')
|
||||
@@ -400,12 +406,16 @@ export function PvpStadiumScreen({
|
||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
||||
const playerSpellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||
[playerBuffCounts, playerSide.freeCastReady],
|
||||
)
|
||||
const playerSpellSlots = useSpellSlots({
|
||||
spells: starterSpells,
|
||||
cooldowns: playerSide.cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||
[buffCatalog, cpuSide.buffs],
|
||||
@@ -496,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)
|
||||
@@ -532,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)
|
||||
@@ -790,7 +802,7 @@ export function PvpStadiumScreen({
|
||||
...side,
|
||||
party: nextParty,
|
||||
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
||||
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||
survivalSeconds: nextSurvival,
|
||||
dampeningPercent,
|
||||
}
|
||||
@@ -873,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>({
|
||||
@@ -918,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>({
|
||||
@@ -1168,16 +1193,16 @@ export function PvpStadiumScreen({
|
||||
</section>
|
||||
|
||||
<section className="dual-top-spell-strip">
|
||||
{starterSpells.map((spell, slotIndex) => {
|
||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
||||
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
|
||||
{playerSpellSlots.map((spell, slotIndex) => {
|
||||
if (!spell) return null
|
||||
const remaining = spell.remaining
|
||||
const percent = remaining > 0
|
||||
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
|
||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
|
||||
key={spell.id}
|
||||
onClick={() => castPlayerSpell(spell)}
|
||||
type="button"
|
||||
@@ -1192,7 +1217,7 @@ export function PvpStadiumScreen({
|
||||
<div className="dual-top-resource">
|
||||
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
|
||||
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1226,7 +1251,7 @@ export function PvpStadiumScreen({
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid pvp-party-grid">
|
||||
@@ -1256,7 +1281,7 @@ export function PvpStadiumScreen({
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid pvp-party-grid">
|
||||
@@ -1280,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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,21 +18,62 @@ 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,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
combatTouchLocked,
|
||||
beginCapture,
|
||||
cancelCapture,
|
||||
resetBindings,
|
||||
setControllerIconStyle,
|
||||
setDirectPartyTargeting,
|
||||
setCombatTouchLocked,
|
||||
} = useInput()
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
@@ -45,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
|
||||
@@ -77,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
|
||||
@@ -88,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>
|
||||
@@ -128,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>
|
||||
@@ -174,20 +367,40 @@ 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' : ''} ${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'}
|
||||
</button>
|
||||
<div className="controller-icon-options">
|
||||
<span>Controller Icons</span>
|
||||
{(['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} />
|
||||
@@ -210,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
|
||||
@@ -228,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>
|
||||
@@ -250,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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
||||
import type { ControllerIconStyle } from '../input'
|
||||
import type { Spell } from '../game'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { barFillStyle } from './barStyles'
|
||||
|
||||
export type SpellSlot = (Spell & {
|
||||
cost: number
|
||||
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
|
||||
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle,
|
||||
resourceName,
|
||||
disabled,
|
||||
focusable = true,
|
||||
onCast,
|
||||
emptyKeyPrefix = 'empty',
|
||||
}: {
|
||||
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle: ControllerIconStyle
|
||||
resourceName: string
|
||||
disabled?: boolean
|
||||
focusable?: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
emptyKeyPrefix?: string
|
||||
}) {
|
||||
@@ -60,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"
|
||||
>
|
||||
@@ -94,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>
|
||||
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
canCast: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
className?: string
|
||||
focusable?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -118,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}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
function clampScale(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
export function barFillStyle(
|
||||
percent: number,
|
||||
origin: 'left' | 'right' = 'left',
|
||||
): CSSProperties {
|
||||
return {
|
||||
transform: `scaleX(${clampScale(percent / 100)})`,
|
||||
transformOrigin: `${origin} center`,
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
dispatchExternalGameAction,
|
||||
type ControllerIconStyle,
|
||||
type InputAction,
|
||||
useGameAction,
|
||||
} from './input'
|
||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
import { PartyMemberFrame } from './components/PartyFrames'
|
||||
import { barFillStyle } from './components/barStyles'
|
||||
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||
@@ -47,6 +49,11 @@ export type DualScreenCombatState = {
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
opponentEnemyHealth?: number
|
||||
opponentArena?: {
|
||||
bounds: { width: number, height: number }
|
||||
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
|
||||
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
|
||||
}
|
||||
opponentResource?: number
|
||||
opponentMaxResource?: number
|
||||
opponentResourceName?: string
|
||||
@@ -82,7 +89,7 @@ export type DualScreenCombatState = {
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
|
||||
title: string
|
||||
subtitle: string
|
||||
summary?: string
|
||||
@@ -95,14 +102,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
|
||||
@@ -148,6 +175,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',
|
||||
@@ -349,6 +394,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 () => {
|
||||
@@ -365,9 +413,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()
|
||||
@@ -377,13 +469,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(() => {
|
||||
@@ -401,6 +501,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">
|
||||
@@ -435,6 +543,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">
|
||||
@@ -448,7 +592,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>
|
||||
@@ -480,7 +627,7 @@ export function DualScreenBottomDisplay() {
|
||||
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||
</div>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||
<span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
|
||||
</div>
|
||||
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
|
||||
<>
|
||||
@@ -489,13 +636,55 @@ export function DualScreenBottomDisplay() {
|
||||
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
|
||||
</div>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${Math.max(0, (state.opponentResource / state.opponentMaxResource) * 100)}%` }} />
|
||||
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{state.opponentArena && (
|
||||
<section className="dual-opponent-arena" aria-label="Opponent arena">
|
||||
<div
|
||||
className="dual-opponent-arena-field"
|
||||
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
|
||||
>
|
||||
{state.opponentArena.bosses.map((boss) => (
|
||||
<div
|
||||
className="dual-opponent-arena-entity boss"
|
||||
key={boss.id}
|
||||
style={{
|
||||
backgroundColor: boss.color,
|
||||
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||
}}
|
||||
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
|
||||
>
|
||||
{boss.icon}
|
||||
</div>
|
||||
))}
|
||||
{state.opponentArena.party.map((member) => (
|
||||
<div
|
||||
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
|
||||
key={member.id}
|
||||
style={{
|
||||
backgroundColor: member.color,
|
||||
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||
opacity: member.health > 0 ? 1 : 0.35,
|
||||
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||
}}
|
||||
>
|
||||
{member.icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||
{state.opponentParty.map((member) => (
|
||||
<PartyMemberFrame
|
||||
@@ -527,7 +716,7 @@ export function DualScreenBottomDisplay() {
|
||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -648,6 +837,7 @@ export function DualScreenTopCombat({
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
data-controller-nav={state.opponentParty ? 'skip' : undefined}
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
@@ -678,13 +868,23 @@ export function DualScreenTopCombat({
|
||||
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
||||
</div>
|
||||
<div className="bar enemy-health">
|
||||
<span style={{ width: `${enemyPercent}%` }} />
|
||||
<span style={barFillStyle(enemyPercent)} />
|
||||
</div>
|
||||
<p>{state.encounterDescription}</p>
|
||||
{state.opponentParty && (
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!state.opponentParty && <p>{state.encounterDescription}</p>}
|
||||
</div>
|
||||
{state.opponentParty && (
|
||||
<div className="dual-top-cooldowns" aria-label="Cooldowns">
|
||||
{spellButtons}
|
||||
<div className="dual-top-side-status">
|
||||
<div className="dual-top-cooldowns" aria-label="Cooldowns">
|
||||
{spellButtons}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -720,7 +920,7 @@ export function DualScreenTopCombat({
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
|
||||
|
||||
export function useCooldownClock(
|
||||
cooldowns: Record<string, number>,
|
||||
active: boolean,
|
||||
intervalMs = 100,
|
||||
) {
|
||||
const [now, setNow] = useState(cooldownNow)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
|
||||
|
||||
let timer = 0
|
||||
let startTimer = 0
|
||||
const tick = () => {
|
||||
const nextNow = cooldownNow()
|
||||
setNow(nextNow)
|
||||
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
|
||||
window.clearInterval(timer)
|
||||
timer = 0
|
||||
}
|
||||
}
|
||||
|
||||
startTimer = window.setTimeout(() => {
|
||||
tick()
|
||||
timer = window.setInterval(tick, intervalMs)
|
||||
}, 0)
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
if (timer) window.clearInterval(timer)
|
||||
}
|
||||
}, [active, cooldowns, intervalMs])
|
||||
|
||||
return now
|
||||
}
|
||||
@@ -15,41 +15,63 @@ type FloatingCombatTextOptions = {
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
|
||||
|
||||
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
||||
durationMs = 900,
|
||||
}: FloatingCombatTextOptions = {}) {
|
||||
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
|
||||
const nextId = useRef(1)
|
||||
const expirationsRef = useRef(new Map<number, number>())
|
||||
const cleanupTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearCleanupTimer = useCallback(() => {
|
||||
if (cleanupTimerRef.current === null) return
|
||||
window.clearTimeout(cleanupTimerRef.current)
|
||||
cleanupTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const scheduleCleanup = useCallback(() => {
|
||||
function scheduleNext() {
|
||||
clearCleanupTimer()
|
||||
let nextExpiry = Infinity
|
||||
for (const expiresAt of expirationsRef.current.values()) {
|
||||
nextExpiry = Math.min(nextExpiry, expiresAt)
|
||||
}
|
||||
if (!Number.isFinite(nextExpiry)) return
|
||||
|
||||
cleanupTimerRef.current = window.setTimeout(() => {
|
||||
cleanupTimerRef.current = null
|
||||
const now = performance.now()
|
||||
let removed = false
|
||||
for (const [id, expiresAt] of expirationsRef.current) {
|
||||
if (expiresAt > now) continue
|
||||
expirationsRef.current.delete(id)
|
||||
removed = true
|
||||
}
|
||||
if (removed) {
|
||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||
}
|
||||
scheduleNext()
|
||||
}, Math.max(0, nextExpiry - performance.now()))
|
||||
}
|
||||
|
||||
scheduleNext()
|
||||
}, [clearCleanupTimer])
|
||||
|
||||
const clearFloatingTexts = useCallback(() => {
|
||||
clearCleanupTimer()
|
||||
expirationsRef.current.clear()
|
||||
setFloatingTexts([])
|
||||
}, [])
|
||||
}, [clearCleanupTimer])
|
||||
|
||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||
if (entry.value <= 0) return
|
||||
const id = nextId.current++
|
||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||
}, [durationMs])
|
||||
scheduleCleanup()
|
||||
}, [durationMs, scheduleCleanup])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const now = performance.now()
|
||||
let removed = false
|
||||
for (const [id, expiresAt] of expirationsRef.current) {
|
||||
if (expiresAt > now) continue
|
||||
expirationsRef.current.delete(id)
|
||||
removed = true
|
||||
}
|
||||
if (!removed) return
|
||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
|
||||
|
||||
const floatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(floatingTexts),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Spell } from '../game'
|
||||
import type { SpellSlot } from '../components/SpellBars'
|
||||
import { cooldownRemaining } from '../combat/spellCasting'
|
||||
import { useCooldownClock } from './useCooldownClock'
|
||||
|
||||
type UseSpellSlotsOptions = {
|
||||
spells: readonly Spell[]
|
||||
cooldowns: Record<string, number>
|
||||
active: boolean
|
||||
cost: (spell: Spell) => number
|
||||
abilitySlots?: readonly (number | null)[]
|
||||
}
|
||||
|
||||
export function useSpellSlots({
|
||||
spells,
|
||||
cooldowns,
|
||||
active,
|
||||
cost,
|
||||
abilitySlots,
|
||||
}: UseSpellSlotsOptions) {
|
||||
const now = useCooldownClock(cooldowns, active)
|
||||
|
||||
return useMemo<SpellSlot[]>(() => {
|
||||
const slotCount = abilitySlots?.length ?? spells.length
|
||||
return Array.from({ length: slotCount }, (_, slotIndex) => {
|
||||
if (abilitySlots && !abilitySlots[slotIndex]) return null
|
||||
const spell = spells[slotIndex]
|
||||
if (!spell) return null
|
||||
return {
|
||||
...spell,
|
||||
cost: cost(spell),
|
||||
slotIndex,
|
||||
remaining: cooldownRemaining(cooldowns, spell.id, now),
|
||||
}
|
||||
})
|
||||
}, [abilitySlots, cooldowns, cost, now, spells])
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
|
||||
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
|
||||
color: #f4eed8;
|
||||
font-family: 'VT323', Consolas, monospace;
|
||||
--body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||
--pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||
font-family: var(--body-font);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -37,6 +41,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'toggleTouchLock',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
@@ -66,6 +71,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
toggleSpeed: 'Toggle 2x Speed',
|
||||
toggleTouchLock: 'Toggle Combat Touch Lock',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
@@ -93,6 +99,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
toggleSpeed: 'Backquote',
|
||||
toggleTouchLock: 'F7',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
@@ -115,9 +122,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button10',
|
||||
toggleTargetGroup: 'Button6',
|
||||
targetParty6: 'Button6',
|
||||
toggleTargetGroup: 'Button8',
|
||||
toggleSpeed: 'Button11',
|
||||
toggleTouchLock: 'Button10',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
@@ -126,10 +134,22 @@ 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 NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
|
||||
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 NATIVE_STICK_TIMEOUT_MS = 140
|
||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
|
||||
let lastControllerFocus: HTMLElement | null = null
|
||||
|
||||
@@ -144,11 +164,13 @@ type InputContextValue = {
|
||||
lastDevice: InputDevice
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
combatTouchLocked: boolean
|
||||
beginCapture: (device: InputDevice, action: InputAction) => void
|
||||
cancelCapture: () => void
|
||||
resetBindings: (device: InputDevice) => void
|
||||
setControllerIconStyle: (style: ControllerIconStyle) => void
|
||||
setDirectPartyTargeting: (enabled: boolean) => void
|
||||
setCombatTouchLocked: (locked: boolean) => void
|
||||
}
|
||||
|
||||
const InputContext = createContext<InputContextValue | null>(null)
|
||||
@@ -187,6 +209,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
if (savedController?.targetParty6 === 'Button11') {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
if (
|
||||
savedController?.targetParty6 === undefined
|
||||
|| savedController.targetParty6 === 'Button10'
|
||||
) {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
if (
|
||||
savedController?.toggleTargetGroup === undefined
|
||||
|| savedController.toggleTargetGroup === 'Button6'
|
||||
) {
|
||||
controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup
|
||||
}
|
||||
if (savedController?.toggleTouchLock === undefined) {
|
||||
controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock
|
||||
}
|
||||
return {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
@@ -201,15 +238,18 @@ function loadPreferences() {
|
||||
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
directPartyTargeting?: boolean
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +263,7 @@ function bindingGroup(action: InputAction) {
|
||||
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
|
||||
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
|
||||
if (action === 'pause') return 'pause'
|
||||
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
|
||||
return 'navigation'
|
||||
}
|
||||
|
||||
@@ -231,6 +272,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')
|
||||
@@ -239,10 +290,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) {
|
||||
@@ -252,12 +315,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)) {
|
||||
@@ -266,16 +337,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
|
||||
@@ -286,21 +375,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)
|
||||
}
|
||||
@@ -313,6 +429,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',
|
||||
@@ -408,6 +536,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
|
||||
}
|
||||
@@ -428,6 +560,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const [preferences, setPreferences] = useState(loadPreferences)
|
||||
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
const [keyboardShift, setKeyboardShift] = useState(false)
|
||||
const [touchLockMessage, setTouchLockMessage] = useState('')
|
||||
const bindingsRef = useRef(bindings)
|
||||
const preferencesRef = useRef(preferences)
|
||||
const captureRef = useRef(capture)
|
||||
@@ -435,6 +568,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
|
||||
const touchLockMessageTimerRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -481,22 +615,58 @@ 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 (action.startsWith('navigate')) {
|
||||
if (uiOverlay || !combatActive) moveFocus(action)
|
||||
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
dispatchGameAction(action, device)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'toggleTouchLock') {
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
combatTouchLocked: !current.combatTouchLocked,
|
||||
}))
|
||||
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||
const nextLocked = !preferencesRef.current.combatTouchLocked
|
||||
setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
|
||||
touchLockMessageTimerRef.current = window.setTimeout(() => {
|
||||
setTouchLockMessage('')
|
||||
}, 1400)
|
||||
} else if (action.startsWith('navigate')) {
|
||||
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()
|
||||
@@ -512,9 +682,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) => {
|
||||
@@ -528,12 +696,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',
|
||||
@@ -551,8 +719,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'toggleTouchLock',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'toggleTouchLock',
|
||||
'pause',
|
||||
'toggleSpeed',
|
||||
'ability1',
|
||||
@@ -568,18 +738,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,
|
||||
)
|
||||
@@ -618,15 +788,18 @@ 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) => {
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
if (event.pointerType !== 'touch') {
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
}
|
||||
const target = event.target
|
||||
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 })
|
||||
@@ -645,10 +818,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||
}, [dispatchControllerToken])
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let focusFrame = 0
|
||||
const ensureFocus = () => {
|
||||
focusFrame = 0
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
if (hasDedicatedGameNavigation()) return
|
||||
const candidates = focusableElements()
|
||||
const activeControl = currentFocusableControl(candidates)
|
||||
if (
|
||||
@@ -663,22 +843,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheduleEnsureFocus = () => {
|
||||
if (focusFrame) return
|
||||
focusFrame = window.requestAnimationFrame(ensureFocus)
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
scheduleEnsureFocus()
|
||||
})
|
||||
observer.observe(document.getElementById('root') ?? document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
|
||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
return () => observer.disconnect()
|
||||
scheduleEnsureFocus()
|
||||
return () => {
|
||||
if (focusFrame) window.cancelAnimationFrame(focusFrame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let timer = 0
|
||||
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
|
||||
previousTokensRef.current = new Set()
|
||||
repeatRef.current = {}
|
||||
return undefined
|
||||
}
|
||||
const nextDelay = () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return GAMEPAD_COMBAT_POLL_MS
|
||||
}
|
||||
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
|
||||
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
|
||||
}
|
||||
@@ -705,11 +900,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -735,7 +930,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
window.removeEventListener('gamepadconnected', onGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
|
||||
}
|
||||
}, [assignBinding, dispatchControllerToken])
|
||||
}, [assignBinding, capture, dispatchControllerToken])
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBlockTouch = () => (
|
||||
Capacitor.isNativePlatform()
|
||||
&& preferencesRef.current.combatTouchLocked
|
||||
&& isCombatActive()
|
||||
&& !hasUiOverlay()
|
||||
)
|
||||
const blockTouch = (event: Event) => {
|
||||
if (
|
||||
typeof PointerEvent !== 'undefined'
|
||||
&& event instanceof PointerEvent
|
||||
&& event.pointerType !== 'touch'
|
||||
) return
|
||||
if (!shouldBlockTouch()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const options = { capture: true, passive: false }
|
||||
document.addEventListener('pointerdown', blockTouch, options)
|
||||
document.addEventListener('pointerup', blockTouch, options)
|
||||
document.addEventListener('touchstart', blockTouch, options)
|
||||
document.addEventListener('touchmove', blockTouch, options)
|
||||
document.addEventListener('touchend', blockTouch, options)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', blockTouch, options)
|
||||
document.removeEventListener('pointerup', blockTouch, options)
|
||||
document.removeEventListener('touchstart', blockTouch, options)
|
||||
document.removeEventListener('touchmove', blockTouch, options)
|
||||
document.removeEventListener('touchend', blockTouch, options)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const contextValue = useMemo<InputContextValue>(() => ({
|
||||
bindings,
|
||||
@@ -743,6 +970,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
lastDevice,
|
||||
controllerIconStyle: preferences.controllerIconStyle,
|
||||
directPartyTargeting: preferences.directPartyTargeting,
|
||||
combatTouchLocked: preferences.combatTouchLocked,
|
||||
beginCapture: (device, action) => setCapture({ device, action }),
|
||||
cancelCapture: () => setCapture(null),
|
||||
resetBindings: (device) => setBindings((current) => ({
|
||||
@@ -757,6 +985,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
...current,
|
||||
directPartyTargeting,
|
||||
})),
|
||||
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
|
||||
...current,
|
||||
combatTouchLocked,
|
||||
})),
|
||||
}), [bindings, capture, lastDevice, preferences])
|
||||
|
||||
function typeKeyboardKey(key: string) {
|
||||
@@ -821,10 +1053,101 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{touchLockMessage && (
|
||||
<div className="combat-touch-lock-status" aria-live="polite">
|
||||
{touchLockMessage}
|
||||
</div>
|
||||
)}
|
||||
</InputContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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>()
|
||||
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||
let nativeStickUpdatedAt = 0
|
||||
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 native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
|
||||
? applyStickDeadzone(nativeStick.x, nativeStick.y)
|
||||
: { x: 0, y: 0 }
|
||||
const keyboard = keyboardVector()
|
||||
movementRef.current = Math.hypot(native.x, native.y) > 0
|
||||
? native
|
||||
: Math.hypot(stick.x, stick.y) > 0
|
||||
? stick
|
||||
: keyboard
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
}
|
||||
const onNativeMotion = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
|
||||
nativeStick = {
|
||||
x: Number(detail.x) || 0,
|
||||
y: Number(detail.y) || 0,
|
||||
}
|
||||
nativeStickUpdatedAt = performance.now()
|
||||
document.documentElement.dataset.inputDevice = 'controller'
|
||||
}
|
||||
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(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
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')
|
||||
@@ -852,7 +1175,5 @@ export function dispatchExternalGameAction(
|
||||
action: InputAction,
|
||||
device: InputDevice,
|
||||
) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
dispatchGameAction(action, device)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGameAction } from '../../input'
|
||||
import { getGameMode } from '../../gameRepository'
|
||||
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
|
||||
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenSetupPublisher,
|
||||
useDualScreenWorkshopPublisher,
|
||||
type DualScreenSetupState,
|
||||
type DualScreenWorkshopState,
|
||||
} from '../../dualScreen'
|
||||
import { BossArenaScreen } from './screens/BossArenaScreen'
|
||||
import {
|
||||
Iwt2CloudSaveScreen,
|
||||
Iwt2CustomizeCharacterScreen,
|
||||
Iwt2DungeonsScreen,
|
||||
Iwt2HunterProfileScreen,
|
||||
Iwt2ModeScreen,
|
||||
Iwt2RoguelikeScreen,
|
||||
Iwt2RoguelikeUpgradeScreen,
|
||||
Iwt2SettingsScreen,
|
||||
} from './screens/Iwt2ShellScreens'
|
||||
import {
|
||||
loadIwt2Save,
|
||||
writeIwt2Save,
|
||||
type Iwt2Save,
|
||||
} from './save/iwt2Repository'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import {
|
||||
buildIwt2OpponentDebuffChoices,
|
||||
buildIwt2SelfBuffChoices,
|
||||
IWT2_REVIVE_PARTY_CHOICE,
|
||||
type Iwt2RoguelikeChoice,
|
||||
type Iwt2RoguelikeContentType,
|
||||
type Iwt2RoguelikeOpponentDebuffId,
|
||||
type Iwt2RoguelikeSelfBuffId,
|
||||
type Iwt2RoguelikeVariant,
|
||||
} from './content/roguelike'
|
||||
|
||||
type Iwt2Screen =
|
||||
| 'menu'
|
||||
| 'arena'
|
||||
| 'cloud-save'
|
||||
| 'dungeons'
|
||||
| 'raids'
|
||||
| 'roguelike'
|
||||
| 'roguelike-arena'
|
||||
| 'roguelike-upgrade'
|
||||
| 'hunter-profile'
|
||||
| 'customize-character'
|
||||
| 'settings'
|
||||
|
||||
const IWT2_MENU_COLUMNS = 4
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
|
||||
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
|
||||
const MENU_ITEMS: Array<{
|
||||
screen: Iwt2Screen
|
||||
title: string
|
||||
description: string
|
||||
glyph: string
|
||||
}> = [
|
||||
{
|
||||
screen: 'cloud-save',
|
||||
title: 'Backup Slot',
|
||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
||||
glyph: 'C',
|
||||
},
|
||||
{
|
||||
screen: 'dungeons',
|
||||
title: 'Dungeons',
|
||||
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
|
||||
glyph: 'D',
|
||||
},
|
||||
{
|
||||
screen: 'raids',
|
||||
title: 'Raids',
|
||||
description: 'Open raid assignments built from active IWT2 boss mechanics.',
|
||||
glyph: 'R',
|
||||
},
|
||||
{
|
||||
screen: 'roguelike',
|
||||
title: 'Roguelike',
|
||||
description: 'Draft upgrades through escalating random encounters.',
|
||||
glyph: 'L',
|
||||
},
|
||||
{
|
||||
screen: 'roguelike',
|
||||
title: 'PvP',
|
||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||
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: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||
glyph: 'K',
|
||||
},
|
||||
{
|
||||
screen: 'settings',
|
||||
title: 'Settings',
|
||||
description: 'IWT2 targeting preference and controller icon style.',
|
||||
glyph: 'S',
|
||||
},
|
||||
]
|
||||
|
||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
writeIwt2Save(save)
|
||||
}, [save])
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
}, [])
|
||||
|
||||
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
||||
[save, screen, selectedBossId],
|
||||
)
|
||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||
[save, screen],
|
||||
)
|
||||
|
||||
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
|
||||
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (screen !== 'menu' || device !== 'controller') return
|
||||
if (action === 'back') {
|
||||
onBackToGameSelect()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openMenuItem(MENU_ITEMS[selectedIndex])
|
||||
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))
|
||||
}
|
||||
})
|
||||
|
||||
function openMenuItem(item: (typeof MENU_ITEMS)[number]) {
|
||||
if (item.title === 'PvP') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
if (item.title === 'Roguelike') {
|
||||
setRoguelikeVariant('pve')
|
||||
setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current)
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}
|
||||
|
||||
if (screen === 'arena') {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossIds={roguelikeRun.bossIds}
|
||||
bossId={selectedBossId}
|
||||
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
|
||||
roguelikeRun={{
|
||||
buffs: roguelikeRun.buffs,
|
||||
contentType: roguelikeRun.contentType,
|
||||
debuffs: roguelikeRun.debuffs,
|
||||
onVictory: () => {
|
||||
setRoguelikeRun((current) => current
|
||||
? {
|
||||
...current,
|
||||
...buildRoguelikeChoices(save, current.variant),
|
||||
}
|
||||
: current)
|
||||
setScreen('roguelike-upgrade')
|
||||
},
|
||||
stage: roguelikeRun.stage,
|
||||
variant: roguelikeRun.variant,
|
||||
}}
|
||||
save={save}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2RoguelikeUpgradeScreen
|
||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||
contentType={roguelikeRun.contentType}
|
||||
debuffChoices={roguelikeRun.debuffChoices}
|
||||
selfChoices={roguelikeRun.selfChoices}
|
||||
stage={roguelikeRun.stage}
|
||||
variant={roguelikeRun.variant}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onChoose={(buffId, debuffId) => {
|
||||
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'dungeons') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2DungeonsScreen
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Dungeon')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2RoguelikeScreen
|
||||
contentType={roguelikeContentType}
|
||||
variant={roguelikeVariant}
|
||||
onBack={() => setScreen('menu')}
|
||||
onContentTypeChange={setRoguelikeContentType}
|
||||
onStart={() => {
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
queueMessage={pvpQueueMessage}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'raids') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2ModeScreen
|
||||
mode="Raids"
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Raid')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
/>
|
||||
</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')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'cloud-save') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CloudSaveScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</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"
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
key={`${item.screen}-${item.title}`}
|
||||
onClick={() => openMenuItem(item)}
|
||||
onPointerDown={() => setSelectedIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
|
||||
function startIwt2RoguelikeRun() {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
if (roguelikeVariant !== 'pvp') {
|
||||
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||
return
|
||||
}
|
||||
const startStage = 1
|
||||
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||
contentType: roguelikeContentType,
|
||||
startStage,
|
||||
gameMode: getGameMode(),
|
||||
liveMatchActive: () => false,
|
||||
onSearching: setPvpQueueMessage,
|
||||
onCpuMatch: (_difficulty, message) => {
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
onLiveMatch: (...liveMatchArgs) => {
|
||||
const message = liveMatchArgs[2]
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
messages: {
|
||||
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
|
||||
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
|
||||
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
|
||||
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
||||
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function beginIwt2RoguelikeArena(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
) {
|
||||
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function createRoguelikeRun(
|
||||
save: Iwt2Save,
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
...buildRoguelikeChoices(save, variant),
|
||||
stage: 1,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||
return {
|
||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||
debuffChoices: variant === 'pvp'
|
||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||
if (buffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
buffs,
|
||||
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
|
||||
if (debuffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
debuffs,
|
||||
buildIwt2OpponentDebuffChoices(abilities),
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function applyRoguelikeChoice(
|
||||
run: Iwt2RoguelikeRunState,
|
||||
buffId: Iwt2RoguelikeSelfBuffId,
|
||||
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
|
||||
save: Iwt2Save,
|
||||
): Iwt2RoguelikeRunState {
|
||||
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
|
||||
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
|
||||
? {
|
||||
buffs: run.buffs,
|
||||
debuffs: nextDebuffs.slice(1),
|
||||
}
|
||||
: {
|
||||
buffs: [...run.buffs, buffId],
|
||||
debuffs: nextDebuffs,
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
...nextBase,
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
stage: run.stage + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||
return chooseRunChoices(pool, 2)
|
||||
}
|
||||
|
||||
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||
const pool = [...items]
|
||||
const choices: T[] = []
|
||||
while (pool.length > 0 && choices.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
const [choice] = pool.splice(index, 1)
|
||||
if (choice) choices.push(choice)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function buildIwt2SetupDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
selectedBossId: Iwt2BossId,
|
||||
save: Iwt2Save,
|
||||
): DualScreenSetupState | null {
|
||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||
const raid = screen === 'raids'
|
||||
return {
|
||||
contentType: raid ? 'raid' : 'dungeon',
|
||||
description: raid
|
||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
||||
experience: 125,
|
||||
initials: boss.icon,
|
||||
itemLevel: save.character.level,
|
||||
stats: {
|
||||
damage: `${boss.meleeDamage}`,
|
||||
health: `${boss.maxHealth}`,
|
||||
loot: 'IWT2',
|
||||
xp: '125',
|
||||
},
|
||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||
}
|
||||
}
|
||||
|
||||
function buildIwt2WorkshopDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
save: Iwt2Save,
|
||||
): DualScreenWorkshopState | null {
|
||||
if (screen === 'hunter-profile') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
glyph: 'H',
|
||||
meta: `${save.character.experience} XP`,
|
||||
status: `Level ${save.character.level}`,
|
||||
title: save.character.name,
|
||||
},
|
||||
{
|
||||
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
|
||||
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
|
||||
status: 'Class',
|
||||
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
||||
},
|
||||
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
||||
glyph: IWT2_BOSS_METADATA[bossId].icon,
|
||||
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
|
||||
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
|
||||
title: IWT2_BOSS_METADATA[bossId].name,
|
||||
})),
|
||||
],
|
||||
mode: 'collection',
|
||||
subtitle: 'IWT2 inventory and collection log',
|
||||
summary: `${save.inventory.length} inventory slots`,
|
||||
title: 'Hunter Profile',
|
||||
}
|
||||
}
|
||||
|
||||
if (screen === 'customize-character') {
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
return {
|
||||
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
|
||||
glyph: ability.icon,
|
||||
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
|
||||
status: `Slot ${ability.slot}`,
|
||||
title: ability.name,
|
||||
})),
|
||||
mode: 'class',
|
||||
subtitle: healer.description,
|
||||
summary: healer.name,
|
||||
title: 'Customize Character',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
|
||||
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
||||
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
||||
if (bossId === 'khezu') return 'khezu-pearl'
|
||||
return 'raw-bulldrome-coin'
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import { loadIwt2Save } from './save/iwt2Repository'
|
||||
|
||||
export function Iwt2BottomDisplay() {
|
||||
const {
|
||||
bindings,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const activeBindings = lastDevice === 'controller'
|
||||
? bindings.controller
|
||||
: DEFAULT_BINDINGS.controller
|
||||
const save = loadIwt2Save()
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
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>{healer.name}</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 (
|
||||
<div className="dual-control-chip" key={target.id}>
|
||||
<ControllerBindingLabel
|
||||
binding={activeBindings[action]}
|
||||
iconStyle="playstation"
|
||||
/>{' '}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<div className="dual-control-chip">
|
||||
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
|
||||
</div>
|
||||
<div className="dual-control-chip">
|
||||
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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 (
|
||||
<div className="spell iwt2-bottom-spell" key={ability.id}>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Iwt2BossEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
|
||||
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
|
||||
const entries = bosses ?? (boss ? [boss] : [])
|
||||
return (
|
||||
<div className="iwt2-boss-hud">
|
||||
{entries.map((entry) => {
|
||||
const metadata = IWT2_BOSS_METADATA[entry.bossId]
|
||||
return (
|
||||
<div className="iwt2-boss-hud-row" key={entry.id}>
|
||||
<div>
|
||||
<strong>{metadata.name}</strong>
|
||||
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||
</div>
|
||||
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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"
|
||||
data-game-selected={selected ? 'true' : undefined}
|
||||
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: {},
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
||||
|
||||
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
|
||||
|
||||
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
|
||||
packHowlCooldown?: number
|
||||
packHowlWindup?: number
|
||||
packLaneDamage?: number
|
||||
packLaneStunSeconds?: number
|
||||
packLaneWidth?: number
|
||||
packLaneCount?: number
|
||||
thunderRingCooldown?: number
|
||||
thunderRingWindup?: number
|
||||
thunderRingInnerRadius?: number
|
||||
thunderRingOuterRadius?: number
|
||||
thunderRingDamage?: number
|
||||
thunderRingStunSeconds?: number
|
||||
lightningStrikeCooldown?: number
|
||||
lightningStrikeWindup?: number
|
||||
lightningStrikeRadius?: number
|
||||
lightningStrikeDamage?: number
|
||||
lightningStrikeStunSeconds?: number
|
||||
lightningStrikeCount?: 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,
|
||||
}
|
||||
|
||||
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'great-jaggi',
|
||||
name: 'Great Jaggi',
|
||||
icon: 'J',
|
||||
color: '#3f8f73',
|
||||
accentColor: '#b8f0aa',
|
||||
maxHealth: 620,
|
||||
radius: 27,
|
||||
moveSpeed: 146,
|
||||
meleeRange: 52,
|
||||
meleeDamage: 7,
|
||||
meleeCooldown: 0.82,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
packHowlCooldown: 5.8,
|
||||
packHowlWindup: 0.95,
|
||||
packLaneDamage: 15,
|
||||
packLaneStunSeconds: 0.45,
|
||||
packLaneWidth: 24,
|
||||
packLaneCount: 3,
|
||||
}
|
||||
|
||||
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'khezu',
|
||||
name: 'Khezu',
|
||||
icon: 'K',
|
||||
color: '#d8d7c9',
|
||||
accentColor: '#77d9ff',
|
||||
maxHealth: 760,
|
||||
radius: 30,
|
||||
moveSpeed: 92,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 10,
|
||||
meleeCooldown: 1.15,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
thunderRingCooldown: 7.2,
|
||||
thunderRingWindup: 0.9,
|
||||
thunderRingInnerRadius: 70,
|
||||
thunderRingOuterRadius: 172,
|
||||
thunderRingDamage: 22,
|
||||
thunderRingStunSeconds: 0.7,
|
||||
lightningStrikeCooldown: 4.2,
|
||||
lightningStrikeWindup: 0.78,
|
||||
lightningStrikeRadius: 46,
|
||||
lightningStrikeDamage: 17,
|
||||
lightningStrikeStunSeconds: 0.55,
|
||||
lightningStrikeCount: 2,
|
||||
}
|
||||
|
||||
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 GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
|
||||
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA)
|
||||
|
||||
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
|
||||
bulldrome: BULLDROME_BOSS_METADATA,
|
||||
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
||||
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
|
||||
khezu: KHEZU_BOSS_METADATA,
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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',
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Spell } from '../../../game'
|
||||
|
||||
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
||||
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
||||
|
||||
export type Iwt2HealerMetadata = {
|
||||
id: Iwt2HealerId
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type Iwt2HealerAbility = {
|
||||
id: string
|
||||
healerId: Iwt2HealerId
|
||||
slot: number
|
||||
name: string
|
||||
icon: string
|
||||
kind: Spell['kind']
|
||||
power: number
|
||||
effectType?: string
|
||||
manaCost: number
|
||||
cooldownSeconds: number
|
||||
target: Iwt2AbilityTarget
|
||||
extraTargets?: number
|
||||
}
|
||||
|
||||
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
||||
dawnweaver: {
|
||||
id: 'dawnweaver',
|
||||
name: 'Dawnweaver',
|
||||
icon: '+',
|
||||
description: 'Direct heals, radiant group recovery, shields, and cleanse.',
|
||||
},
|
||||
lifebinder: {
|
||||
id: 'lifebinder',
|
||||
name: 'Lifebinder',
|
||||
icon: '~',
|
||||
description: 'Verdant healing over time, barkskin shielding, and steady group recovery.',
|
||||
},
|
||||
runesage: {
|
||||
id: 'runesage',
|
||||
name: 'Runesage',
|
||||
icon: 'R',
|
||||
description: 'Rune-scripted mends, concordance healing, aegis shielding, and unraveling cleanse.',
|
||||
},
|
||||
}
|
||||
|
||||
export const IWT2_HEALER_ORDER: Iwt2HealerId[] = ['dawnweaver', 'lifebinder', 'runesage']
|
||||
|
||||
export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> = {
|
||||
dawnweaver: [
|
||||
createAbility('dawnweaver', 1, 'mend', 'Mend', '+', 'direct', 30, 5, 0.5),
|
||||
createAbility('dawnweaver', 2, 'renew', 'Renew', '~', 'hot', 12, 7, 0.5, 'heal_over_time'),
|
||||
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
||||
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
|
||||
createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
|
||||
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
|
||||
],
|
||||
lifebinder: [
|
||||
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
||||
createAbility('lifebinder', 2, 'seed-of-life', 'Seed of Life', '~', 'hot', 16, 9, 1, 'heal_over_time'),
|
||||
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
||||
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
|
||||
createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'),
|
||||
createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'),
|
||||
],
|
||||
runesage: [
|
||||
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
||||
createAbility('runesage', 2, 'mending-rune', 'Mending Rune', '~', 'hot', 10, 5, 0.45, 'heal_over_time'),
|
||||
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
||||
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
|
||||
createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'),
|
||||
createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5),
|
||||
],
|
||||
}
|
||||
|
||||
export function abilitiesForHealer(healerId: Iwt2HealerId | string) {
|
||||
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
||||
}
|
||||
|
||||
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
||||
if (value === 'field_medic') return 'dawnweaver'
|
||||
if (value === 'ward_sage') return 'lifebinder'
|
||||
if (value === 'storm_chanter') return 'runesage'
|
||||
return IWT2_HEALER_ORDER.includes(value as Iwt2HealerId)
|
||||
? value as Iwt2HealerId
|
||||
: 'dawnweaver'
|
||||
}
|
||||
|
||||
function createAbility(
|
||||
healerId: Iwt2HealerId,
|
||||
slot: number,
|
||||
slug: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
kind: Spell['kind'],
|
||||
power: number,
|
||||
manaCost: number,
|
||||
cooldownSeconds: number,
|
||||
effectType?: string,
|
||||
): Iwt2HealerAbility {
|
||||
return {
|
||||
id: `${healerId}-${slug}`,
|
||||
healerId,
|
||||
slot,
|
||||
name,
|
||||
icon,
|
||||
kind,
|
||||
power,
|
||||
effectType,
|
||||
manaCost,
|
||||
cooldownSeconds,
|
||||
target: 'party-member',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
buildOpponentSlotDebuffChoices,
|
||||
buildSelfSlotUpgradeChoices,
|
||||
} from '../../../combat/roguelikeUpgrades'
|
||||
import type { Spell } from '../../../game'
|
||||
import { type Iwt2HealerAbility } from './healerAbilities'
|
||||
|
||||
export type Iwt2RoguelikeVariant = 'pve' | 'pvp'
|
||||
export type Iwt2RoguelikeContentType = 'dungeon' | 'raid' | 'stadium'
|
||||
export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
|
||||
|
||||
export type Iwt2RoguelikeSelfBuffId =
|
||||
| 'revive-party-members'
|
||||
| `slot${Iwt2RoguelikeSlot}-extra-target`
|
||||
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
||||
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
||||
|
||||
export type Iwt2RoguelikeOpponentDebuffId =
|
||||
| `opp-slot${Iwt2RoguelikeSlot}-cost-up`
|
||||
| `opp-slot${Iwt2RoguelikeSlot}-cooldown-up`
|
||||
|
||||
export type Iwt2RoguelikeChoice<T extends string> = {
|
||||
id: T
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
||||
|
||||
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
||||
id: 'revive-party-members',
|
||||
name: 'Revive Party Members',
|
||||
description: 'Revive fallen party members before the next IWT2 arena.',
|
||||
}
|
||||
|
||||
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
||||
return {
|
||||
id: ability.id,
|
||||
key: String(ability.slot),
|
||||
name: ability.name,
|
||||
description: ability.name,
|
||||
cost: ability.manaCost,
|
||||
cooldown: ability.cooldownSeconds,
|
||||
power: ability.power,
|
||||
glyph: ability.icon,
|
||||
kind: ability.kind,
|
||||
effectType: ability.effectType,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
||||
slots: IWT2_ROGUELIKE_SLOTS,
|
||||
spells: abilities.map(iwt2AbilityToSpell),
|
||||
labelMode: 'ability',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||
return buildOpponentSlotDebuffChoices<Iwt2RoguelikeOpponentDebuffId>({
|
||||
slots: IWT2_ROGUELIKE_SLOTS,
|
||||
spells: abilities.map(iwt2AbilityToSpell),
|
||||
labelMode: 'ability',
|
||||
})
|
||||
}
|
||||
@@ -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.bosses]
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
||||
|
||||
export type Iwt2InventoryItem = {
|
||||
id: string
|
||||
name: string
|
||||
quantity: number
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
}
|
||||
|
||||
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
||||
|
||||
export type Iwt2CollectionLog = {
|
||||
bossKills: Record<string, number>
|
||||
dropsFound: Record<string, number>
|
||||
}
|
||||
|
||||
export type Iwt2Character = {
|
||||
name: string
|
||||
level: number
|
||||
experience: number
|
||||
healerStyle: Iwt2HealerId
|
||||
armorPalette: Iwt2ArmorPaletteId
|
||||
}
|
||||
|
||||
export type Iwt2CloudSlot = {
|
||||
savedAt: number
|
||||
character: Iwt2Character
|
||||
inventory: Iwt2InventoryItem[]
|
||||
collectionLog: Iwt2CollectionLog
|
||||
}
|
||||
|
||||
export type Iwt2Save = {
|
||||
version: 1
|
||||
updatedAt: number
|
||||
character: Iwt2Character
|
||||
inventory: Iwt2InventoryItem[]
|
||||
collectionLog: Iwt2CollectionLog
|
||||
cloudSlot?: Iwt2CloudSlot
|
||||
}
|
||||
|
||||
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,
|
||||
healerStyle: 'dawnweaver',
|
||||
armorPalette: 'guild_green',
|
||||
},
|
||||
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)),
|
||||
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
||||
},
|
||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||
collectionLog: {
|
||||
bossKills: candidate.collectionLog?.bossKills ?? {},
|
||||
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
||||
},
|
||||
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function updateIwt2CharacterSettings(
|
||||
save: Iwt2Save,
|
||||
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
|
||||
): Iwt2Save {
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
character: {
|
||||
...save.character,
|
||||
...settings,
|
||||
name: normalizeCharacterName(settings.name ?? save.character.name),
|
||||
healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
cloudSlot: {
|
||||
savedAt: Date.now(),
|
||||
character: { ...save.character },
|
||||
inventory: save.inventory.map((item) => ({ ...item })),
|
||||
collectionLog: {
|
||||
bossKills: { ...save.collectionLog.bossKills },
|
||||
dropsFound: { ...save.collectionLog.dropsFound },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
||||
if (!save.cloudSlot) return save
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
character: { ...save.cloudSlot.character },
|
||||
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
|
||||
collectionLog: {
|
||||
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
|
||||
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function bossDropFor(bossId: string): { id: string, name: string } {
|
||||
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
|
||||
if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' }
|
||||
if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' }
|
||||
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
|
||||
}
|
||||
|
||||
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
|
||||
return value === 'sun_gold'
|
||||
|| value === 'ember_red'
|
||||
|| value === 'moon_blue'
|
||||
|| value === 'guild_green'
|
||||
? value
|
||||
: 'guild_green'
|
||||
}
|
||||
|
||||
function normalizeCharacterName(name: string): string {
|
||||
const trimmed = name.trim().slice(0, 18)
|
||||
return trimmed || 'Healer'
|
||||
}
|
||||
|
||||
function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = value as Partial<Iwt2CloudSlot>
|
||||
if (!candidate.character || !candidate.collectionLog) return undefined
|
||||
return {
|
||||
savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(),
|
||||
character: {
|
||||
name: normalizeCharacterName(candidate.character.name ?? 'Healer'),
|
||||
level: Math.max(1, Math.floor(candidate.character.level ?? 1)),
|
||||
experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)),
|
||||
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
||||
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
||||
},
|
||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
||||
collectionLog: {
|
||||
bossKills: candidate.collectionLog.bossKills ?? {},
|
||||
dropsFound: candidate.collectionLog.dropsFound ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../../../dualScreen'
|
||||
import type { PartyMember, Role, Spell } from '../../../game'
|
||||
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_CLASS_METADATA } from '../content/classes'
|
||||
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'
|
||||
import type {
|
||||
Iwt2RoguelikeContentType,
|
||||
Iwt2RoguelikeOpponentDebuffId,
|
||||
Iwt2RoguelikeSelfBuffId,
|
||||
Iwt2RoguelikeVariant,
|
||||
} from '../content/roguelike'
|
||||
|
||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||
type OverlayAction = 'primary' | 'menu'
|
||||
type OverlayNavEntry = {
|
||||
action: OverlayAction
|
||||
row: number
|
||||
}
|
||||
|
||||
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0 },
|
||||
{ action: 'menu', row: 1 },
|
||||
]
|
||||
|
||||
type BossArenaScreenProps = {
|
||||
bossId: Iwt2BossId
|
||||
bossIds?: Iwt2BossId[]
|
||||
modeLabel?: string
|
||||
save: Iwt2Save
|
||||
onBack: () => void
|
||||
onSaveUpdated: (save: Iwt2Save) => void
|
||||
roguelikeRun?: {
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
onVictory: () => void
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
}
|
||||
|
||||
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
|
||||
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
||||
))
|
||||
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 opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||
const statusRef = useRef(status)
|
||||
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||
const saveRef = useRef(save)
|
||||
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||
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 { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
opponentStateRef.current = opponentArenaState
|
||||
}, [opponentArenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
selectedOverlayActionRef.current = selectedOverlayAction
|
||||
}, [selectedOverlayAction])
|
||||
|
||||
useEffect(() => {
|
||||
selectedPartyIdRef.current = selectedPartyId
|
||||
}, [selectedPartyId])
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = save
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
|
||||
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
||||
recordedKillIdsRef.current = new Set()
|
||||
abilityCooldownsRef.current = {}
|
||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||
stateRef.current = next
|
||||
opponentStateRef.current = nextOpponentState
|
||||
setArenaState(next)
|
||||
setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
const abilities = useMemo(
|
||||
() => applyRoguelikeModifiers(
|
||||
abilitiesForHealer(save.character.healerStyle),
|
||||
roguelikeRun?.buffs ?? [],
|
||||
roguelikeRun?.debuffs ?? [],
|
||||
),
|
||||
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
||||
)
|
||||
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const moveOverlaySelection = useCallback((action: string) => {
|
||||
setSelectedOverlayAction((current) => {
|
||||
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
||||
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
||||
if (entry.action === current) return false
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
if (action === 'navigateDown') return entry.row > active.row
|
||||
return false
|
||||
})
|
||||
if (candidates.length === 0) return current
|
||||
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||
return candidates[0]?.action ?? current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||
if (overlayAction === 'menu') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'paused') {
|
||||
setStatus('playing')
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'victory' && roguelikeRun) {
|
||||
roguelikeRun.onVictory()
|
||||
return
|
||||
}
|
||||
resetArena()
|
||||
}, [onBack, resetArena, roguelikeRun])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||
if (action.startsWith('navigate')) {
|
||||
moveOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
activateOverlayAction()
|
||||
return
|
||||
}
|
||||
if (action === 'back') {
|
||||
if (statusRef.current === 'paused') setStatus('playing')
|
||||
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
|
||||
let nextOpponentState = opponentStateRef.current
|
||||
if (pvpRoguelike && nextOpponentState) {
|
||||
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
|
||||
nextOpponentState = tickIwt2Arena(
|
||||
nextOpponentState,
|
||||
{
|
||||
moveX: opponentMovement.x,
|
||||
moveY: opponentMovement.y,
|
||||
},
|
||||
dtSeconds,
|
||||
)
|
||||
opponentStateRef.current = nextOpponentState
|
||||
}
|
||||
|
||||
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
|
||||
if (newlyDefeatedBosses.length > 0) {
|
||||
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
||||
let updatedSave = saveRef.current
|
||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||
nextRecordedIds.add(defeatedBoss.bossId)
|
||||
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
|
||||
}
|
||||
recordedKillIdsRef.current = nextRecordedIds
|
||||
saveRef.current = updatedSave
|
||||
onSaveUpdated(updatedSave)
|
||||
}
|
||||
|
||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
||||
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.bosses.some((boss) => boss.health <= 0)
|
||||
) {
|
||||
lastHudSignatureRef.current = hudSignature
|
||||
lastPublishTimeRef.current = next.time
|
||||
setArenaState(next)
|
||||
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [onSaveUpdated, pvpRoguelike, 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 defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
|
||||
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
|
||||
const overlayPrimaryLabel = status === 'paused'
|
||||
? 'Resume'
|
||||
: status === 'victory' && roguelikeRun
|
||||
? 'Choose Upgrade'
|
||||
: 'Restart'
|
||||
const overlayTitle = status === 'victory'
|
||||
? `${bossTitle} 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'
|
||||
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
||||
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
||||
? undefined
|
||||
: 'Combat is stopped. Resume the fight or leave the current run.'
|
||||
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
|
||||
? 'Leave'
|
||||
: roguelikeRun
|
||||
? 'Leave Roguelike'
|
||||
: `Leave ${modeLabel ?? 'Arena'}`
|
||||
const dualScreenState = useMemo(
|
||||
() => buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings: activeBindings,
|
||||
cooldowns: abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel: modeLabel ?? 'Arena',
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}),
|
||||
[
|
||||
abilities,
|
||||
activeBindings,
|
||||
arenaState,
|
||||
abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
],
|
||||
)
|
||||
|
||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||
|
||||
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 bosses={arenaState.bosses} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
party={arenaState.party}
|
||||
selectedPartyId={selectedPartyId}
|
||||
targetBindings={targetBindings}
|
||||
/>
|
||||
|
||||
<PhaserArena
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
{status === 'paused' && (
|
||||
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2>{pauseTitle}</h2>
|
||||
{pauseCopy && <p>{pauseCopy}</p>}
|
||||
<button
|
||||
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||
type="button"
|
||||
>
|
||||
{pauseLeaveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'playing' && status !== 'paused' && (
|
||||
<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>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
|
||||
</div>
|
||||
<p className="eyebrow">{overlayEyebrow}</p>
|
||||
<h1>{overlayTitle}</h1>
|
||||
{roguelikeRun && (
|
||||
<p className="iwt2-result-hint">
|
||||
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
|
||||
</p>
|
||||
)}
|
||||
<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>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||
Bosses
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+{arenaState.bosses.length * 125} XP</span>
|
||||
<span>{arenaState.bosses.length} carves</span>
|
||||
<span>Log +{arenaState.bosses.length}</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={() => activateOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
{overlayPrimaryLabel}
|
||||
</button>
|
||||
<button
|
||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
type="button"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
{lastDevice === 'controller' && (
|
||||
<small className="iwt2-result-hint">D-pad selects, A confirms</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 roguelikeBossHealthScale(stage: number): number {
|
||||
return 0.5 + Math.max(0, stage - 1) * 0.1
|
||||
}
|
||||
|
||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||
return [
|
||||
...state.bosses.map((boss) => [
|
||||
boss.id,
|
||||
Math.ceil(boss.health),
|
||||
boss.attackPhase,
|
||||
].join(':')),
|
||||
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 formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
||||
return bosses
|
||||
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
|
||||
.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
|
||||
}
|
||||
|
||||
function applyRoguelikeModifiers(
|
||||
abilities: Iwt2HealerAbility[],
|
||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||
): Iwt2HealerAbility[] {
|
||||
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||
return abilities.map((ability) => {
|
||||
const slot = String(ability.slot)
|
||||
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
|
||||
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
||||
return {
|
||||
...ability,
|
||||
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function countStacks(items: readonly string[], id: string) {
|
||||
return items.filter((item) => item === id).length
|
||||
}
|
||||
|
||||
function roundModifier(value: number) {
|
||||
return Math.max(0.1, Math.round(value * 100) / 100)
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function arenaPauseTitle(
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||
fallbackTitle: string,
|
||||
) {
|
||||
if (!roguelikeRun) return fallbackTitle
|
||||
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
|
||||
if (roguelikeRun.variant === 'pvp') {
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
|
||||
}
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
|
||||
}
|
||||
|
||||
function buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings,
|
||||
cooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}: {
|
||||
abilities: Iwt2HealerAbility[]
|
||||
arenaState: Iwt2ArenaState
|
||||
bindings: DualScreenCombatState['bindings']
|
||||
cooldowns: Record<string, number>
|
||||
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
||||
directPartyTargeting: boolean
|
||||
modeLabel: string
|
||||
opponentArenaState: Iwt2ArenaState | null
|
||||
playerMana: number
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
|
||||
selectedPartyId: Iwt2EntityId
|
||||
status: ArenaStatus
|
||||
}): DualScreenCombatState {
|
||||
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
|
||||
const arenaBossHealth = totalBossHealth(arenaState)
|
||||
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
|
||||
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
|
||||
? {
|
||||
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
|
||||
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
|
||||
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
|
||||
opponentArena: toDualScreenOpponentArena(opponentArenaState),
|
||||
opponentEnemyHealth: totalBossHealth(opponentArenaState),
|
||||
opponentMaxResource: opponentHealer?.maxMana ?? 100,
|
||||
opponentName: 'CPU Rival',
|
||||
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
|
||||
opponentResource: opponentHealer?.mana ?? 100,
|
||||
opponentResourceName: 'Mana',
|
||||
}
|
||||
: {}
|
||||
return {
|
||||
...pvpOpponentState,
|
||||
bindings,
|
||||
contentName: modeLabel,
|
||||
controllerIconStyle,
|
||||
difficultyName: 'IWT2',
|
||||
directPartyTargeting,
|
||||
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
|
||||
encounterCount: 1,
|
||||
encounterDescription: arenaState.boss.attackPhase,
|
||||
encounterHealth: arenaBossHealth,
|
||||
encounterIndex: 0,
|
||||
encounterIsBoss: true,
|
||||
encounterMaxHealth: arenaBossMaxHealth,
|
||||
encounterName: formatBossEncounterTitle(arenaState.bosses),
|
||||
floatingTexts: [],
|
||||
maxResource: 100,
|
||||
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
|
||||
partySize: arenaState.party.length,
|
||||
paused: status === 'paused',
|
||||
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
||||
resource: playerMana,
|
||||
resourceName: 'Mana',
|
||||
selectedId: selectedPartyId,
|
||||
speedMultiplier: 1,
|
||||
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
|
||||
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
|
||||
targetGroup: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function totalBossHealth(state: Iwt2ArenaState): number {
|
||||
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
|
||||
}
|
||||
|
||||
function totalBossMaxHealth(state: Iwt2ArenaState): number {
|
||||
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
|
||||
}
|
||||
|
||||
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
|
||||
return {
|
||||
bounds: state.bounds,
|
||||
bosses: state.bosses.map((boss) => {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
return {
|
||||
id: boss.id,
|
||||
name: metadata.name,
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: boss.position.x,
|
||||
y: boss.position.y,
|
||||
radius: boss.radius,
|
||||
health: boss.health,
|
||||
maxHealth: boss.maxHealth,
|
||||
}
|
||||
}),
|
||||
party: state.party.map((member) => {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
id: member.id,
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: member.position.x,
|
||||
y: member.position.y,
|
||||
radius: member.radius,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
isHealer: member.id === 'player-healer',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
bounceHeals: [],
|
||||
health: member.health,
|
||||
hotEffects: member.hotEffects.map((effect) => ({
|
||||
id: effect.id,
|
||||
label: effect.label,
|
||||
power: effect.power,
|
||||
spellId: effect.id,
|
||||
ticks: Math.ceil(effect.remainingSeconds),
|
||||
})),
|
||||
hotTicks: member.hotEffects.length,
|
||||
id: opponent ? `opponent-${member.id}` : member.id,
|
||||
maxHealth: member.maxHealth,
|
||||
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
|
||||
role: toDualScreenRole(metadata.role),
|
||||
shield: member.shield,
|
||||
}
|
||||
}
|
||||
|
||||
function opponentPartyName(name: string): string {
|
||||
if (name === 'Player Healer') return 'CPU Healer'
|
||||
return `CPU ${name.replace(' Tank', '')}`
|
||||
}
|
||||
|
||||
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
|
||||
if (role === 'tank') return 'Tank'
|
||||
if (role === 'healer') return 'Healer'
|
||||
return 'Damage'
|
||||
}
|
||||
|
||||
function toDualScreenSpell(
|
||||
ability: Iwt2HealerAbility,
|
||||
slotIndex: number,
|
||||
remaining: number,
|
||||
): Spell & { slotIndex: number; remaining: number } {
|
||||
return {
|
||||
cooldown: ability.cooldownSeconds,
|
||||
cost: ability.manaCost,
|
||||
description: 'IWT2 arena ability',
|
||||
effectType: ability.effectType,
|
||||
glyph: ability.icon,
|
||||
id: ability.id,
|
||||
key: String(ability.slot),
|
||||
kind: ability.kind,
|
||||
name: ability.name,
|
||||
power: ability.power,
|
||||
remaining,
|
||||
slotIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
|
||||
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
|
||||
const orbitSeconds = Math.max(1, state.time)
|
||||
return normalizedArenaMovement({
|
||||
x: Math.cos(orbitSeconds * 0.75),
|
||||
y: Math.sin(orbitSeconds * 0.75) * 0.55,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizedArenaMovement(vector: MovementVector): MovementVector {
|
||||
const magnitude = Math.hypot(vector.x, vector.y)
|
||||
if (magnitude <= 1) return vector
|
||||
return {
|
||||
x: vector.x / magnitude,
|
||||
y: vector.y / magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
|
||||
if (debuffs.length === 0) return 'none'
|
||||
return debuffs
|
||||
.map((debuff) => debuff
|
||||
.replace('opp-', '')
|
||||
.replaceAll('-', ' '))
|
||||
.join(', ')
|
||||
}
|
||||