Compare commits

...
14 Commits
Author SHA1 Message Date
Warren H 0d269a6041 Android build v1.1.16 2026-07-04 17:41:45 -04:00
Warren H 99e97e5208 Android build v1.1.15 2026-07-04 12:43:59 -04:00
Warren H a849cd2c69 Android build v1.1.14 2026-07-03 12:58:53 -04:00
Warren H a121b2ef74 Android build v1.1.13 2026-07-03 01:01:32 -04:00
Warren H b7cb572631 Android build v1.1.12 2026-07-03 00:28:38 -04:00
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
Warren H bbcc67a54a Android build v1.1.3 2026-06-29 22:55:53 -04:00
116 changed files with 15586 additions and 1631 deletions
+43 -1
View File
@@ -1,9 +1,51 @@
# Project Notes # Project Notes
## Target Hardware: AYN Thor
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. - 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 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. - 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. - 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. - 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. - Apply game changes to both web version and mobile app version.
## I Want To Heal 1 vs I Want To Heal 2
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
- Prefer event-driven updates and cached calculations for UI and game systems.
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
## Build Requirements
- User rebuilds app; do not rebuild APK unless explicitly requested.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 81 versionCode 95
versionName "1.1.2" versionName "1.1.16"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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; package com.warren.iwanttoheal;
import android.content.Intent; 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.Bundle;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File; 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 abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 220; private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private long lastDpadDispatchAt = 0; 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 @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers(); boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) { if (bridge != null) {
if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true); bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
} }
loadIntentUrl(); loadIntentUrl();
} }
@@ -35,7 +54,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override @Override
public void onWindowFocusChanged(boolean hasFocus) { public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus); super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode(); if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
} }
@Override @Override
@@ -74,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
deleteIfExists(new File(webViewData, "Service Worker")); 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) { private void deleteIfExists(File file) {
if (!file.exists()) return; if (!file.exists()) return;
if (file.isDirectory()) { if (file.isDirectory()) {
@@ -94,7 +147,88 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true; if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
"Button14",
"Button15"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
"Button12",
"Button13"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_X),
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y),
"Axis1-",
"Axis1+"
);
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
long now = SystemClock.uptimeMillis();
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 = String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -105,13 +239,12 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
} }
); );
} }
return true;
}
private boolean shouldThrottleDpad() { private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now; lastDpadDispatchAt[buttonIndex] = now;
return false; return false;
} }
@@ -125,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
private String controllerToken(int keyCode) { private String controllerToken(int keyCode) {
switch (keyCode) { switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A: case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0"; return "Button0";
case KeyEvent.KEYCODE_BUTTON_B: case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1"; return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: case KeyEvent.KEYCODE_BUTTON_X:
return "Button2"; return "Button2";
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation { private final class TopDisplayPresentation extends Presentation {
private final String initialUrl; private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) { TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display); super(context, display);
this.display = display;
this.initialUrl = initialUrl; this.initialUrl = initialUrl;
} }
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
); );
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext()); FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK); container.setBackgroundColor(Color.BLACK);
+16 -1
View File
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0, completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
completed_raid_phases INTEGER NOT NULL DEFAULT 0, completed_raid_phases INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL, 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 ( 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) 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 ( CREATE TABLE IF NOT EXISTS action_characters (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+80
View File
@@ -10,12 +10,14 @@
"dependencies": { "dependencies": {
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.4.0", "@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -801,6 +803,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "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": ">=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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2952,6 +2976,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2972,6 +3005,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/plist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+4
View File
@@ -12,6 +12,8 @@
"android:open": "cap open android", "android:open": "cap open android",
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug", "android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk", "android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
"release:gui": "python3 scripts/release_game.py",
"release:cli": "python3 scripts/release_game_cli.py",
"accounts:ip": "node scripts/manage-ip-allowance.mjs", "accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs", "db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs", "db:init": "node scripts/init-db.mjs",
@@ -26,12 +28,14 @@
"dependencies": { "dependencies": {
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.4.0", "@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+6
View File
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0') addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'completed_raid_phases', '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)') addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
migrateCharacterAccountConstraint() migrateCharacterAccountConstraint()
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from getpass import getpass
from pathlib import Path
from typing import Callable
try:
import tkinter as tk
from tkinter import messagebox, ttk
except ImportError:
tk = None
messagebox = None
ttk = None
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
DEFAULT_GITEA_URL = "https://git.whoagland.com"
DEFAULT_GITEA_OWNER = "phenom"
DEFAULT_GITEA_REPO = "i-want-to-heal"
DEFAULT_BRANCH = "main"
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
class ReleaseError(RuntimeError):
pass
@dataclass(frozen=True)
class ReleaseConfig:
version: str
token: str
api_base_url: str
java_home: str
gitea_url: str
gitea_owner: str
gitea_repo: str
branch: str
truenas_path: str
truenas_db_path: str
restart_command: str
run_build: bool
run_commit_push: bool
run_release: bool
run_truenas_pull: bool
run_truenas_backup: bool
run_restart: bool
def read_current_version() -> str:
text = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', text)
return match.group(1) if match else ""
def run_command(
args: list[str],
*,
cwd: Path,
env: dict[str, str] | None,
log: Callable[[str], None],
) -> None:
log(f"$ {' '.join(args)}")
proc = subprocess.Popen(
args,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
for line in proc.stdout:
log(line.rstrip("\n"))
code = proc.wait()
if code != 0:
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
log(f"Android version set: versionName {version}, versionCode {next_code}")
return next_code
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
update_gradle_version(config.version, log)
env = os.environ.copy()
env["JAVA_HOME"] = config.java_home
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = config.api_base_url
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
if not apk_source.exists():
raise ReleaseError(f"APK not found: {apk_source}")
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
apk_target.write_bytes(apk_source.read_bytes())
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
return apk_target
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
message = f"Android build v{config.version}"
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
def gitea_request(
config: ReleaseConfig,
method: str,
path: str,
*,
body: bytes | None = None,
content_type: str | None = None,
) -> tuple[int, bytes]:
url = config.gitea_url.rstrip("/") + path
headers = {"Authorization": f"token {config.token}"}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
data = exc.read()
return exc.code, data
def parse_json_response(status: int, data: bytes, action: str) -> dict:
try:
parsed = json.loads(data.decode("utf-8") or "{}")
except json.JSONDecodeError as exc:
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
if status >= 400:
raise ReleaseError(f"{action} failed ({status}): {parsed}")
if not isinstance(parsed, dict):
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
tag = f"v{config.version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
status, data = gitea_request(config, "GET", tag_path)
if status == 200:
release = parse_json_response(status, data, "Find release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Existing release missing id: {release}")
log(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
if status != 404:
parse_json_response(status, data, "Find release")
payload = {
"tag_name": tag,
"target_commitish": config.branch,
"name": tag,
"body": f"I Want to Heal Android build v{config.version}",
"draft": False,
"prerelease": False,
}
status, data = gitea_request(
config,
"POST",
repo_path + "/releases",
body=json.dumps(payload).encode("utf-8"),
content_type="application/json",
)
release = parse_json_response(status, data, "Create release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Created release missing id: {release}")
log(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
if not apk_path.exists():
raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name)
path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}"
)
status, data = gitea_request(
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset")
log(f"Gitea asset uploaded: {apk_path.name}")
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
app_path = Path(config.truenas_path)
if not app_path.exists():
raise ReleaseError(f"TrueNAS path not found: {app_path}")
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
if config.run_truenas_backup:
db_path = Path(config.truenas_db_path)
if not db_path.exists():
raise ReleaseError(f"TrueNAS database not found: {db_path}")
stamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
backup_path.write_bytes(db_path.read_bytes())
log(f"TrueNAS database backup copied: {backup_path}")
if config.run_restart:
if not config.restart_command.strip():
raise ReleaseError("Restart command is empty")
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
def validate_config(config: ReleaseConfig) -> None:
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
raise ReleaseError("Version must look like 1.1.2")
if config.run_release and not config.token:
raise ReleaseError("Gitea token required for release step")
if config.run_restart and not config.run_truenas_pull:
raise ReleaseError("Restart needs TrueNAS update step enabled")
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
prompt = f"{label}"
if default:
prompt += f" [{default}]"
prompt += ": "
try:
value = getpass(prompt) if secret else input(prompt)
except EOFError:
value = ""
return value.strip() or default
def prompt_bool(label: str, default: bool) -> bool:
suffix = "Y/n" if default else "y/N"
try:
value = input(f"{label} [{suffix}]: ").strip().lower()
except EOFError:
value = ""
if not value:
return default
return value in {"y", "yes", "true", "1"}
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
if config.run_build:
build_apk(config, log)
if config.run_commit_push:
commit_and_push(config, log)
if config.run_release:
upload_release_asset(config, log)
if config.run_truenas_pull:
update_truenas(config, log)
def cli_main() -> int:
print("Tkinter not available. CLI mode.")
current_version = read_current_version()
config = ReleaseConfig(
version=prompt_text("Version", current_version),
token=prompt_text("Gitea token", secret=True),
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
branch=prompt_text("Git branch", DEFAULT_BRANCH),
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
restart_command=prompt_text("Restart command"),
run_build=prompt_bool("Build APK", True),
run_commit_push=prompt_bool("Commit + push", True),
run_release=prompt_bool("Create Gitea release", True),
run_truenas_pull=prompt_bool("TrueNAS pull", True),
run_truenas_backup=prompt_bool("Backup DB", True),
run_restart=prompt_bool("Restart", False),
)
validate_config(config)
run_release_steps(config, print)
print("Release steps complete")
return 0
if tk is not None and ttk is not None and messagebox is not None:
class ReleaseApp(ttk.Frame):
def __init__(self, root: tk.Tk) -> None:
super().__init__(root, padding=12)
self.root = root
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
self.worker: threading.Thread | None = None
self.vars: dict[str, tk.Variable] = {}
self.grid(sticky="nsew")
root.title("I Want to Heal Release Tool")
root.geometry("920x720")
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
self.columnconfigure(1, weight=1)
self.rowconfigure(13, weight=1)
self._build_form()
self.after(100, self._drain_log_queue)
def _var(self, name: str, value: object = "") -> tk.Variable:
if isinstance(value, bool):
var: tk.Variable = tk.BooleanVar(value=value)
else:
var = tk.StringVar(value=str(value))
self.vars[name] = var
return var
def _build_form(self) -> None:
current_version = read_current_version()
fields = [
("Version", "version", current_version),
("Gitea token", "token", ""),
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
("Git branch", "branch", DEFAULT_BRANCH),
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
("Restart command", "restart_command", ""),
]
for row, (label, name, value) in enumerate(fields):
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
entry.grid(row=row, column=1, sticky="ew", pady=3)
checks = ttk.Frame(self)
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
check_data = [
("Build APK", "run_build", True),
("Commit + push", "run_commit_push", True),
("Create Gitea release", "run_release", True),
("TrueNAS pull", "run_truenas_pull", True),
("Backup DB", "run_truenas_backup", True),
("Restart", "run_restart", False),
]
for index, (text, name, value) in enumerate(check_data):
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
row=0, column=index, padx=(0, 14), sticky="w"
)
buttons = ttk.Frame(self)
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
self.run_button.pack(side="left")
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
self.log_text = tk.Text(self, height=18, wrap="word")
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
scroll.grid(row=13, column=2, sticky="ns")
self.log_text.configure(yscrollcommand=scroll.set)
def config_from_form(self) -> ReleaseConfig:
get = lambda name: str(self.vars[name].get()).strip()
get_bool = lambda name: bool(self.vars[name].get())
return ReleaseConfig(
version=get("version"),
token=get("token"),
api_base_url=get("api_base_url"),
java_home=get("java_home"),
gitea_url=get("gitea_url"),
gitea_owner=get("gitea_owner"),
gitea_repo=get("gitea_repo"),
branch=get("branch"),
truenas_path=get("truenas_path"),
truenas_db_path=get("truenas_db_path"),
restart_command=get("restart_command"),
run_build=get_bool("run_build"),
run_commit_push=get_bool("run_commit_push"),
run_release=get_bool("run_release"),
run_truenas_pull=get_bool("run_truenas_pull"),
run_truenas_backup=get_bool("run_truenas_backup"),
run_restart=get_bool("run_restart"),
)
def log(self, message: str) -> None:
self.log_queue.put(("log", message))
def _drain_log_queue(self) -> None:
try:
while True:
kind, message = self.log_queue.get_nowait()
if kind == "done":
self.run_button.configure(state="normal")
if message:
messagebox.showinfo("Release tool", message)
elif kind == "error":
self.run_button.configure(state="normal")
messagebox.showerror("Release failed", message)
else:
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
except queue.Empty:
pass
self.after(100, self._drain_log_queue)
def run_release(self) -> None:
if self.worker and self.worker.is_alive():
return
try:
config = self.config_from_form()
validate_config(config)
except ReleaseError as exc:
messagebox.showerror("Invalid settings", str(exc))
return
self.run_button.configure(state="disabled")
self.log_text.delete("1.0", "end")
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
self.worker.start()
def _run_release_worker(self, config: ReleaseConfig) -> None:
try:
run_release_steps(config, self.log)
self.log_queue.put(("done", "Release steps complete"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def start_admin(self) -> None:
if self.worker and self.worker.is_alive():
messagebox.showerror("Busy", "Release steps are still running")
return
self.log_text.delete("1.0", "end")
self.run_button.configure(state="disabled")
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
self.worker.start()
def _start_admin_worker(self) -> None:
try:
self.log("Admin URL: http://127.0.0.1:4174")
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
self.log_queue.put(("done", "Admin server stopped"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def main() -> int:
if not GRADLE_FILE.exists():
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
return 1
if tk is None:
return cli_main()
root = tk.Tk()
ReleaseApp(root)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+304
View File
@@ -0,0 +1,304 @@
#!/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
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
GITEA_URL = "https://git.whoagland.com"
GITEA_OWNER = "phenom"
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:
print(f"$ {' '.join(args)}", flush=True)
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)
current_version = match.group(1) if match else ""
suffix = f" [{current_version}]" if current_version else ""
version = input(f"Version{suffix}: ").strip() or current_version
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
return version
def update_gradle_version(version: str) -> None:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
print(f"Android version: {version}, versionCode: {next_code}")
def build_apk(version: str) -> Path:
update_gradle_version(version)
env = os.environ.copy()
env["JAVA_HOME"] = JAVA_HOME
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = API_BASE_URL
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
shutil.copy2(source, apk)
print(f"APK: {apk}")
return apk
def commit_and_push(version: str) -> None:
run(["git", "add", "."], cwd=REPO_ROOT)
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
body = json.dumps(data).encode("utf-8") if data is not None else None
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"target_commitish": BRANCH,
"name": f"v{version}",
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
},
)
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
def update_truenas() -> None:
if TRUENAS_PATH.exists():
run(["git", "pull"], cwd=TRUENAS_PATH)
print("Restart app in TrueNAS UI.")
return
print("TrueNAS path not mounted here. Run on TrueNAS:")
print(f"cd {TRUENAS_PATH}")
print("git pull")
print("Then restart app in TrueNAS UI.")
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
if __name__ == "__main__":
raise SystemExit(main())
+101 -1
View File
@@ -1,4 +1,4 @@
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path' 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 databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
const distPath = fileURLToPath(new URL('../dist', 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 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 bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', 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)) const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
@@ -248,6 +249,89 @@ function saveDungeonImage(database, dungeonId, payload) {
return imageUrl 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) { function sendFile(response, filePath) {
const contentTypes = { const contentTypes = {
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
@@ -308,6 +392,22 @@ const server = createServer(async (request, response) => {
return 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)) { if (!existsSync(databasePath)) {
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
return return
+6
View File
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
})), })),
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe), craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
dungeons: profile.dungeons.map(normalizeDungeon), dungeons: profile.dungeons.map(normalizeDungeon),
hunterStats: {
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
},
} }
} }
+155 -24
View File
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
const pvpQueueTtlMs = 15 * 1000 const pvpQueueTtlMs = 15 * 1000
const pvpMatchTtlMs = 60 * 60 * 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 = {}) { function sendJson(response, status, body, headers = {}) {
response.statusCode = status response.statusCode = status
response.setHeader('Content-Type', 'application/json') response.setHeader('Content-Type', 'application/json')
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
response.end(JSON.stringify(body)) 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() { function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '') return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
.split(',') .split(',')
@@ -815,6 +857,21 @@ export function getProfile(database, characterId, accountId) {
AND completed_parts >= 1 AND completed_parts >= 1
GROUP BY dungeon_id GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count])) `).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( const settings = Object.fromEntries(
database.prepare('SELECT key, value FROM game_settings').all() 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) })), .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(` const account = database.prepare(`
SELECT SELECT
completed_dungeon_parts AS completedDungeonParts, completed_dungeon_parts AS completedDungeonParts,
completed_raid_phases AS completedRaidPhases completed_raid_phases AS completedRaidPhases,
last_saved_at AS lastSavedAt
FROM accounts FROM accounts
WHERE id = ? WHERE id = ?
`).get(accountId) `).get(accountId)
@@ -1025,8 +1089,19 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
?? characters[0]?.name ?? characters[0]?.name
?? 'Mira' ?? '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 { return {
version: 3, version: 4,
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
characterName, characterName,
activeClassId, activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0, completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1038,6 +1113,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
]), ]),
), ),
lootRolls: {}, 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 ( if (
!save !save
|| typeof save !== 'object' || typeof save !== 'object'
|| Number(save.version) !== 3 || (Number(save.version) !== 4 && Number(save.version) !== 3)
|| typeof save.characterName !== 'string' || typeof save.characterName !== 'string'
|| !save.characters || !save.characters
|| typeof save.characters !== 'object' || typeof save.characters !== 'object'
@@ -1129,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
database.prepare(` database.prepare(`
UPDATE accounts UPDATE accounts
SET completed_dungeon_parts = ?, completed_raid_phases = ? SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
clampInteger(save.completedDungeonParts, 0, 0, 3), clampInteger(save.completedDungeonParts, 0, 0, 3),
clampInteger(save.completedRaidPhases, 0, 0, 3), clampInteger(save.completedRaidPhases, 0, 0, 3),
sqliteDateFromMs(save.updatedAt),
accountId, accountId,
) )
@@ -1149,6 +1229,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`) `)
const insertBossStats = database.prepare(`
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) { for (const classId of classIds) {
const local = save.characters[classId] const local = save.characters[classId]
@@ -1213,6 +1297,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
DELETE FROM character_inventory DELETE FROM character_inventory
WHERE character_id = ? WHERE character_id = ?
`).run(characterId) `).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 inventoryByItemId = new Map()
const equippedSlots = new Set() const equippedSlots = new Set()
for (const item of Array.isArray(local.inventory) ? local.inventory : []) { 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) { for (const [itemId, itemState] of inventoryByItemId) {
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0) 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( let syncedClassId = clampInteger(
@@ -2929,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
const database = new DatabaseSync(databasePath) const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON') database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try { try {
const ip = requestIp(request) const ip = requestIp(request)
@@ -2985,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
const database = new DatabaseSync(databasePath) const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON') database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try { try {
const ip = requestIp(request) const ip = requestIp(request)
@@ -3023,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/profile' && request.method === 'PUT') { if (request.url === '/api/profile' && request.method === 'PUT') {
const payload = await readJson(request) const payload = await readJson(request)
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload) 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 return
} }
@@ -3073,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
if (dungeonCompletion && request.method === 'POST') { if (dungeonCompletion && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
completeDungeon( completeDungeon(
database, database,
session.characterId, session.characterId,
@@ -3090,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/roguelike/complete' && request.method === 'POST') { if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
completeRoguelike(database, session.characterId, session.accountId, payload), completeRoguelike(database, session.characterId, session.accountId, payload),
) )
return return
@@ -3100,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/) const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
if (talentAllocation && request.method === 'POST') { if (talentAllocation && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
allocateTalent(database, session.characterId, Number(talentAllocation[1])), allocateTalent(database, session.characterId, Number(talentAllocation[1])),
) )
return return
} }
if (request.url === '/api/talents/reset' && request.method === 'POST') { 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 return
} }
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/) const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
if (itemEquip && request.method === 'POST') { if (itemEquip && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
equipItem(database, session.characterId, Number(itemEquip[1])), equipItem(database, session.characterId, Number(itemEquip[1])),
) )
return return
@@ -3125,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/) const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
if (itemDiscard && request.method === 'POST') { if (itemDiscard && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
discardExtraItem(database, session.characterId, Number(itemDiscard[1])), discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
) )
return return
@@ -3135,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/) const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
if (itemBreakdown && request.method === 'POST') { if (itemBreakdown && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
breakdownItem(database, session.characterId, Number(itemBreakdown[1])), breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
) )
return return
@@ -3145,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/) const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
if (recipeCraft && request.method === 'POST') { if (recipeCraft && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
craftItem(database, session.characterId, Number(recipeCraft[1])), craftItem(database, session.characterId, Number(recipeCraft[1])),
) )
return return
@@ -3155,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/) const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
if (itemUpgrade && request.method === 'POST') { if (itemUpgrade && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
upgradeItem(database, session.characterId, Number(itemUpgrade[1])), upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
) )
return return
@@ -3166,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') { if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
rollEncounterLoot( rollEncounterLoot(
database, database,
session.characterId, session.characterId,
+2610 -177
View File
File diff suppressed because it is too large Load Diff
+65 -1101
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css' import './App.css'
import { AdminScreen } from './components/AdminScreen' import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<InputProvider>
<AdminScreen onBack={() => window.close()} /> <AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>, </StrictMode>,
) )
+60 -2
View File
@@ -1,21 +1,79 @@
import type { Spell } from '../game' 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( export function canCastSpell(
spell: Spell, spell: Spell,
resource: number, resource: number,
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
resourceCost: 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( export function putSpellOnCooldown(
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
spell: Spell, spell: Spell,
cooldownMultiplier = 1, cooldownMultiplier = 1,
now = cooldownNow(),
) { ) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return { return {
...cooldowns, ...cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier, [spell.id]: now + cooldownMs,
} }
} }
+357 -1
View File
@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { CSSProperties, Dispatch, SetStateAction } 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 = { type AdminItem = {
id: number id: number
@@ -130,7 +136,38 @@ type AdminData = {
classes: AdminClass[] 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 SavingState = Record<string, boolean>
type SetData = Dispatch<SetStateAction<AdminData | null>> type SetData = Dispatch<SetStateAction<AdminData | null>>
type SetSaving = Dispatch<SetStateAction<SavingState>> type SetSaving = Dispatch<SetStateAction<SavingState>>
@@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [
{ id: 'crafting', label: 'Crafting' }, { id: 'crafting', label: 'Crafting' },
{ id: 'upgrades', label: 'Upgrades' }, { id: 'upgrades', label: 'Upgrades' },
{ id: 'classes', label: 'Classes' }, { id: 'classes', label: 'Classes' },
{ id: 'iwt2', label: 'IWT2' },
] ]
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { 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 === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'upgrades' && <UpgradesTab 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 === 'classes' && <ClassesTab data={data} />}
{tab === 'iwt2' && <Iwt2AdminTab />}
</section> </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 { function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
return { return {
method, 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) { function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
+60 -20
View File
@@ -3,6 +3,7 @@ import {
completeDungeon, completeDungeon,
completeRoguelike, completeRoguelike,
loadProfile, loadProfile,
recordBossKill,
type DungeonReward, type DungeonReward,
rollEncounterLoot, rollEncounterLoot,
type LootRoll, type LootRoll,
@@ -46,7 +47,7 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine' import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions' import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads' import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
@@ -58,7 +59,11 @@ import {
appendCombatLog, appendCombatLog,
type BasicFloatingCombatText, type BasicFloatingCombatText,
} from '../combat/combatPresentation' } from '../combat/combatPresentation'
import { canCastSpell } from '../combat/spellCasting' import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
applySpellEffectProfile, applySpellEffectProfile,
buildSpellTargetPlan, buildSpellTargetPlan,
@@ -69,9 +74,11 @@ import {
useInput, useInput,
} from '../input' } from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText' import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames' 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 { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen' import { ResultScreen } from './ResultScreen'
import { import {
@@ -365,6 +372,7 @@ export function CombatScreen({
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
@@ -510,6 +518,12 @@ export function CombatScreen({
: `${result.encounterName} dropped no components.`, : `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system', result.dropped ? 'loot' : 'system',
) )
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
addLog( addLog(
@@ -521,6 +535,29 @@ export function CombatScreen({
[addLog, difficulty.id], [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 resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode const nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
@@ -552,6 +589,7 @@ export function CombatScreen({
rewardClaimedRef.current = false rewardClaimedRef.current = false
profileRefreshedRef.current = false profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set() rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID() runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0 marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent resourceSpentRef.current = setup.defaults.resourceSpent
@@ -668,10 +706,10 @@ export function CombatScreen({
}) })
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value)) floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost resourceSpentRef.current += effectiveCost
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spellByName.get('Radiance') 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({ setCombat(applyCastStateUpdate({
current, current,
@@ -855,7 +893,7 @@ export function CombatScreen({
const runCombatTick = useCallback(() => { const runCombatTick = useCallback(() => {
const current = combatRef.current const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1 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) let nextResource = regenerateResource(current.resource, 2.4, maxResource)
const living = current.party.filter((member) => member.health > 0) const living = current.party.filter((member) => member.health > 0)
@@ -985,6 +1023,7 @@ export function CombatScreen({
requestLootRoll(encounter.id, rollIndex) requestLootRoll(encounter.id, rollIndex)
} }
} }
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({ setCombat({
@@ -1094,6 +1133,7 @@ export function CombatScreen({
maxResource, maxResource,
gameClass.resourceName, gameClass.resourceName,
requestLootRoll, requestLootRoll,
recordRoguelikeBossKill,
profile.character.name, profile.character.name,
setCombat, setCombat,
startPart, startPart,
@@ -1161,17 +1201,17 @@ export function CombatScreen({
}).reverse(), }).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth], [enemyCount, enemyHealth, encounter.maxHealth],
) )
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => { const spellSlotCost = useCallback(
const spell = spellByKey.get(String(slotIndex + 1)) (spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
return abilityId && spell [freeCastReady, roguelikeUpgradeCounts],
? { )
...spell, const spellSlots = useSpellSlots({
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady), spells,
slotIndex, abilitySlots: profile.abilitySlots,
remaining: cooldowns[spell.id] ?? 0, cooldowns,
} active: status === 'playing' && !paused,
: null cost: spellSlotCost,
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey]) })
const dualScreenState = useMemo(() => buildCombatDualScreenState({ const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: dungeon.name,
@@ -1232,7 +1272,7 @@ export function CombatScreen({
return ( return (
<main <main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`} className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'} data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
@@ -1266,13 +1306,13 @@ export function CombatScreen({
<div className="hard-enemy-bars"> <div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => ( {enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}> <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> <em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div> </div>
))} ))}
</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> <p>{encounter.description}</p>
</div> </div>
+263 -20
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { import {
saveProfile, saveProfile,
type CharacterProfile, type CharacterProfile,
type GameClass, type GameClass,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
import { EquipmentScreen } from './EquipmentScreen' import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen' import { TalentScreen } from './TalentScreen'
@@ -14,20 +15,121 @@ type Props = {
onSaved: (profile: CharacterProfile) => void 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) { 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 { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId) const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots) const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0) const [selectedSlot, setSelectedSlot] = useState(0)
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
const [abilityPage, setAbilityPage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const gameClass = profile.classes.find((candidate) => candidate.id === classId)! const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo( const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])), () => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass], [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(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0) setSelectedSlot(0)
setAbilityPage(0)
setMessage('') setMessage('')
} }
function selectTab(tab: CustomizeTab) {
setActiveTab(tab)
setSelectedNavKey(`tab:${tab}`)
}
function equipAbility(abilityId: number) { function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) { if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.') 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>(() => { const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null if (activeTab !== 'class') return null
return { return {
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
} }
return ( 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 className="screen-heading customize-heading">
<div> <div>
<p className="eyebrow">Character Workshop</p> <p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1> <h1>Customize Character</h1>
</div> </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>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections"> <div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button> <button
{([ className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
{ key: 'equipment', label: 'Equipment' }, data-controller-nav={classNavActive ? 'skip' : undefined}
{ key: 'crafting', label: 'Crafting' }, data-game-selected={selected('back') ? 'true' : undefined}
{ key: 'talents', label: 'Talents' }, onClick={onBack}
{ key: 'class', label: 'Class' }, onPointerDown={() => setSelectedNavKey('back')}
] as const).map((tab) => ( type="button"
>
Back
</button>
{CUSTOMIZE_TABS.map((tab) => (
<button <button
aria-selected={activeTab === tab.key} 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} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab" role="tab"
type="button" type="button"
> >
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Healing Class</p> <p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => ( {profile.classes.map((candidate) => (
<button <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} key={candidate.id}
onClick={() => chooseClass(candidate)} 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" type="button"
> >
<span>{candidate.name[0]}</span> <span>{candidate.name[0]}</span>
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const ability = abilityId ? abilityMap.get(abilityId) : undefined const ability = abilityId ? abilityMap.get(abilityId) : undefined
return ( return (
<button <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} key={index}
onClick={() => setSelectedSlot(index)} onClick={() => setSelectedSlot(index)}
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
ref={navRef(`slot:${index}`)}
type="button" type="button"
> >
<kbd>{index + 1}</kbd> <kbd>{index + 1}</kbd>
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Class Abilities</p> <p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2> <h2>Ability Library</h2>
</div> </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>
<div className="ability-library"> <div className="ability-library">
{gameClass.spells.map((ability) => { {visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id) const equipped = slots.includes(ability.id)
return ( return (
<button <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} disabled={locked}
key={ability.id} key={ability.id}
onClick={() => equipAbility(ability.id)} onClick={() => equipAbility(ability.id)}
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
ref={navRef(`ability:${ability.id}`)}
type="button" type="button"
> >
<span>{locked ? 'L' : ability.glyph}</span> <span>{locked ? 'L' : ability.glyph}</span>
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<div className="save-row"> <div className="save-row">
<span>{message}</span> <span>{message}</span>
<button <button
className="primary-button" className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('save') ? 'true' : undefined}
disabled={saving} disabled={saving}
onClick={persistChanges} onClick={persistChanges}
onPointerDown={() => setSelectedNavKey('save')}
ref={navRef('save')}
type="button" type="button"
> >
{saving ? 'Saving...' : 'Save Character'} {saving ? 'Saving...' : 'Save Character'}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
type HunterProfileTab = 'stats' | 'collection'
type HunterProfileNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
| { kind: 'stat'; key: string; row: number; column: number; index: number }
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
const HUNTER_PROFILE_DROP_COLUMNS = 6
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const statTiles = useMemo(() => [
{
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
},
{
key: 'stat:pvp-matches',
label: 'PvP Matches',
value: matchesPlayed,
},
{
key: 'stat:pvp-wins',
label: 'PvP Wins',
value: matchesWon,
},
{
key: 'stat:pvp-win-rate',
label: 'PvP Win Rate',
value: `${winRate}%`,
},
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
const entries: HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
kind: 'stat' as const,
key: tile.key,
row: 1,
column: index,
index,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
kind: 'boss' as const,
key: `boss:${boss.encounter.id}`,
row: index + 1,
column: 0,
bossId: boss.encounter.id,
})),
...collectionItems.map((item, index) => ({
kind: 'item' as const,
key: `item:${item.key}`,
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: number) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
key={boss.encounter.id}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</strong>
</button>
)
})}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
+4 -3
View File
@@ -7,6 +7,7 @@ import {
memberHotEffects, memberHotEffects,
} from '../combat/rules' } from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = { export type FloatingCombatText = {
id: number id: number
@@ -108,7 +109,7 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
].filter(Boolean).join(' ') ].filter(Boolean).join(' ')
const action = targetBinding ? { const action = targetBinding ? {
binding: targetBinding, binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox', iconStyle: controllerIconStyle ?? 'playstation',
} : null } : null
return ( return (
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>} {showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div> </div>
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} /> <span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />} {member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>} {showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div> </div>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
+237 -25
View File
@@ -9,13 +9,15 @@ import {
type PartyMember, type PartyMember,
type Spell, type Spell,
} from '../game' } from '../game'
import { completeRoguelike, type DungeonReward } from '../profile' import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile' import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' 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 { focusFirstControl, useGameAction, useInput } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { import {
@@ -37,10 +39,14 @@ import {
spellExtraTargets, spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads' import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
nextPvpRoguelikeStage, nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome, resolvePvpRoguelikeCombatOutcome,
@@ -146,6 +152,14 @@ type LivePvpMatch = {
opponentClassName: string 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> = { const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members', id: 'revive-party-members',
name: 'Revive Party Members', name: 'Revive Party Members',
@@ -350,12 +364,14 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([]) const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null) const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null) const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
const [encountersCleared, setEncountersCleared] = useState(0) const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2) const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0) const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false) const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false) const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>()) const bossRewardClaimedRef = useRef(new Set<number>())
@@ -398,12 +414,16 @@ export function PvPRoguelikeScreen({
const cpuAlive = cpuSide.party.some((member) => member.health > 0) const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs]) const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none', () => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog], [cpuSide.buffs, selfBuffChoicesCatalog],
@@ -528,6 +548,12 @@ export function PvPRoguelikeScreen({
'loot', 'loot',
) )
} }
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
setRewardError( setRewardError(
@@ -644,6 +670,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
recordedRunRef.current = false recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -677,6 +704,7 @@ export function PvPRoguelikeScreen({
setStartStage(setup.startStage) setStartStage(setup.startStage)
setStage(setup.startStage) setStage(setup.startStage)
setStatus('queueing') setStatus('queueing')
setOverlaySelectedIndex(0)
setPlayerSide(setup.playerSide) setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide) setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id) setSelectedTargetId(partyTemplate[0].id)
@@ -701,6 +729,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
recordedRunRef.current = false recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -880,9 +909,9 @@ export function PvPRoguelikeScreen({
damageReductionTargets: new Set<string>(), damageReductionTargets: new Set<string>(),
groupTargets, groupTargets,
} }
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) { 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({ return applyPvpSpellCast({
current, current,
@@ -994,7 +1023,7 @@ export function PvPRoguelikeScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, 2.4, maxResource), 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)), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
} }
}, [activeSpellEffects, addFloatingHeal, maxResource]) }, [activeSpellEffects, addFloatingHeal, maxResource])
@@ -1006,6 +1035,7 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3)) setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null) setSelectedBuff(null)
setSelectedDebuff(null) setSelectedDebuff(null)
setOverlaySelectedIndex(0)
setStatus('upgrade-choice') setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
@@ -1086,6 +1116,20 @@ export function PvPRoguelikeScreen({
}) })
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status]) }, [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(() => { useEffect(() => {
if (status !== 'upgrade-choice') return if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl()) 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(`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]) }, [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) => { 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 (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1)) if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return return
} }
if (action === 'pause' || action === 'back') { if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value) if (status === 'playing') {
setOverlaySelectedIndex(0)
setPaused((value) => !value)
}
return return
} }
if (paused || status !== 'playing') return if (paused || status !== 'playing') return
@@ -1339,6 +1510,9 @@ export function PvPRoguelikeScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: cpuSide.enemyHealth, opponentEnemyHealth: cpuSide.enemyHealth,
opponentResource: cpuSide.resource,
opponentMaxResource: maxResource,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1362,6 +1536,7 @@ export function PvPRoguelikeScreen({
cpuDifficulty, cpuDifficulty,
cpuSide.enemyHealth, cpuSide.enemyHealth,
cpuSide.party, cpuSide.party,
cpuSide.resource,
directPartyTargeting, directPartyTargeting,
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
@@ -1397,9 +1572,18 @@ export function PvPRoguelikeScreen({
> >
<section className="content-screen pvp-match-screen"> <section className="content-screen pvp-match-screen">
{status === 'queueing' && ( {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> <div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p> <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> </div>
)} )}
@@ -1424,13 +1608,13 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span> <span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <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> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {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> </div>
</div> </div>
@@ -1464,12 +1648,12 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span> <span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <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> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span> <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> </div>
</div> </div>
@@ -1495,6 +1679,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive} canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
@@ -1514,7 +1699,7 @@ export function PvPRoguelikeScreen({
)} )}
{status === 'upgrade-choice' && ( {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-dialog">
<div className="pvp-upgrade-header"> <div className="pvp-upgrade-header">
<div> <div>
@@ -1529,9 +1714,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid"> <div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => ( {playerBuffChoices.map((choice) => (
<button <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} key={choice.id}
onClick={() => setSelectedBuff(choice)} onClick={() => setSelectedBuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
type="button" type="button"
> >
<strong>{choice.name}</strong> <strong>{choice.name}</strong>
@@ -1545,9 +1732,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid"> <div className="upgrade-choice-grid">
{playerDebuffChoices.map((choice) => ( {playerDebuffChoices.map((choice) => (
<button <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} key={choice.id}
onClick={() => setSelectedDebuff(choice)} onClick={() => setSelectedDebuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
type="button" type="button"
> >
<strong>{choice.name}</strong> <strong>{choice.name}</strong>
@@ -1558,7 +1747,14 @@ export function PvPRoguelikeScreen({
</div> </div>
</div> </div>
{liveUpgradePending && <p>Waiting for opponent choice...</p>} {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'} {liveUpgradePending ? 'Waiting' : 'Continue'}
</button> </button>
</div> </div>
@@ -1566,12 +1762,28 @@ export function PvPRoguelikeScreen({
)} )}
{paused && ( {paused && (
<div className="pause-screen"> <div className="pause-screen" data-game-nav-active="true">
<div> <div>
<p className="eyebrow">Paused</p> <p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2> <h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button> <button
<button className="secondary-result-button" onClick={onExit} type="button">Leave</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>
</div> </div>
)} )}
+57 -27
View File
@@ -7,13 +7,15 @@ import {
type PartyMember, type PartyMember,
type Spell, type Spell,
} from '../game' } from '../game'
import { completeRoguelike } from '../profile' import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile' import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' 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 { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { useDualScreen, useDualScreenPublisher } from '../dualScreen' import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
@@ -31,10 +33,13 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads' import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
} from '../combat/spellCasting'
import { import {
appendCombatLog, appendCombatLog,
STADIUM_COMBAT_LOG_LIMIT, STADIUM_COMBAT_LOG_LIMIT,
@@ -386,6 +391,7 @@ export function PvpStadiumScreen({
const nextLogId = useRef(2) const nextLogId = useRef(2)
const submittedShopRef = useRef(false) const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>()) const awardedXpRef = useRef(new Set<string>())
const matchStatsRecordedRef = useRef(false)
const queuedMatchRef = useRef(false) const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false) const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('') const loggedOpponentRoundRef = useRef('')
@@ -400,12 +406,16 @@ export function PvpStadiumScreen({
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const playerAlive = playerSide.party.some((member) => member.health > 0) const playerAlive = playerSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog), () => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs], [buffCatalog, cpuSide.buffs],
@@ -496,6 +506,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false roundResolvedRef.current = false
setPlayerSide(setup.playerSide) setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide) setCpuSide(setup.opponentSide)
@@ -532,6 +543,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false roundResolvedRef.current = false
setPlayerSide(setup.playerSide) setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide) setCpuSide(setup.opponentSide)
@@ -790,7 +802,7 @@ export function PvpStadiumScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE), resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), cooldowns: pruneExpiredCooldowns(side.cooldowns),
survivalSeconds: nextSurvival, survivalSeconds: nextSurvival,
dampeningPercent, dampeningPercent,
} }
@@ -873,6 +885,20 @@ export function PvpStadiumScreen({
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status]) }, [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 startNextRound = useCallback(() => {
const nextRound = roundIndex + 1 const nextRound = roundIndex + 1
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({ const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
@@ -918,24 +944,23 @@ export function PvpStadiumScreen({
const finishShop = useCallback(() => { const finishShop = useCallback(() => {
if (shopReady || status !== 'shop') return if (shopReady || status !== 'shop') return
const liveMatch = liveMatchRef.current
if (!liveMatch) {
startNextRound()
return
}
setShopReady(true) setShopReady(true)
submittedShopRef.current = true submittedShopRef.current = true
setPlayerSide((current) => { const readyPlayer = { ...playerRef.current, shopReady: true }
const next = { ...current, shopReady: true } playerRef.current = readyPlayer
playerRef.current = next setPlayerSide(readyPlayer)
return next submitPvpUpgradeChoice(liveMatch.id, {
})
if (liveMatchRef.current) {
submitPvpUpgradeChoice(liveMatchRef.current.id, {
encounterIndex: roundIndex, encounterIndex: roundIndex,
buffId: 'stadium-shop', buffId: 'stadium-shop',
debuffId: '', debuffId: '',
purchases: playerRef.current.buffs, purchases: playerRef.current.buffs,
shopReady: true, shopReady: true,
}).catch(() => undefined) }).catch(() => undefined)
return
}
startNextRound()
}, [roundIndex, shopReady, startNextRound, status]) }, [roundIndex, shopReady, startNextRound, status])
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({ const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
@@ -1055,6 +1080,9 @@ export function PvpStadiumScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: 0, opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1083,6 +1111,7 @@ export function PvpStadiumScreen({
controllerIconStyle, controllerIconStyle,
cpuDifficulty, cpuDifficulty,
cpuSide.party, cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds, cpuSide.survivalSeconds,
directPartyTargeting, directPartyTargeting,
dualScreenFloatingTexts, dualScreenFloatingTexts,
@@ -1164,16 +1193,16 @@ export function PvpStadiumScreen({
</section> </section>
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => { {playerSpellSlots.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0 if (!spell) return null
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady) const remaining = spell.remaining
const percent = remaining > 0 const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0 : 0
return ( return (
<button <button
className="dual-top-spell" 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} key={spell.id}
onClick={() => castPlayerSpell(spell)} onClick={() => castPlayerSpell(spell)}
type="button" type="button"
@@ -1188,7 +1217,7 @@ export function PvpStadiumScreen({
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong> <strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /> <span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -1222,7 +1251,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span> <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> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
@@ -1252,7 +1281,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span> <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> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
@@ -1276,6 +1305,7 @@ export function PvpStadiumScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && playerAlive} canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+7 -6
View File
@@ -100,14 +100,15 @@ export function LootRollList({
return ( return (
<div className="run-loot-rolls"> <div className="run-loot-rolls">
{rolls.map((roll) => ( {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> <strong>{roll.encounterName}</strong>
<span> <span>
{roll.items.length > 0 {[
? roll.items ...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) ...(roll.petAwarded
.join(', ') ? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
: 'No components dropped'} : []),
].join(', ') || 'No components dropped'}
</span> </span>
</div> </div>
))} ))}
+281 -48
View File
@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
ACTION_LABELS, ACTION_LABELS,
INPUT_ACTIONS, INPUT_ACTIONS,
useInput, useInput,
useGameAction,
type InputAction,
type InputDevice, type InputDevice,
} from '../input' } from '../input'
import { import {
ControllerBindingLabel, ControllerBindingLabel,
ControllerStylePreview, ControllerStylePreview,
} from './ControllerIcons' } from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen' import { useDualScreen } from '../dualScreen'
import { import {
getNativeDisplays, getNativeDisplays,
@@ -22,21 +18,62 @@ import {
type AndroidDisplay, type AndroidDisplay,
} from '../nativeDualScreen' } 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 }) { export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller') 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 [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([]) const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const { const {
bindings, bindings,
capture, capture,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
combatTouchLocked,
beginCapture, beginCapture,
cancelCapture, cancelCapture,
resetBindings, resetBindings,
setControllerIconStyle, setControllerIconStyle,
setDirectPartyTargeting, setDirectPartyTargeting,
setCombatTouchLocked,
} = useInput() } = useInput()
const { const {
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
@@ -45,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
openTopDisplay, openTopDisplay,
} = useDualScreen() } = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge() const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([ const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
directPartyTargeting directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget' ? 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() { async function refreshNativeDisplays() {
if (!nativeDualScreen) return if (!nativeDualScreen) return
@@ -77,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
.catch(() => setAndroidDisplays([])) .catch(() => setAndroidDisplays([]))
}, [nativeDualScreen]) }, [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() { async function launchTopDisplay() {
const opened = await openTopDisplay() const opened = await openTopDisplay()
setDisplayMessage(opened setDisplayMessage(opened
@@ -88,33 +264,38 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
} }
return ( return (
<section className="content-screen settings-screen"> <section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
<div className="screen-heading"> <div className="settings-nav">
<div>
<p className="eyebrow">Game Options</p>
<h1>Settings</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</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 <button
aria-selected={settingsTab === tab.key} className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
className={settingsTab === tab.key ? 'selected' : ''} data-controller-nav="skip"
key={tab.key} data-game-selected={selected('back') ? 'true' : undefined}
onClick={() => setSettingsTab(tab.key)} 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" role="tab"
type="button" type="button"
> >
{tab.label} {SETTINGS_TAB_LABELS[tab]}
</button> </button>
))} ))}
</nav> </nav>
</div>
{settingsTab === 'display' && ( {settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel"> <section className="dual-screen-settings settings-tab-panel">
@@ -128,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div> </div>
<div className="dual-screen-actions"> <div className="dual-screen-actions">
<button <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={() => { onClick={() => {
setDualScreenEnabled(!dualScreenEnabled) setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('') setDisplayMessage('')
}} }}
onPointerDown={() => selectKey('display:dual-toggle')}
ref={navRef('display:dual-toggle')}
type="button" type="button"
> >
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'} {dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button> </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'} {topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button> </button>
</div> </div>
@@ -174,20 +367,40 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div> </div>
<button <button
aria-pressed={directPartyTargeting} 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)} onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
onPointerDown={() => selectKey('input:direct-targeting')}
ref={navRef('input:direct-targeting')}
type="button" type="button"
> >
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'} {directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button> </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"> <div className="controller-icon-options">
<span>Controller Icons</span> <span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => ( {(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button <button
aria-pressed={controllerIconStyle === style} 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} key={style}
onClick={() => setControllerIconStyle(style)} onClick={() => setControllerIconStyle(style)}
onPointerDown={() => selectKey(`input:icons:${style}`)}
ref={navRef(`input:icons:${style}`)}
type="button" type="button"
> >
<ControllerStylePreview iconStyle={style} /> <ControllerStylePreview iconStyle={style} />
@@ -210,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-tabs"> <div className="binding-tabs">
<button <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')} onClick={() => setDevice('controller')}
onPointerDown={() => selectKey('bindings:device:controller')}
ref={navRef('bindings:device:controller')}
type="button" type="button"
> >
Controller Controller
</button> </button>
<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')} onClick={() => setDevice('pc')}
onPointerDown={() => selectKey('bindings:device:pc')}
ref={navRef('bindings:device:pc')}
type="button" type="button"
> >
PC PC
@@ -228,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-list"> <div className="binding-list">
{visibleActions.map((action) => ( {visibleActions.map((action) => (
<button <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} key={action}
onClick={() => beginCapture(device, action)} onClick={() => beginCapture(device, action)}
onPointerDown={() => selectKey(`bindings:action:${action}`)}
ref={navRef(`bindings:action:${action}`)}
type="button" type="button"
> >
<span>{ACTION_LABELS[action]}</span> <span>{ACTION_LABELS[action]}</span>
@@ -250,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<footer className="settings-footer"> <footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span> <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 Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button> </button>
</footer> </footer>
+9 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import type { ControllerIconStyle } from '../input' import type { ControllerIconStyle } from '../input'
import type { Spell } from '../game' import type { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type SpellSlot = (Spell & { export type SpellSlot = (Spell & {
cost: number cost: number
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`} {unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span> </span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {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> </div>
) )
}) })
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle, iconStyle,
resourceName, resourceName,
disabled, disabled,
focusable = true,
onCast, onCast,
emptyKeyPrefix = 'empty', emptyKeyPrefix = 'empty',
}: { }: {
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle iconStyle: ControllerIconStyle
resourceName: string resourceName: string
disabled?: boolean disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
emptyKeyPrefix?: string emptyKeyPrefix?: string
}) { }) {
@@ -60,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return ( return (
<button <button
className="spell" className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled} disabled={disabled}
key={spell.id} key={spell.id}
onClick={() => onCast(spell)} onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description} title={spell.description}
type="button" type="button"
> >
@@ -94,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast, canCast,
onCast, onCast,
className = 'spell-bar six-slots vertical-spell-bar', className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: { }: {
spells: SpellSlot[] spells: SpellSlot[]
bindings: Record<string, string> bindings: Record<string, string>
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean canCast: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
className?: string className?: string
focusable?: boolean
}) { }) {
return ( return (
<div className={className}> <div className={className}>
@@ -118,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton <SpellButton
binding={bindings[`ability${slotIndex + 1}`]} binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0} disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle} iconStyle={iconStyle}
key={spell.id} key={spell.id}
onCast={onCast} onCast={onCast}
+16
View File
@@ -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`,
}
}
+209 -33
View File
@@ -19,9 +19,11 @@ import {
dispatchExternalGameAction, dispatchExternalGameAction,
type ControllerIconStyle, type ControllerIconStyle,
type InputAction, type InputAction,
useGameAction,
} from './input' } from './input'
import { ControllerBindingLabel } from './components/ControllerIcons' import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames' import { PartyMemberFrame } from './components/PartyFrames'
import { barFillStyle } from './components/barStyles'
import { groupFloatingTextsByMember } from './combat/combatPresentation' import { groupFloatingTextsByMember } from './combat/combatPresentation'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled' const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
@@ -47,6 +49,9 @@ export type DualScreenCombatState = {
opponentClassName?: string opponentClassName?: string
opponentParty?: PartyMember[] opponentParty?: PartyMember[]
opponentEnemyHealth?: number opponentEnemyHealth?: number
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
opponentBuffSummary?: string opponentBuffSummary?: string
opponentDebuffSummary?: string opponentDebuffSummary?: string
floatingTexts: Array<{ floatingTexts: Array<{
@@ -79,7 +84,7 @@ export type DualScreenCombatState = {
} }
export type DualScreenWorkshopState = { export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents' mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
title: string title: string
subtitle: string subtitle: string
summary?: string summary?: string
@@ -92,14 +97,34 @@ export type DualScreenWorkshopState = {
}> }>
} }
export type DualScreenSetupState = {
contentType: 'dungeon' | 'raid'
title: string
subtitle: string
description: string
initials: string
difficultyName: string
itemLevel: number
experience: number
lockedReason?: string
stats: {
health: string
damage: string
xp: string
loot: string
}
}
type DualScreenMessage = type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState } | { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState } | { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'setup-state'; state: DualScreenSetupState }
| { type: 'companion-ready' } | { type: 'companion-ready' }
| { type: 'companion-heartbeat' } | { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction } | { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' } | { type: 'combat-ended' }
| { type: 'workshop-ended' } | { type: 'workshop-ended' }
| { type: 'setup-ended' }
type DualScreenContextValue = { type DualScreenContextValue = {
enabled: boolean enabled: boolean
@@ -145,6 +170,24 @@ function formatDualTime(seconds: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` 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 }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -346,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
} }
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish() if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
} }
publish() publish()
return () => { return () => {
@@ -362,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
}, [enabled, state]) }, [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() { export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot) const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null) const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
useEffect(() => { useEffect(() => {
const channel = createChannel() const channel = createChannel()
@@ -374,13 +464,21 @@ export function DualScreenBottomDisplay() {
if (event.data.type === 'combat-state') { if (event.data.type === 'combat-state') {
setState(event.data.state) setState(event.data.state)
setWorkshopState(null) setWorkshopState(null)
setSetupState(null)
} }
if (event.data.type === 'workshop-state') { if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state) setWorkshopState(event.data.state)
setState(null) 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 === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null) if (event.data.type === 'workshop-ended') setWorkshopState(null)
if (event.data.type === 'setup-ended') setSetupState(null)
} }
announce() announce()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -398,6 +496,14 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
useGameAction((action) => {
if (
!shouldRelayBottomAction(action, state)
&& !shouldRelaySetupAction(action, setupState)
) return
sendAction(action)
})
if (!state && workshopState) { if (!state && workshopState) {
return ( return (
<main className="dual-bottom-display workshop-bottom-display"> <main className="dual-bottom-display workshop-bottom-display">
@@ -432,6 +538,42 @@ export function DualScreenBottomDisplay() {
) )
} }
if (!state && setupState) {
return (
<main className="dual-bottom-display setup-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{setupState.contentType}</p>
<h1>{setupState.title}</h1>
<small>{setupState.subtitle}</small>
</div>
<div className="dual-controls-progress">
<span>{setupState.difficultyName}</span>
<span>iLvl {setupState.itemLevel}</span>
<span>{setupState.experience} XP</span>
</div>
</header>
<section className="setup-bottom-summary">
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
{setupState.initials}
</span>
<div>
<p className="eyebrow">Selected Run</p>
<h2>{setupState.title}</h2>
<p>{setupState.description}</p>
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
</div>
</section>
<dl className="setup-bottom-stats">
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
</dl>
</main>
)
}
if (!state) { if (!state) {
return ( return (
<main className="dual-bottom-display dual-bottom-waiting"> <main className="dual-bottom-display dual-bottom-waiting">
@@ -445,7 +587,10 @@ export function DualScreenBottomDisplay() {
} }
return ( 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"> <header className="dual-controls-header">
<div> <div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p> <p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -477,8 +622,19 @@ export function DualScreenBottomDisplay() {
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong> <strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div> </div>
<div className="bar enemy-health boss-bar"> <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> </div>
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
<>
<div>
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
</div>
<div className="bar mana-bar">
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
</div>
</>
) : null}
</section> </section>
)} )}
@@ -513,7 +669,7 @@ export function DualScreenBottomDisplay() {
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span> <span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -626,8 +782,35 @@ export function DualScreenTopCombat({
[state.floatingTexts], [state.floatingTexts],
) )
const spellButtons = state.spells.map((spell, slotIndex) => {
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return ( return (
<div className="dual-top-main"> <button
className="dual-top-spell"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})
return (
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
<section className="dual-top-enemy"> <section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true"> <div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'} {state.encounterIsBoss ? 'B' : 'M'}
@@ -638,10 +821,25 @@ export function DualScreenTopCombat({
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span> <span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div> </div>
<div className="bar enemy-health"> <div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} /> <span style={barFillStyle(enemyPercent)} />
</div> </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>
</div>
)}
{!state.opponentParty && <p>{state.encounterDescription}</p>}
</div>
{state.opponentParty && (
<div className="dual-top-side-status">
<div className="dual-top-cooldowns" aria-label="Cooldowns">
{spellButtons}
</div>
</div>
)}
</section> </section>
<section className="dual-top-party"> <section className="dual-top-party">
@@ -669,39 +867,17 @@ export function DualScreenTopCombat({
</div> </div>
</section> </section>
{!state.opponentParty && (
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{state.spells.map((spell, slotIndex) => { {spellButtons}
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
className="dual-top-spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})}
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong> <strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
)}
</div> </div>
) )
+257 -22
View File
@@ -17,6 +17,7 @@ import type {
LootRoll, LootRoll,
Item, Item,
EquipmentSlot, EquipmentSlot,
PetAward,
} from './profile' } from './profile'
export type GameMode = 'online' | 'offline' export type GameMode = 'online' | 'offline'
@@ -64,6 +65,14 @@ export interface GameRepository {
difficultyId: number, difficultyId: number,
runToken: string, runToken: string,
): Promise<LootRoll> ): Promise<LootRoll>
recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}>
recordPvpMatch(won: boolean): Promise<CharacterProfile>
} }
type CharacterData = { type CharacterData = {
@@ -76,7 +85,8 @@ type CharacterData = {
} }
type OfflineSave = { type OfflineSave = {
version: 3 version: 4
updatedAt: number
characterName: string characterName: string
activeClassId: number activeClassId: number
completedDungeonParts: number completedDungeonParts: number
@@ -84,6 +94,10 @@ type OfflineSave = {
dungeonCompletions?: Record<string, number> dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData> characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll> lootRolls: Record<string, LootRoll>
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
} }
type OnlineCache = { type OnlineCache = {
@@ -104,6 +118,22 @@ export type CloudSyncStatus = {
dirty: boolean 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 RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
type NetworkError = Error & { type NetworkError = Error & {
@@ -124,12 +154,37 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1' const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6 const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null let activeCatalogCache: CatalogCache | null = null
function clone<T>(value: T): T { function clone<T>(value: T): T {
return structuredClone(value) 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 { function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline' return mode === 'online' ? 'online' : 'offline'
} }
@@ -175,7 +230,8 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
} }
} }
return { return {
version: 3, version: 4,
updatedAt: 0,
characterName: p.character.name, characterName: p.character.name,
activeClassId: p.character.classId, activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts, completedDungeonParts: p.completedDungeonParts,
@@ -185,14 +241,35 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
), ),
characters, characters,
lootRolls: v1.lootRolls ?? {}, 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({ return normalizeSaveAbilitySlots({
...v2, ...v2,
version: 3, version: 4,
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
completedRaidPhases: 0, 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 { function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
for (const character of Object.values(save.characters)) { for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots) character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
} }
@@ -224,9 +302,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile profile?: CharacterProfile
lootRolls?: Record<string, LootRoll> 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) { 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) { if (candidate.version === 1 && candidate.profile) {
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })) 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 raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw) const save = normalizeOfflineSave(raw)
if (!save) return null if (!save) return null
if (raw.version !== 3) { if (raw.version !== 4) {
localStorage.setItem(key, JSON.stringify(save)) localStorage.setItem(key, JSON.stringify(save))
} }
return save return save
@@ -284,7 +365,7 @@ function readOnlineCache(): OnlineCache | null {
save, save,
dirty: Boolean(raw.dirty), dirty: Boolean(raw.dirty),
} }
if ((raw.save as { version?: number } | undefined)?.version !== 3) { if ((raw.save as { version?: number } | undefined)?.version !== 4) {
writeOnlineCache(cache) writeOnlineCache(cache)
} }
return cache return cache
@@ -380,6 +461,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
...dungeon, ...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0, 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_ return static_
} }
@@ -547,7 +634,8 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
inventory: clone(profile.inventory), inventory: clone(profile.inventory),
} }
return { return {
version: 3, version: 4,
updatedAt: existingSave?.updatedAt ?? Date.now(),
characterName: profile.character.name, characterName: profile.character.name,
activeClassId: profile.character.classId, activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts, completedDungeonParts: profile.completedDungeonParts,
@@ -557,6 +645,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
), ),
characters, characters,
lootRolls: clone(existingSave?.lootRolls ?? {}), 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] 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( function awardRoguelikeCoin(
profile: CharacterProfile, profile: CharacterProfile,
sourceEncounterId: number | undefined, sourceEncounterId: number | undefined,
@@ -885,6 +1021,10 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.upgradeItem(itemId), cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.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 { function emptyCharacterData(classId: number): CharacterData {
@@ -914,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
return save return save
} }
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
store.writeSave(stampSave(save))
}
function createLocalRepository(store: LocalSaveStore): GameRepository { function createLocalRepository(store: LocalSaveStore): GameRepository {
return { return {
async loadSession() { async loadSession() {
@@ -961,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} }
save.characters[classId].abilitySlots = slots save.characters[classId].abilitySlots = slots
save.activeClassId = classId save.activeClassId = classId
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) { 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) const updatedProfile = buildProfile(save)
return { return {
@@ -1172,6 +1316,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints, profile.maxTalentPoints,
cd.talentPoints + levelsGained, 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( const bonusItem = awardRoguelikeCoin(
profile, profile,
options?.lootSourceEncounterId, options?.lootSourceEncounterId,
@@ -1179,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
) )
cd.inventory = profile.inventory cd.inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
const updatedProfile = buildProfile(save) const updatedProfile = buildProfile(save)
return { return {
@@ -1196,6 +1351,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
averageItemLevel: updatedProfile.gearStats.averageItemLevel, averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem, bonusItem,
petAwarded,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
@@ -1233,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} }
cd.talentRanks[String(talentId)] = 1 cd.talentRanks[String(talentId)] = 1
} }
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
} }
if (cd.talentPoints <= 0) { if (cd.talentPoints <= 0) {
@@ -1261,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} }
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1 cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1 cd.talentPoints -= 1
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async resetTalents() { async resetTalents() {
@@ -1284,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
cd.talentPoints + refunded, cd.talentPoints + refunded,
) )
} }
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async equipItem(itemId) { async equipItem(itemId) {
@@ -1296,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
} }
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async discardExtraItem(itemId) { 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.') if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1 item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async breakdownItem(itemId) { async breakdownItem(itemId) {
@@ -1350,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} }
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async craftItem(recipeId) { async craftItem(recipeId) {
@@ -1378,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
addInventoryItem(profile.inventory, recipe.item, 1) addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async upgradeItem(itemId) { async upgradeItem(itemId) {
@@ -1421,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (upgraded) upgraded.equipped = true if (upgraded) upgraded.equipped = true
} }
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return buildProfile(save) return buildProfile(save)
}, },
async rollEncounterLoot(encounterId, difficultyId, runToken) { async rollEncounterLoot(encounterId, difficultyId, runToken) {
@@ -1446,6 +1602,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('This encounter has no configured loot.') throw new Error('This encounter has no configured loot.')
} }
const dropChance = entries[0].dropChance const dropChance = entries[0].dropChance
const petAwarded = encounter.isBoss
? recordBossKillInSave(save, encounter)
: null
const items: LootRoll['items'] = [] const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>() const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
@@ -1497,12 +1656,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
awarded: Boolean(item), awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate), duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0, quantityAfter: item?.quantityAfter ?? 0,
petAwarded,
} }
save.lootRolls[rollKey] = result save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) writeStoreSave(store, save)
return clone(result) 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), upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.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 { export function getGameMode(): GameMode {
@@ -1613,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
return buildProfile(synced.save) 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() { export function selectOnlineMode() {
writeMode('online') writeMode('online')
} }
@@ -1627,13 +1857,18 @@ export function createOfflineCharacter(characterName: string): AuthSession {
characters[cid] = emptyCharacterData(cid) characters[cid] = emptyCharacterData(cid)
} }
const save: OfflineSave = { const save: OfflineSave = {
version: 3, version: 4,
updatedAt: Date.now(),
characterName: name, characterName: name,
activeClassId: 1, activeClassId: 1,
completedDungeonParts: 0, completedDungeonParts: 0,
completedRaidPhases: 0, completedRaidPhases: 0,
characters, characters,
lootRolls: {}, lootRolls: {},
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
} }
writeOfflineSave(save) writeOfflineSave(save)
writeMode('offline-local') writeMode('offline-local')
+36
View File
@@ -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
}
+39 -17
View File
@@ -15,29 +15,31 @@ type FloatingCombatTextOptions = {
durationMs?: number durationMs?: number
} }
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900, durationMs = 900,
}: FloatingCombatTextOptions = {}) { }: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([]) const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1) const nextId = useRef(1)
const expirationsRef = useRef(new Map<number, number>()) const expirationsRef = useRef(new Map<number, number>())
const cleanupTimerRef = useRef<number | null>(null)
const clearFloatingTexts = useCallback(() => { const clearCleanupTimer = useCallback(() => {
expirationsRef.current.clear() if (cleanupTimerRef.current === null) return
setFloatingTexts([]) window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, []) }, [])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => { const scheduleCleanup = useCallback(() => {
if (entry.value <= 0) return function scheduleNext() {
const id = nextId.current++ clearCleanupTimer()
expirationsRef.current.set(id, performance.now() + durationMs) let nextExpiry = Infinity
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText)) for (const expiresAt of expirationsRef.current.values()) {
}, [durationMs]) nextExpiry = Math.min(nextExpiry, expiresAt)
}
if (!Number.isFinite(nextExpiry)) return
useEffect(() => { cleanupTimerRef.current = window.setTimeout(() => {
const timer = window.setInterval(() => { cleanupTimerRef.current = null
const now = performance.now() const now = performance.now()
let removed = false let removed = false
for (const [id, expiresAt] of expirationsRef.current) { for (const [id, expiresAt] of expirationsRef.current) {
@@ -45,11 +47,31 @@ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
expirationsRef.current.delete(id) expirationsRef.current.delete(id)
removed = true removed = true
} }
if (!removed) return if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id))) setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS) }
return () => window.clearInterval(timer) 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))
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo( const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts), () => groupFloatingTextsByMember(floatingTexts),
+38
View File
@@ -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 -1
View File
@@ -3,7 +3,9 @@
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)), linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px); repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8; 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; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
+349 -48
View File
@@ -13,6 +13,10 @@ import { Capacitor } from '@capacitor/core'
export type InputDevice = 'pc' | 'controller' export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo' export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export type MovementVector = {
x: number
y: number
}
export const INPUT_ACTIONS = [ export const INPUT_ACTIONS = [
'navigateUp', 'navigateUp',
@@ -37,6 +41,7 @@ export const INPUT_ACTIONS = [
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed', 'toggleSpeed',
'toggleTouchLock',
'pause', 'pause',
] as const ] as const
@@ -66,6 +71,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty6: 'Target Party Member 6', targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group', toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed', toggleSpeed: 'Toggle 2x Speed',
toggleTouchLock: 'Toggle Combat Touch Lock',
pause: 'Pause Menu', pause: 'Pause Menu',
} }
@@ -93,6 +99,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty6: 'F6', targetParty6: 'F6',
toggleTargetGroup: 'Tab', toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote', toggleSpeed: 'Backquote',
toggleTouchLock: 'F7',
pause: 'Escape', pause: 'Escape',
}, },
controller: { controller: {
@@ -115,9 +122,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15', targetParty3: 'Button15',
targetParty4: 'Button13', targetParty4: 'Button13',
targetParty5: 'Button4', targetParty5: 'Button4',
targetParty6: 'Button10', targetParty6: 'Button6',
toggleTargetGroup: 'Button6', toggleTargetGroup: 'Button8',
toggleSpeed: 'Button11', toggleSpeed: 'Button11',
toggleTouchLock: 'Button10',
pause: 'Button9', pause: 'Button9',
}, },
} }
@@ -126,10 +134,20 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30 const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null let lastControllerFocus: HTMLElement | null = null
@@ -144,11 +162,13 @@ type InputContextValue = {
lastDevice: InputDevice lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean directPartyTargeting: boolean
combatTouchLocked: boolean
beginCapture: (device: InputDevice, action: InputAction) => void beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void cancelCapture: () => void
resetBindings: (device: InputDevice) => void resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void setDirectPartyTargeting: (enabled: boolean) => void
setCombatTouchLocked: (locked: boolean) => void
} }
const InputContext = createContext<InputContextValue | null>(null) const InputContext = createContext<InputContextValue | null>(null)
@@ -187,6 +207,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
if (savedController?.targetParty6 === 'Button11') { if (savedController?.targetParty6 === 'Button11') {
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6 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 { return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc }, pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller, controller,
@@ -201,15 +236,18 @@ function loadPreferences() {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as { const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean directPartyTargeting?: boolean
combatTouchLocked?: boolean
} }
return { return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox', controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
directPartyTargeting: saved.directPartyTargeting ?? false, directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
} }
} catch { } catch {
return { return {
controllerIconStyle: 'xbox' as ControllerIconStyle, controllerIconStyle: 'playstation' as ControllerIconStyle,
directPartyTargeting: false, directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
} }
} }
} }
@@ -223,6 +261,7 @@ function bindingGroup(action: InputAction) {
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting' if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting' if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause' if (action === 'pause') return 'pause'
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
return 'navigation' return 'navigation'
} }
@@ -231,6 +270,16 @@ function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0 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() { function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard') const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen') const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
@@ -239,10 +288,22 @@ function focusableElements() {
'.result-screen, .binding-capture, .dual-startup-prompt', '.result-screen, .binding-capture, .dual-startup-prompt',
), ),
).find(isVisible) ).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document const mainContent = Array.from(
return Array.from( document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
scope.querySelectorAll<HTMLElement>(FOCUSABLE_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) ).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) { function rememberFocusableControl(element: HTMLElement) {
@@ -252,12 +313,20 @@ function rememberFocusableControl(element: HTMLElement) {
function focusControl(element: HTMLElement) { function focusControl(element: HTMLElement) {
rememberFocusableControl(element) rememberFocusableControl(element)
element.focus({ preventScroll: true }) 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 const active = document.activeElement
if (active instanceof HTMLElement && candidates.includes(active)) { if (active instanceof HTMLElement && candidates.includes(active)) {
rememberFocusableControl(active)
return active return active
} }
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) { if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
@@ -266,16 +335,34 @@ function currentFocusableControl(candidates = focusableElements()) {
return null 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() { export function focusFirstControl() {
if (hasDedicatedGameNavigation()) return null
const first = focusableElements()[0] const first = focusableElements()[0]
if (first) focusControl(first) if (first) focusControl(first)
return first return first
} }
function moveFocus(action: InputAction) { function moveFocus(action: InputAction, preferControllerFocus = false) {
const candidates = focusableElements() const candidates = focusableElements()
if (candidates.length === 0) return if (candidates.length === 0) return
const current = currentFocusableControl(candidates) const current = currentFocusableControl(candidates, preferControllerFocus)
if (!current) { if (!current) {
focusFirstControl() focusFirstControl()
return return
@@ -286,21 +373,48 @@ function moveFocus(action: InputAction) {
const currentY = currentRect.top + currentRect.height / 2 const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown' const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 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 const ranked = candidates
.filter((candidate) => candidate !== current) .filter((candidate) => candidate !== current)
.map((candidate) => { .map((candidate, index) => {
const rect = candidate.getBoundingClientRect() const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2 const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2 const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX const primary = vertical
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY) ? direction > 0
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 } ? 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) .filter(({ primary }) => primary >= -4)
.sort((a, b) => a.score - b.score) 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 if (!next) return
focusControl(next) focusControl(next)
} }
@@ -313,6 +427,18 @@ function hasUiOverlay() {
).some(isVisible) ).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> = { const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross', 0: 'A / Cross',
1: 'B / Circle', 1: 'B / Circle',
@@ -408,6 +534,10 @@ function isCombatActive() {
return Boolean(document.querySelector('[data-combat-active="true"]')) return Boolean(document.querySelector('[data-combat-active="true"]'))
} }
function hasContinuousMovementActive() {
return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
}
function firstConnectedGamepad() { function firstConnectedGamepad() {
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
} }
@@ -428,6 +558,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(loadPreferences) const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null) const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false) const [keyboardShift, setKeyboardShift] = useState(false)
const [touchLockMessage, setTouchLockMessage] = useState('')
const bindingsRef = useRef(bindings) const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences) const preferencesRef = useRef(preferences)
const captureRef = useRef(capture) const captureRef = useRef(capture)
@@ -435,6 +566,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const previousTokensRef = useRef(new Set<string>()) const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({}) const repeatRef = useRef<Record<string, number>>({})
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform()) const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
const touchLockMessageTimerRef = useRef(0)
useEffect(() => { useEffect(() => {
bindingsRef.current = bindings bindingsRef.current = bindings
@@ -481,22 +613,58 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) 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) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
if (action.startsWith('navigate')) { if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (uiOverlay || !combatActive) moveFocus(action) 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') { } else if (action === 'confirm') {
const active = currentFocusableControl() const active = currentFocusableControl(focusableElements(), controllerUiInput)
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) 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 ( } else if (
active active
&& active.matches('button:not(:disabled), [role="button"]') && active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active) && isVisible(active)
) { ) {
if (controllerUiInput) focusControl(active)
active.click() active.click()
} else { } else {
focusFirstControl() focusFirstControl()
@@ -512,9 +680,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
} }
} }
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { dispatchGameAction(action, device)
detail: { action, device },
}))
}, [closeKeyboard]) }, [closeKeyboard])
const dispatchControllerToken = useCallback((token: string, repeat = false) => { const dispatchControllerToken = useCallback((token: string, repeat = false) => {
@@ -528,12 +694,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'), document.querySelector('[data-combat-active="true"]'),
) )
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = { if (
Button12: 'navigateUp', combatActive
Button13: 'navigateDown', && !uiOverlay
Button14: 'navigateLeft', && hasContinuousMovementActive()
Button15: 'navigateRight', && (token.startsWith('Axis0') || token.startsWith('Axis1'))
} ) return
const uiPriority = [ const uiPriority = [
'navigateUp', 'navigateUp',
'navigateDown', 'navigateDown',
@@ -551,8 +717,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed', 'toggleSpeed',
'toggleTouchLock',
] satisfies InputAction[] ] satisfies InputAction[]
const combatPriority = [ const combatPriority = [
'toggleTouchLock',
'pause', 'pause',
'toggleSpeed', 'toggleSpeed',
'ability1', 'ability1',
@@ -568,18 +736,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft', 'navigateLeft',
'navigateRight', 'navigateRight',
] satisfies InputAction[] ] satisfies InputAction[]
const action = menuDpadActions[token] && (!combatActive || uiOverlay) const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: uiOverlay : uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting : combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find( ? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
: combatActive && menuDpadActions[token] : combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: !combatActive && menuDpadActions[token] : !combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find( : (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
@@ -618,15 +786,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
const target = event.target const target = event.target
if (!(target instanceof HTMLElement)) return if (!(target instanceof HTMLElement)) return
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
if (document.documentElement.dataset.inputDevice !== 'controller') return
rememberFocusableControl(target) rememberFocusableControl(target)
} }
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
document.documentElement.dataset.inputDevice = 'pc' document.documentElement.dataset.inputDevice = 'pc'
}
const target = event.target const target = event.target
if (!(target instanceof Element)) return if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR) const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
if (!control || !isVisible(control)) return if (!control || !isVisible(control)) return
rememberFocusableControl(control) if (event.pointerType !== 'touch') rememberFocusableControl(control)
} }
document.addEventListener('focusin', onFocusIn) document.addEventListener('focusin', onFocusIn)
document.addEventListener('pointerdown', onPointerDown, { capture: true }) document.addEventListener('pointerdown', onPointerDown, { capture: true })
@@ -645,10 +816,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener) return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken]) }, [dispatchControllerToken])
useEffect(() => () => {
window.clearTimeout(touchLockMessageTimerRef.current)
}, [])
useEffect(() => { useEffect(() => {
let focusFrame = 0
const ensureFocus = () => { const ensureFocus = () => {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]') const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return if (combatActive) return
if (hasDedicatedGameNavigation()) return
const candidates = focusableElements() const candidates = focusableElements()
const activeControl = currentFocusableControl(candidates) const activeControl = currentFocusableControl(candidates)
if ( if (
@@ -663,22 +841,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
} }
} }
} }
const scheduleEnsureFocus = () => {
if (focusFrame) return
focusFrame = window.requestAnimationFrame(ensureFocus)
}
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus) scheduleEnsureFocus()
}) })
observer.observe(document.getElementById('root') ?? document.body, { observer.observe(document.getElementById('root') ?? document.body, {
attributes: true, attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'], attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
childList: true, childList: true,
subtree: true, subtree: true,
}) })
window.requestAnimationFrame(ensureFocus) scheduleEnsureFocus()
return () => observer.disconnect() return () => {
if (focusFrame) window.cancelAnimationFrame(focusFrame)
observer.disconnect()
}
}, []) }, [])
useEffect(() => { useEffect(() => {
let timer = 0 let timer = 0
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
previousTokensRef.current = new Set()
repeatRef.current = {}
return undefined
}
const nextDelay = () => { const nextDelay = () => {
if (Capacitor.isNativePlatform()) {
return GAMEPAD_COMBAT_POLL_MS
}
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) { if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
} }
@@ -705,11 +898,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
const action = INPUT_ACTIONS.find( const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token, (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 const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) { if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed) dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125) repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
} }
}) })
@@ -735,7 +928,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.removeEventListener('gamepadconnected', onGamepadConnected) window.removeEventListener('gamepadconnected', onGamepadConnected)
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected) 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>(() => ({ const contextValue = useMemo<InputContextValue>(() => ({
bindings, bindings,
@@ -743,6 +968,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
lastDevice, lastDevice,
controllerIconStyle: preferences.controllerIconStyle, controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting, directPartyTargeting: preferences.directPartyTargeting,
combatTouchLocked: preferences.combatTouchLocked,
beginCapture: (device, action) => setCapture({ device, action }), beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null), cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({ resetBindings: (device) => setBindings((current) => ({
@@ -757,6 +983,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
...current, ...current,
directPartyTargeting, directPartyTargeting,
})), })),
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
...current,
combatTouchLocked,
})),
}), [bindings, capture, lastDevice, preferences]) }), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) { function typeKeyboardKey(key: string) {
@@ -821,10 +1051,83 @@ export function InputProvider({ children }: { children: ReactNode }) {
</section> </section>
</div> </div>
)} )}
{touchLockMessage && (
<div className="combat-touch-lock-status" aria-live="polite">
{touchLockMessage}
</div>
)}
</InputContext.Provider> </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>()
const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
)
const updateMovement = () => {
const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const keyboard = keyboardVector()
movementRef.current = Math.hypot(stick.x, stick.y) > 0
? stick
: keyboard
frame = window.requestAnimationFrame(updateMovement)
}
const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.add(event.code)
document.documentElement.dataset.inputDevice = 'pc'
event.preventDefault()
}
const onKeyUp = (event: KeyboardEvent) => {
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.delete(event.code)
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 }
}
}, [enabled])
return movementRef
}
export function useInput() { export function useInput() {
const context = useContext(InputContext) const context = useContext(InputContext)
if (!context) throw new Error('useInput must be used inside InputProvider') if (!context) throw new Error('useInput must be used inside InputProvider')
@@ -852,7 +1155,5 @@ export function dispatchExternalGameAction(
action: InputAction, action: InputAction,
device: InputDevice, device: InputDevice,
) { ) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { dispatchGameAction(action, device)
detail: { action, device },
}))
} }
+11 -1
View File
@@ -2,14 +2,24 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import './index.css' import './index.css'
import './App.css'
import App from './App.tsx' import App from './App.tsx'
import { InputProvider } from './input.tsx' import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? ( {displayMode === 'iwt2-bottom' ? (
<InputProvider>
<Iwt2BottomDisplay />
</InputProvider>
) : displayMode === 'bottom' ? (
<InputProvider>
<DualScreenBottomDisplay /> <DualScreenBottomDisplay />
</InputProvider>
) : ( ) : (
<DualScreenProvider> <DualScreenProvider>
<DualScreenStartupPrompt /> <DualScreenStartupPrompt />
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
import { useEffect, useState } from 'react'
import { useGameAction } from '../../input'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
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 type { Iwt2BossId } from './content/bosses'
import { abilitiesForHealer } 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 = {
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 [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)
useEffect(() => {
writeIwt2Save(save)
}, [save])
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}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'roguelike-arena' && roguelikeRun) {
return (
<BossArenaScreen
bossId={selectedBossId}
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(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage))
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={() => {
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType)
setRoguelikeRun(nextRun)
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
setScreen('roguelike-arena')
}}
onVariantChange={(nextVariant) => {
setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon')
}
}}
/>
</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"
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 bossForRoguelike(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
stage = 1,
): Iwt2BossId {
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
const pool = contentType === 'raid'
? raidBosses
: contentType === 'stadium'
? stadiumBosses
: dungeonBosses
return pool[(stage - 1) % pool.length]
}
function createRoguelikeRun(
save: Iwt2Save,
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
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,
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
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>
)
}
+81
View File
@@ -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>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
export function AbilityBar({
abilities,
cooldowns,
mana,
onCast,
}: {
abilities: Iwt2HealerAbility[]
cooldowns: Record<string, number>
mana: number
onCast: (ability: Iwt2HealerAbility) => void
}) {
return (
<div className="iwt2-ability-bar">
{abilities.map((ability) => {
const remaining = cooldowns[ability.id] ?? 0
const disabled = remaining > 0 || mana < ability.manaCost
return (
<button
aria-label={`${ability.slot}. ${ability.name}`}
className="iwt2-ability-button"
disabled={disabled}
key={ability.id}
onClick={() => onCast(ability)}
title={`${ability.name} - ${ability.manaCost} MP`}
type="button"
>
<span>{ability.icon}</span>
<strong>{ability.slot}</strong>
<i>{remaining > 0 ? `${remaining.toFixed(1)}s` : `${ability.manaCost} MP`}</i>
</button>
)
})}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
export function ArenaBar({
className = '',
current,
max,
shield = 0,
}: {
className?: string
current: number
max: number
shield?: number
}) {
const percent = max > 0 ? Math.max(0, Math.min(100, (current / max) * 100)) : 0
const shieldPercent = max > 0 ? Math.max(0, Math.min(100, (shield / max) * 100)) : 0
return (
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return (
<div className="iwt2-boss-hud">
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { ControllerBindingLabel } from '../../../components/ControllerIcons'
import type { ControllerIconStyle } from '../../../input'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
export function PartyFrames({
party,
selectedPartyId,
targetBindings,
controllerIconStyle,
onTarget,
}: {
party: Iwt2PartyEntityState[]
selectedPartyId: Iwt2EntityId
targetBindings?: Array<string | null>
controllerIconStyle?: ControllerIconStyle
onTarget: (id: Iwt2EntityId) => void
}) {
return (
<aside className="iwt2-party-list" data-game-nav-active="true">
{party.map((member) => {
const meta = IWT2_CLASS_METADATA[member.classId]
const selected = member.id === selectedPartyId
const targetBinding = targetBindings?.[party.indexOf(member)]
return (
<button
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
key={member.id}
onClick={() => onTarget(member.id)}
type="button"
>
{targetBinding && (
<span className="iwt2-party-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={controllerIconStyle ?? 'playstation'}
/>
</span>
)}
<span style={{ background: meta.color }}>{meta.icon}</span>
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
<div className="iwt2-party-effects">
{member.hotEffects.map((effect) => (
<span className="iwt2-effect-badge hot" key={effect.id}>
{effect.label} {Math.ceil(effect.remainingSeconds)}s
</span>
))}
{member.shield > 0 && (
<span className="iwt2-effect-badge shield">
Shield {Math.ceil(member.shield)}
</span>
)}
</div>
<small className="iwt2-party-damage">Damage {Math.round(member.damageDone)}</small>
</div>
</button>
)
})}
</aside>
)
}
@@ -0,0 +1,6 @@
import type { Iwt2BalanceOverrides } from './bosses'
// Generated by local admin panel. Commit this file with intended IWT2 balance changes.
export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = {
bosses: {},
}
+226
View File
@@ -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,
}
+121
View File
@@ -0,0 +1,121 @@
export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogue' | 'warrior'
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
color: string
accentColor: string
maxHealth: number
moveSpeed: number
radius: number
attackRange: number
attackDamage: number
attackCooldown: number
castTime: number
projectileSpeed: number
}
export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> = {
healer: {
id: 'healer',
name: 'Player Healer',
role: 'healer',
icon: '+',
color: '#2fbf71',
accentColor: '#b7f7cf',
maxHealth: 125,
moveSpeed: 210,
radius: 15,
attackRange: 0,
attackDamage: 0,
attackCooldown: 1,
castTime: 0,
projectileSpeed: 0,
},
paladin: {
id: 'paladin',
name: 'Paladin Tank',
role: 'tank',
icon: '🛡',
color: '#f2c94c',
accentColor: '#fff0a8',
maxHealth: 190,
moveSpeed: 165,
radius: 17,
attackRange: 44,
attackDamage: 7,
attackCooldown: 0.85,
castTime: 0,
projectileSpeed: 0,
},
ranger: {
id: 'ranger',
name: 'Ranger',
role: 'damage',
icon: '🏹',
color: '#4aa3df',
accentColor: '#b9e3ff',
maxHealth: 115,
moveSpeed: 185,
radius: 14,
attackRange: 245,
attackDamage: 8,
attackCooldown: 0.62,
castTime: 0.32,
projectileSpeed: 410,
},
mage: {
id: 'mage',
name: 'Mage',
role: 'damage',
icon: '⚚',
color: '#c56cf0',
accentColor: '#efc4ff',
maxHealth: 100,
moveSpeed: 175,
radius: 14,
attackRange: 220,
attackDamage: 11,
attackCooldown: 0.78,
castTime: 0.42,
projectileSpeed: 330,
},
rogue: {
id: 'rogue',
name: 'Rogue',
role: 'damage',
icon: '††',
color: '#55efc4',
accentColor: '#c8fff2',
maxHealth: 110,
moveSpeed: 230,
radius: 14,
attackRange: 38,
attackDamage: 6,
attackCooldown: 0.38,
castTime: 0,
projectileSpeed: 0,
},
warrior: {
id: 'warrior',
name: 'Warrior',
role: 'damage',
icon: '⚔',
color: '#ff7675',
accentColor: '#ffd0d0',
maxHealth: 150,
moveSpeed: 180,
radius: 16,
attackRange: 42,
attackDamage: 9,
attackCooldown: 0.52,
castTime: 0,
projectileSpeed: 0,
},
}
export const IWT2_PARTY_ORDER: Iwt2PlayerClassId[] = ['healer', 'paladin', 'ranger', 'mage', 'rogue', 'warrior']
+19
View File
@@ -0,0 +1,19 @@
import type { InputAction } from '../../../input'
export const IWT2_ABILITY_ACTIONS: InputAction[] = [
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
]
export const IWT2_TARGET_ACTIONS: InputAction[] = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
]
+116
View File
@@ -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',
}
}
+18
View File
@@ -0,0 +1,18 @@
export {
IWT2_CLASS_METADATA,
IWT2_PARTY_ORDER,
} from './classes'
export type {
Iwt2ClassMetadata,
Iwt2PlayerClassId,
Iwt2PlayerClassRole,
} from './classes'
export {
BULLDROME_BOSS_METADATA,
IWT2_BOSS_METADATA,
} from './bosses'
export type {
Iwt2BossId,
Iwt2BossMetadata,
} from './bosses'
+65
View File
@@ -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',
})
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef, type MutableRefObject } from 'react'
import Phaser from 'phaser'
import type { MovementVector } from '../../../input'
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
import type { Iwt2ArenaState } from '../sim/arenaState'
type PhaserArenaProps = {
movementRef: MutableRefObject<MovementVector>
selectedPartyIdRef: MutableRefObject<string>
stateRef: MutableRefObject<Iwt2ArenaState>
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!hostRef.current) return undefined
const initialState = stateRef.current
const scene = new BulldromeArenaScene({
getMovement: () => movementRef.current,
getSelectedPartyId: () => selectedPartyIdRef.current,
getState: () => stateRef.current,
step: onStep,
})
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: hostRef.current,
width: initialState.bounds.width,
height: initialState.bounds.height,
backgroundColor: '#10141b',
pixelArt: false,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene,
})
return () => {
game.destroy(true)
}
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
return <div className="iwt2-phaser-host" ref={hostRef} />
}
@@ -0,0 +1,281 @@
import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA } from '../../content/bosses'
import { IWT2_CLASS_METADATA } from '../../content/classes'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from '../../sim'
type SceneDeps = {
getMovement: () => MovementVector
getSelectedPartyId: () => string
getState: () => Iwt2ArenaState
step: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
private arenaGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private labels = new Map<string, Phaser.GameObjects.Text>()
private lastHudPublish = 0
constructor(deps: SceneDeps) {
super('bulldrome-arena')
this.deps = deps
}
create() {
this.cameras.main.setBackgroundColor('#10141b')
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.entityGraphics = this.add.graphics()
this.drawState(this.deps.getState())
}
update(_: number, delta: number) {
const state = this.deps.step(this.deps.getMovement(), Math.min(0.05, delta / 1000))
this.drawState(state)
}
private drawState(state: Iwt2ArenaState) {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.lastHudPublish += 1
}
private drawArena(state: Iwt2ArenaState) {
const graphics = this.arenaGraphics!
graphics.clear()
graphics.lineStyle(3, 0x3c4a59, 1)
graphics.fillStyle(0x171d24, 1)
graphics.fillRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
graphics.strokeRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
graphics.lineStyle(1, 0x26313d, 0.55)
for (let x = 80; x < state.bounds.width; x += 80) {
graphics.lineBetween(x, 0, x, state.bounds.height)
}
for (let y = 80; y < state.bounds.height; y += 80) {
graphics.lineBetween(0, y, state.bounds.width, y)
}
}
private drawTelegraphs(state: Iwt2ArenaState) {
const graphics = this.telegraphGraphics!
graphics.clear()
state.indicators.forEach((indicator) => drawIndicator(graphics, indicator))
}
private drawEntities(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
const liveIds = new Set<string>(entities.map((entity) => entity.id))
for (const entity of entities.sort(entitySort)) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
const color = entityColor(entity)
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
if (entity.kind === 'boss') {
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
} else if (entity.kind === 'hostileAdd') {
const wing = entity.radius * 1.55
graphics.fillTriangle(
entity.position.x + entity.facing.x * wing,
entity.position.y,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y - entity.radius,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y + entity.radius,
)
graphics.strokeTriangle(
entity.position.x + entity.facing.x * wing,
entity.position.y,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y - entity.radius,
entity.position.x - entity.facing.x * entity.radius,
entity.position.y + entity.radius,
)
} else {
if (entity.id === this.deps.getSelectedPartyId()) {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
}
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
this.drawLabel(entity)
this.drawHealthBar(graphics, entity)
}
for (const [id, label] of this.labels) {
if (!liveIds.has(id)) {
label.destroy()
this.labels.delete(id)
}
}
}
private drawProjectiles(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
for (const projectile of state.projectiles) {
const color = Number.parseInt(projectile.color.slice(1), 16)
graphics.fillStyle(color, 0.95)
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
}
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entityIcon(entity)
if (!label) {
label = this.add.text(entity.position.x, entity.position.y, icon, {
align: 'center',
color: '#fff7df',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px',
fontStyle: '900',
}).setOrigin(0.5)
this.labels.set(entity.id, label)
}
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
label.setText(stunned ? '!' : icon)
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
}
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
const width = entity.kind === 'boss' ? 92 : 34
const height = entity.kind === 'boss' ? 8 : 7
const y = entity.position.y + entity.radius + 8
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
graphics.fillStyle(0x050608, 0.9)
graphics.fillRect(entity.position.x - width / 2, y, width, height)
graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1)
graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height)
if (entity.kind === 'party' && entity.shield > 0) {
const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth))
graphics.fillStyle(0x58a8ff, 0.92)
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
}
}
}
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
return rank[a.kind] - rank[b.kind]
}
function entityColor(entity: DrawableEntity): string {
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
if (entity.kind === 'hostileAdd') return '#f0b84f'
return IWT2_CLASS_METADATA[entity.classId].color
}
function entityIcon(entity: DrawableEntity): string {
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon
if (entity.kind === 'hostileAdd') return 'v'
return IWT2_CLASS_METADATA[entity.classId].icon
}
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
graphics.fillStyle(0xff7a2f, alpha)
graphics.lineStyle(2, 0xffc15a, 0.55)
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
}
function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) {
const color = Number.parseInt(indicator.color.slice(1), 16)
const active = indicator.phase === 'active'
graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5))
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
if (indicator.kind === 'lane') {
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
if (indicator.kind === 'circle') {
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius)
return
}
if (indicator.kind === 'cone') {
const points = coneDangerPolygon(
indicator.origin,
indicator.direction,
indicator.range,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
}
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.max(1, Math.hypot(dx, dy))
const normal = {
x: (-dy / length) * halfWidth,
y: (dx / length) * halfWidth,
}
return [
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y),
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y),
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y),
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y),
]
}
function coneDangerPolygon(
origin: Iwt2Vec2,
direction: Iwt2Vec2,
range: number,
angleRadians: number,
) {
const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
const segmentCount = 14
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
origin.x + Math.cos(angle) * range,
origin.y + Math.sin(angle) * range,
))
}
return points
}
+220
View File
@@ -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 ?? {},
},
}
}
+509
View File
@@ -0,0 +1,509 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
createInitialIwt2ArenaState,
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKill,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
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
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, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const killRecordedRef = useRef(false)
const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({})
const movementRef = useMovementVectorRef(status === 'playing')
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
statusRef.current = status
}, [status])
useEffect(() => {
selectedOverlayActionRef.current = selectedOverlayAction
}, [selectedOverlayAction])
useEffect(() => {
selectedPartyIdRef.current = selectedPartyId
}, [selectedPartyId])
useEffect(() => {
saveRef.current = save
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId)
killRecordedRef.current = false
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
setArenaState(next)
setAbilityCooldowns({})
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
setStatus(nextStatus)
}, [])
const abilities = useMemo(
() => 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')
else onBack()
return
}
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
return
}
if (
device === 'controller'
&& statusRef.current === 'playing'
&& !directPartyTargeting
&& (
action === 'navigateUp'
|| action === 'navigateLeft'
|| action === 'previousTarget'
|| action === 'navigateDown'
|| action === 'navigateRight'
|| action === 'nextTarget'
)
) {
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
return
}
if (action.startsWith('targetParty')) {
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
if (statusRef.current !== 'playing') return stateRef.current
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
const next = tickIwt2Arena(stateRef.current, {
moveX: movement.x,
moveY: movement.y,
}, dtSeconds)
stateRef.current = next
if (next.boss.health <= 0 && !killRecordedRef.current) {
killRecordedRef.current = true
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
saveRef.current = updatedSave
onSaveUpdated(updatedSave)
showOverlay('victory')
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
const hudSignature = arenaHudSignature(next)
if (
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.boss.health <= 0
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
setArenaState(next)
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [bossId, onSaveUpdated, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
: undefined
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossMetadata.name} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
const overlayEyebrow = status === 'victory'
? 'Hunt Complete'
: status === 'defeat'
? 'Recovery'
: 'Paused'
const overlayTone = status === 'victory'
? 'is-victory'
: status === 'defeat'
? 'is-defeat'
: 'is-paused'
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'}`
return (
<main
className="game-shell iwt2-arena-shell"
data-combat-active={status === 'playing' ? 'true' : 'false'}
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
>
<section className="iwt2-arena-layout">
<div className="iwt2-arena-stage">
<BossHud boss={arenaState.boss} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
party={arenaState.party}
selectedPartyId={selectedPartyId}
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status === '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"
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
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>{bossMetadata.icon}</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>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+125 XP</span>
<span>Common carve</span>
<span>Log +1</span>
</div>
)}
<div className="iwt2-overlay-actions">
<button
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => 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, B exits</small>
)}
</div>
</div>
)}
<AbilityBar
abilities={abilities}
cooldowns={abilityCooldowns}
mana={playerMana}
onCast={castAbility}
/>
</div>
</section>
</main>
)
}
function formatArenaTime(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainder = safeSeconds % 60
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
state.boss.id,
Math.ceil(state.boss.health),
state.boss.attackPhase,
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
member.id,
Math.ceil(member.health),
Math.ceil(member.shield),
Math.ceil(member.mana),
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
].join(':')),
].join('|')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
const nextIndex = (currentIndex + direction + targets.length) % targets.length
return targets[nextIndex]?.id ?? currentId
}
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
let changed = false
const next: Record<string, number> = {}
for (const [id, remaining] of Object.entries(cooldowns)) {
const value = Math.max(0, remaining - dt)
if (value !== remaining) changed = true
if (value > 0) next[id] = value
}
return changed ? next : cooldowns
}
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'
}
File diff suppressed because it is too large Load Diff
+661
View File
@@ -0,0 +1,661 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
import { separateCircles } from './collision'
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
const DEFAULT_ARENA_WIDTH = 960
const DEFAULT_ARENA_HEIGHT = 540
const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
type InitialPartyMember = {
id: Iwt2PartyEntityId
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
x: number
y: number
preferredOffset: { x: number; y: number }
decisionOffset: number
}
const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 },
{ id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 },
{ id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 },
{ id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 },
{ id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 },
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
boss: {
id: bossId,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
},
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
nextHazardId: 1,
events: [],
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
if (bossId === 'khezu') return 3
return 0
}
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
if (bossId === 'yian-kut-ku') return 1.2
if (bossId === 'khezu') return 1.6
return 0
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
const step = Math.max(0, Math.min(dt, MAX_DT))
if (step <= 0) return { ...state, events: [...state.events] }
const inputMove = {
x: clampInputAxis(input.moveX),
y: clampInputAxis(input.moveY),
}
const baseState = {
...state,
time: state.time + step,
party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)),
events: [],
}
const separatedState = {
...baseState,
party: separatePartyFromBoss(baseState.party, baseState),
}
const bossResult = tickBoss(separatedState, step)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.boss,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId,
bossResult.nextProjectileId ?? state.nextProjectileId,
separatedState.bounds,
separatedState.time,
step,
)
const hazardResult = tickGroundHazards({
dt: step,
hazards: projectileResult.hazards,
party: projectileResult.party,
time: separatedState.time,
})
const damageResult = applyPartyAttacks(
hazardResult.party,
projectileResult.boss,
projectileResult.hostileAdds,
separatedState.time,
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
...separatedState,
boss: damageResult.boss,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty,
hostileAdds: damageResult.hostileAdds,
hazards: hazardResult.hazards,
projectiles: [...projectileResult.projectiles, ...damageResult.projectiles],
nextProjectileId: damageResult.nextProjectileId,
nextAddId: bossResult.nextAddId ?? state.nextAddId,
nextHazardId: projectileResult.nextHazardId,
nextEventId: state.nextEventId + nextEvents.length,
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
}
}
function tickPartyHotEffects(
party: Iwt2PartyEntityState[],
dt: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const events: Iwt2ArenaEvent[] = []
const nextParty = party.map((member) => {
if (member.hotEffects.length === 0) return member
let health = member.health
const hotEffects = member.hotEffects.flatMap((effect) => {
const remainingSeconds = effect.remainingSeconds - dt
if (remainingSeconds <= 0) return []
let nextTickInSeconds = effect.nextTickInSeconds - dt
if (member.health > 0 && nextTickInSeconds <= 0) {
const before = health
health = Math.min(member.maxHealth, health + effect.power)
const healed = health - before
if (healed > 0) {
events.push({
id: 0,
time,
type: 'partyHealed',
sourceId: 'player-healer',
targetId: member.id,
value: healed,
})
}
nextTickInSeconds += effect.tickIntervalSeconds
}
return [{
...effect,
nextTickInSeconds,
remainingSeconds,
}]
})
return {
...member,
health,
hotEffects,
}
})
return { party: nextParty, events }
}
function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member
return {
...member,
mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt),
}
})
}
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
return {
id: initial.id,
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
shield: 0,
mana: initial.classId === 'healer' ? 100 : 0,
maxMana: initial.classId === 'healer' ? 100 : 0,
damageDone: 0,
attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35,
castSecondsRemaining: 0,
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
decisionSecondsRemaining: initial.decisionOffset,
}
}
function createEmptyStatus(): Iwt2StatusState {
return {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
}
}
function clampInputAxis(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(-1, value))
}
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const position = separateCircles(
{ position: member.position, radius: member.radius },
{ position: state.boss.position, radius: state.boss.radius },
state.bounds,
)
return { ...member, position }
})
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
nextProjectileId: number,
bounds: Iwt2ArenaState['bounds'],
time: number,
dt: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextProjectileId: number
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBoss = boss
let nextParty = party
let nextHostileAdds = hostileAdds
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
const nextProjectiles: Iwt2ProjectileEntityState[] = []
for (const projectile of projectiles) {
if (projectile.owner === 'boss') {
const result = advanceBossProjectile({
bounds,
dt,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile,
time,
})
nextParty = result.party
nextHazards = result.hazards
nextHazardIdValue = result.nextHazardId
events.push(...result.events)
if (result.projectile) nextProjectiles.push(result.projectile)
continue
}
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
const nextPosition = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
const remainingSeconds = projectile.remainingSeconds - dt
const hitAdd = nextHostileAdds.find((add) => (
add.health > 0
&& distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius
))
if (remainingSeconds <= 0) continue
if (hitAdd) {
const damage = Math.min(projectile.damage, hitAdd.health)
nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
value: damage,
})
if (hitAdd.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
})
}
continue
}
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
const damage = Math.min(projectile.damage, nextBoss.health)
nextBoss = {
...nextBoss,
health: Math.max(0, nextBoss.health - damage),
}
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
value: damage,
})
if (nextBoss.health <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
})
}
continue
}
nextProjectiles.push({
...projectile,
position: nextPosition,
remainingSeconds,
})
}
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
nextProjectileId,
projectiles: nextProjectiles,
events,
}
}
function advanceBossProjectile({
bounds,
dt,
hazards,
nextHazardId,
party,
projectile,
time,
}: {
bounds: Iwt2ArenaState['bounds']
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
projectile: Iwt2ProjectileEntityState
time: number
}): {
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
projectile?: Iwt2ProjectileEntityState
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const remainingSeconds = projectile.remainingSeconds - dt
if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events }
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
let bounced = false
const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt)
const canBounce = bounceCooldownSeconds <= 0
let velocity = { ...projectile.velocity }
let position = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) {
velocity = { ...velocity, x: -velocity.x }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) {
velocity = { ...velocity, y: -velocity.y }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
const hitMember = canBounce
? nextParty.find((member) => (
member.health > 0
&& distanceVec2(position, member.position) <= projectile.radius + member.radius
))
: undefined
if (hitMember) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: hitMember.position,
radius: hitMember.radius + projectile.radius,
}, {
damage: projectile.damage,
sourceId: projectile.sourceId,
time,
})
nextParty = result.party
events.push(...result.events)
const away = normalizeVec2(subtractVec2(position, hitMember.position))
const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1))
const direction = away.x === 0 && away.y === 0 ? fallback : away
const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y))
velocity = scaleVec2(direction, speed)
position = clampVec2ToArena({
x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2),
y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2),
}, projectile.radius, bounds)
bounced = true
}
if (bounced) {
const puddle = addFirePuddle({
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: hitMember?.position ?? position,
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
sourceId: projectile.sourceId,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
const bouncesRemaining = bounced
? (projectile.bouncesRemaining ?? 0) - 1
: projectile.bouncesRemaining
if (bouncesRemaining !== undefined && bouncesRemaining < 0) {
return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events }
}
return {
events,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile: {
...projectile,
bouncesRemaining,
bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds,
position,
remainingSeconds,
velocity,
},
}
}
function applyPartyAttacks(
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
nextProjectileId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (!member.attackReady) return member
const direction = normalizeVec2(subtractVec2(target.position, member.position))
projectiles.push({
id: `projectile-${projectileId}`,
sourceId: member.id,
owner: 'party',
classId: member.classId,
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
color: metadata.accentColor,
position: {
x: member.position.x + direction.x * (member.radius + 4),
y: member.position.y + direction.y * (member.radius + 4),
},
velocity: scaleVec2(direction, metadata.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
remainingSeconds: 1.2,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
}
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: member.id,
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: member.id,
targetId: target.id,
})
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
})
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
nextProjectileId: projectileId,
events,
}
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return boss.health > 0 ? boss : undefined
}
function addDamageDone(
party: Iwt2PartyEntityState[],
sourceId: Iwt2EntityId,
damage: number,
): Iwt2PartyEntityState[] {
return party.map((member) => member.id === sourceId
? { ...member, damageDone: member.damageDone + damage }
: member)
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}

Some files were not shown because too many files have changed in this diff Show More