Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e2c61d0a | ||
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a | ||
|
|
f6d408f594 | ||
|
|
1797154726 | ||
|
|
bbcc67a54a |
@@ -1,9 +1,35 @@
|
|||||||
# 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
@@ -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 90
|
||||||
versionName "1.1.2"
|
versionName "1.1.11"
|
||||||
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) {
|
||||||
bridge.getWebView().clearCache(true);
|
if (shouldClearWebViewCache) {
|
||||||
|
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,24 +147,104 @@ 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;
|
||||||
String script =
|
dispatchNativeControllerToken(token, repeat);
|
||||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
|
||||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
|
||||||
bridge.getWebView().post(
|
|
||||||
() -> {
|
|
||||||
bridge.getWebView().requestFocus();
|
|
||||||
bridge.getWebView().evaluateJavascript(script, null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldThrottleDpad() {
|
@Override
|
||||||
|
public boolean dispatchGenericMotionEvent(MotionEvent event) {
|
||||||
|
if (
|
||||||
|
bridge == null
|
||||||
|
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|
||||||
|
|| !isControllerMotionEvent(event)
|
||||||
|
) {
|
||||||
|
return super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> currentTokens = new HashSet<>();
|
||||||
|
addAxisTokens(
|
||||||
|
currentTokens,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||||
|
"Button14",
|
||||||
|
"Button15"
|
||||||
|
);
|
||||||
|
addAxisTokens(
|
||||||
|
currentTokens,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
|
||||||
|
"Button12",
|
||||||
|
"Button13"
|
||||||
|
);
|
||||||
|
addAxisTokens(
|
||||||
|
currentTokens,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_X),
|
||||||
|
"Axis0-",
|
||||||
|
"Axis0+"
|
||||||
|
);
|
||||||
|
addAxisTokens(
|
||||||
|
currentTokens,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Y),
|
||||||
|
"Axis1-",
|
||||||
|
"Axis1+"
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
|
||||||
long now = SystemClock.uptimeMillis();
|
long now = SystemClock.uptimeMillis();
|
||||||
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
|
for (String token : currentTokens) {
|
||||||
lastDpadDispatchAt = now;
|
boolean repeat = activeMotionTokens.contains(token);
|
||||||
|
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
|
||||||
|
? lastMotionDispatchAt.get(token)
|
||||||
|
: 0L;
|
||||||
|
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
|
||||||
|
dispatchNativeControllerToken(token, repeat);
|
||||||
|
lastMotionDispatchAt.put(token, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeMotionTokens.clear();
|
||||||
|
activeMotionTokens.addAll(currentTokens);
|
||||||
|
|
||||||
|
if (currentTokens.isEmpty() && !hadMotionTokens) {
|
||||||
|
return super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isControllerMotionEvent(MotionEvent event) {
|
||||||
|
int source = event.getSource();
|
||||||
|
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||||
|
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|
||||||
|
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAxisTokens(
|
||||||
|
Set<String> tokens,
|
||||||
|
float value,
|
||||||
|
String negativeToken,
|
||||||
|
String positiveToken
|
||||||
|
) {
|
||||||
|
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
|
||||||
|
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||||
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
|
String script =
|
||||||
|
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||||
|
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||||
|
bridge.getWebView().post(
|
||||||
|
() -> {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
bridge.getWebView().evaluateJavascript(script, null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldThrottleDpad(String token) {
|
||||||
|
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
|
||||||
|
lastDpadDispatchAt[buttonIndex] = now;
|
||||||
return false;
|
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);
|
||||||
|
|||||||
@@ -254,6 +254,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,
|
||||||
|
|||||||
Generated
+64
@@ -16,6 +16,7 @@
|
|||||||
"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 +802,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",
|
||||||
@@ -2972,6 +2989,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"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.
Executable
+502
@@ -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())
|
||||||
Executable
+304
@@ -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())
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -815,6 +815,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 +945,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,8 +1046,18 @@ 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,
|
||||||
characterName,
|
characterName,
|
||||||
activeClassId,
|
activeClassId,
|
||||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
@@ -1038,6 +1069,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 +1087,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'
|
||||||
@@ -1149,6 +1184,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 +1252,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 +1282,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(
|
||||||
|
|||||||
+1022
-160
File diff suppressed because it is too large
Load Diff
+325
-58
@@ -19,11 +19,13 @@ import {
|
|||||||
syncCloudSave,
|
syncCloudSave,
|
||||||
type GameMode,
|
type GameMode,
|
||||||
} from './gameRepository'
|
} from './gameRepository'
|
||||||
import { focusFirstControl } from './input.tsx'
|
import { focusFirstControl, useGameAction } from './input.tsx'
|
||||||
|
import { barFillStyle } from './components/barStyles'
|
||||||
|
|
||||||
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
||||||
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
||||||
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
|
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
|
||||||
|
const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen })))
|
||||||
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
|
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
|
||||||
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
|
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
|
||||||
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
|
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
|
||||||
@@ -38,6 +40,7 @@ type Screen =
|
|||||||
| 'pvp'
|
| 'pvp'
|
||||||
| 'customize'
|
| 'customize'
|
||||||
| 'equipment'
|
| 'equipment'
|
||||||
|
| 'hunter-profile'
|
||||||
| 'talents'
|
| 'talents'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
@@ -51,6 +54,7 @@ const MENU_ITEMS: Array<{
|
|||||||
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
|
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
|
||||||
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
||||||
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
||||||
|
{ screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' },
|
||||||
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
||||||
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
|
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
|
||||||
]
|
]
|
||||||
@@ -58,6 +62,20 @@ const MENU_ITEMS: Array<{
|
|||||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||||
const SHOW_LEADERBOARDS = false
|
const SHOW_LEADERBOARDS = false
|
||||||
const ACTIVITY_PAGE_SIZE = 4
|
const ACTIVITY_PAGE_SIZE = 4
|
||||||
|
const HOME_MENU_COLUMNS = 2
|
||||||
|
const DUNGEON_NAV_COLUMNS = 2
|
||||||
|
|
||||||
|
type DungeonNavEntry =
|
||||||
|
| { kind: 'spacer'; disabled: true }
|
||||||
|
| { kind: 'back'; disabled?: boolean }
|
||||||
|
| { kind: 'pagePrev'; disabled?: boolean }
|
||||||
|
| { kind: 'pageNext'; disabled?: boolean }
|
||||||
|
| { kind: 'activity'; index: number; disabled?: boolean }
|
||||||
|
| { kind: 'tier'; index: number; disabled?: boolean }
|
||||||
|
| { kind: 'start'; disabled?: boolean }
|
||||||
|
| { kind: 'marathon'; disabled?: boolean }
|
||||||
|
| { kind: 'loot'; disabled?: boolean }
|
||||||
|
| { kind: 'lootSort'; disabled?: boolean }
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -111,6 +129,8 @@ function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||||
const [syncMessage, setSyncMessage] = useState('')
|
const [syncMessage, setSyncMessage] = useState('')
|
||||||
|
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||||
|
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAuthSession()
|
loadAuthSession()
|
||||||
@@ -332,6 +352,214 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloudSync = getCloudSyncStatus()
|
||||||
|
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
||||||
|
const homeMenuOffset = canShowCloudSync ? 1 : 0
|
||||||
|
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||||
|
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||||
|
|
||||||
|
function openHomeMenuIndex(index: number) {
|
||||||
|
if (canShowCloudSync && index === 0) {
|
||||||
|
if (!syncingCloud && cloudSync.dirty) void syncSaveNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||||
|
if (!item) return
|
||||||
|
if (item.screen === 'pvp') {
|
||||||
|
setRoguelikeVariant('pvp')
|
||||||
|
setScreen('roguelike')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.screen === 'dungeons' || item.screen === 'raids') {
|
||||||
|
const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions
|
||||||
|
setActivityPage(0)
|
||||||
|
setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount(
|
||||||
|
Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
setScreen(item.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonEntries() {
|
||||||
|
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||||
|
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
||||||
|
const entries: DungeonNavEntry[] = [
|
||||||
|
{ kind: 'back' },
|
||||||
|
]
|
||||||
|
if (activityPageCount > 1) {
|
||||||
|
entries.push(
|
||||||
|
{ kind: 'pagePrev', disabled: currentActivityPage === 0 },
|
||||||
|
{ kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (entries.length % DUNGEON_NAV_COLUMNS !== 0) {
|
||||||
|
entries.push({ kind: 'spacer', disabled: true })
|
||||||
|
}
|
||||||
|
pagedActivityOptions.forEach((candidate, index) => {
|
||||||
|
const candidateDifficulty = difficulty
|
||||||
|
? candidate.difficulties.find(
|
||||||
|
(option) => option.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
) ?? candidate.difficulties[0]
|
||||||
|
: candidate.difficulties[0]
|
||||||
|
entries.push({
|
||||||
|
kind: 'activity',
|
||||||
|
index,
|
||||||
|
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
tierOptions.forEach((difficultyOption, index) => {
|
||||||
|
entries.push({
|
||||||
|
kind: 'tier',
|
||||||
|
index,
|
||||||
|
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
entries.push(
|
||||||
|
{ kind: 'start', disabled: locked },
|
||||||
|
{ kind: 'marathon', disabled: locked },
|
||||||
|
{ kind: 'loot' },
|
||||||
|
)
|
||||||
|
if (showLoot) entries.push({ kind: 'lootSort' })
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) {
|
||||||
|
return Math.max(0, entries.findIndex((entry) => !entry.disabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstActivityDungeonEntryIndexForPageCount(pageCount: number) {
|
||||||
|
const entryCountBeforeActivities = pageCount > 1 ? 3 : 1
|
||||||
|
return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0
|
||||||
|
? entryCountBeforeActivities
|
||||||
|
: entryCountBeforeActivities + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeDungeonEntry(entries = dungeonEntries()) {
|
||||||
|
if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) {
|
||||||
|
return entries[dungeonSelectedIndex]
|
||||||
|
}
|
||||||
|
const firstEnabled = firstEnabledDungeonEntry(entries)
|
||||||
|
return entries[firstEnabled] ?? entries[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) {
|
||||||
|
const active = activeDungeonEntry()
|
||||||
|
return active?.kind === entry && ('index' in active ? active.index === index : index === undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDungeonSelection(action: string) {
|
||||||
|
const entries = dungeonEntries()
|
||||||
|
if (entries.length === 0) return
|
||||||
|
setDungeonSelectedIndex((current) => {
|
||||||
|
const bounded = entries[current] && !entries[current].disabled
|
||||||
|
? current
|
||||||
|
: firstEnabledDungeonEntry(entries)
|
||||||
|
const column = bounded % DUNGEON_NAV_COLUMNS
|
||||||
|
const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1
|
||||||
|
const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1
|
||||||
|
if (action === 'navigateLeft' && column === 0) return bounded
|
||||||
|
if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded
|
||||||
|
for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) {
|
||||||
|
if (!entries[next].disabled) return next
|
||||||
|
}
|
||||||
|
return bounded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectActivityByPageIndex(index: number) {
|
||||||
|
const candidate = pagedActivityOptions[index]
|
||||||
|
if (!candidate) return
|
||||||
|
const difficulty = selectedDifficultyOption
|
||||||
|
? candidate.difficulties.find(
|
||||||
|
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||||
|
) ?? candidate.difficulties[0]
|
||||||
|
: candidate.difficulties[0]
|
||||||
|
if (profile && profile.character.level < difficulty.unlockLevel) return
|
||||||
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
|
else setSelectedDungeonId(candidate.id)
|
||||||
|
setSelectedDifficultyId(difficulty.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTierByIndex(index: number) {
|
||||||
|
const difficulty = tierOptions[index]
|
||||||
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
|
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||||
|
setActivityPage(0)
|
||||||
|
const nextActivity = activity.difficulties.some(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
? activity
|
||||||
|
: activityOptions.find((option) =>
|
||||||
|
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||||
|
)
|
||||||
|
if (!nextActivity) return
|
||||||
|
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||||
|
else setSelectedDungeonId(nextActivity.id)
|
||||||
|
const nextDifficulty = nextActivity.difficulties.find(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSelectedRun(marathon: boolean) {
|
||||||
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
|
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||||
|
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||||
|
setSelectedMarathonMode(marathon)
|
||||||
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(difficulty.id)
|
||||||
|
setScreen('combat')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDungeonEntry(entry: DungeonNavEntry | undefined) {
|
||||||
|
if (!entry || entry.disabled) return
|
||||||
|
if (entry.kind === 'back') setScreen('menu')
|
||||||
|
else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1))
|
||||||
|
else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))
|
||||||
|
else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index)
|
||||||
|
else if (entry.kind === 'tier') selectTierByIndex(entry.index)
|
||||||
|
else if (entry.kind === 'start') startSelectedRun(false)
|
||||||
|
else if (entry.kind === 'marathon') startSelectedRun(true)
|
||||||
|
else if (entry.kind === 'loot') setShowLoot((current) => !current)
|
||||||
|
else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence')
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameAction((action, device) => {
|
||||||
|
if (device !== 'controller') return
|
||||||
|
if (screen === 'menu') {
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openHomeMenuIndex(homeActiveIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!action.startsWith('navigate')) return
|
||||||
|
setHomeSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, homeMenuEntryCount - 1)
|
||||||
|
const column = bounded % HOME_MENU_COLUMNS
|
||||||
|
if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded
|
||||||
|
if (action === 'navigateRight') {
|
||||||
|
const next = bounded + 1
|
||||||
|
return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded
|
||||||
|
}
|
||||||
|
if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded
|
||||||
|
const next = bounded + HOME_MENU_COLUMNS
|
||||||
|
if (next < homeMenuEntryCount) return next
|
||||||
|
return column > 0 ? homeMenuEntryCount - 1 : bounded
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (screen === 'dungeons' || screen === 'raids') {
|
||||||
|
if (action === 'back') {
|
||||||
|
setScreen('menu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openDungeonEntry(activeDungeonEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.startsWith('navigate')) moveDungeonSelection(action)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
@@ -451,37 +679,40 @@ function App() {
|
|||||||
const activity = selectedActivityOption ?? dungeon
|
const activity = selectedActivityOption ?? dungeon
|
||||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const cloudSync = getCloudSyncStatus()
|
|
||||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
{screen !== 'hunter-profile' && (
|
||||||
<button
|
<header className="topbar app-header">
|
||||||
className="brand-button"
|
<button
|
||||||
onClick={() => setScreen('menu')}
|
className="brand-button"
|
||||||
type="button"
|
onClick={() => setScreen('menu')}
|
||||||
>
|
type="button"
|
||||||
<strong>Menu</strong>
|
>
|
||||||
</button>
|
<strong>Menu</strong>
|
||||||
<div className="character-summary">
|
|
||||||
<strong>{profile.character.name}</strong>
|
|
||||||
<small>Level {profile.character.level}</small>
|
|
||||||
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
|
||||||
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
|
||||||
<span style={{ width: `${experiencePercent}%` }} />
|
|
||||||
</div>
|
|
||||||
<button className="logout-button" onClick={signOut} type="button">
|
|
||||||
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div className="character-summary">
|
||||||
</header>
|
<strong>{profile.character.name}</strong>
|
||||||
|
<small>Level {profile.character.level}</small>
|
||||||
|
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
||||||
|
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
||||||
|
<span style={barFillStyle(experiencePercent)} />
|
||||||
|
</div>
|
||||||
|
<button className="logout-button" onClick={signOut} type="button">
|
||||||
|
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="menu-screen">
|
<section className="menu-screen" data-game-nav-active="true">
|
||||||
<div className="main-menu-grid">
|
<div className="main-menu-grid">
|
||||||
{canShowCloudSync && (
|
{canShowCloudSync && (
|
||||||
<div className="menu-card cloud-sync-card">
|
<div
|
||||||
|
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
||||||
|
onPointerDown={() => setHomeSelectedIndex(0)}
|
||||||
|
>
|
||||||
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Cloud Save</strong>
|
<strong>Cloud Save</strong>
|
||||||
@@ -494,6 +725,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={syncingCloud || !cloudSync.dirty}
|
disabled={syncingCloud || !cloudSync.dirty}
|
||||||
onClick={syncSaveNow}
|
onClick={syncSaveNow}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -502,27 +734,26 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{MENU_ITEMS.map((item) => (
|
{MENU_ITEMS.map((item, index) => {
|
||||||
<button
|
const homeIndex = index + homeMenuOffset
|
||||||
className="menu-card"
|
return (
|
||||||
key={item.screen}
|
<button
|
||||||
onClick={() => {
|
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
|
||||||
if (item.screen === 'pvp') {
|
data-controller-nav="skip"
|
||||||
setRoguelikeVariant('pvp')
|
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
|
||||||
setScreen('roguelike')
|
key={item.screen}
|
||||||
return
|
onClick={() => openHomeMenuIndex(homeIndex)}
|
||||||
}
|
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
|
||||||
setScreen(item.screen)
|
type="button"
|
||||||
}}
|
>
|
||||||
type="button"
|
<span>{item.glyph}</span>
|
||||||
>
|
<div>
|
||||||
<span>{item.glyph}</span>
|
<strong>{item.label}</strong>
|
||||||
<div>
|
<small>{item.description}</small>
|
||||||
<strong>{item.label}</strong>
|
</div>
|
||||||
<small>{item.description}</small>
|
</button>
|
||||||
</div>
|
)
|
||||||
</button>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -728,7 +959,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(screen === 'dungeons' || screen === 'raids') && (
|
{(screen === 'dungeons' || screen === 'raids') && (
|
||||||
<section className="content-screen dungeon-run-screen">
|
<section className="content-screen dungeon-run-screen" data-game-nav-active="true">
|
||||||
<div className="dungeon-run-board">
|
<div className="dungeon-run-board">
|
||||||
<div className="dungeon-run-main">
|
<div className="dungeon-run-main">
|
||||||
<article className="run-summary-card dungeon-focus-card">
|
<article className="run-summary-card dungeon-focus-card">
|
||||||
@@ -739,7 +970,15 @@ function App() {
|
|||||||
<p className="eyebrow">Selected Run</p>
|
<p className="eyebrow">Selected Run</p>
|
||||||
<div className="run-title-row">
|
<div className="run-title-row">
|
||||||
<h2>{activity.name}</h2>
|
<h2>{activity.name}</h2>
|
||||||
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
|
<button
|
||||||
|
className={`back-button inline-back-button ${dungeonEntrySelected('back') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
onClick={() => setScreen('menu')}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'back'))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p>{activity.description}</p>
|
<p>{activity.description}</p>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
@@ -761,16 +1000,22 @@ function App() {
|
|||||||
{activityPageCount > 1 ? (
|
{activityPageCount > 1 ? (
|
||||||
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||||
<button
|
<button
|
||||||
|
className={dungeonEntrySelected('pagePrev') ? 'game-selected' : ''}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={currentActivityPage === 0}
|
disabled={currentActivityPage === 0}
|
||||||
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pagePrev'))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
|
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
|
||||||
<button
|
<button
|
||||||
|
className={dungeonEntrySelected('pageNext') ? 'game-selected' : ''}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={currentActivityPage >= activityPageCount - 1}
|
disabled={currentActivityPage >= activityPageCount - 1}
|
||||||
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pageNext'))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
@@ -781,7 +1026,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="activity-card-grid dungeon-choice-grid">
|
<div className="activity-card-grid dungeon-choice-grid">
|
||||||
{pagedActivityOptions.map((candidate) => {
|
{pagedActivityOptions.map((candidate, index) => {
|
||||||
const difficulty = candidate.difficulties.find(
|
const difficulty = candidate.difficulties.find(
|
||||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
@@ -789,7 +1034,8 @@ function App() {
|
|||||||
const selected = candidate.id === activity.id
|
const selected = candidate.id === activity.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -797,6 +1043,7 @@ function App() {
|
|||||||
else setSelectedDungeonId(candidate.id)
|
else setSelectedDungeonId(candidate.id)
|
||||||
setSelectedDifficultyId(difficulty.id)
|
setSelectedDifficultyId(difficulty.id)
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'activity' && 'index' in entry && entry.index === index))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
@@ -824,12 +1071,13 @@ function App() {
|
|||||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-grid">
|
<div className="tier-grid">
|
||||||
{tierOptions.map((difficulty) => {
|
{tierOptions.map((difficulty, index) => {
|
||||||
const locked = profile.character.level < difficulty.unlockLevel
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
key={difficulty.id}
|
key={difficulty.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -850,6 +1098,7 @@ function App() {
|
|||||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'tier' && 'index' in entry && entry.index === index))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
@@ -874,7 +1123,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="part-picker">
|
<div className="part-picker">
|
||||||
<button
|
<button
|
||||||
className="primary-button selected-part"
|
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
disabled={difficultyLocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(false)
|
setSelectedMarathonMode(false)
|
||||||
@@ -882,12 +1132,14 @@ function App() {
|
|||||||
setSelectedDifficultyId(selectedDifficulty.id)
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'start'))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Start Hunt
|
Start Hunt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''}`}
|
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
disabled={difficultyLocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(true)
|
setSelectedMarathonMode(true)
|
||||||
@@ -895,6 +1147,7 @@ function App() {
|
|||||||
setSelectedDifficultyId(selectedDifficulty.id)
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Marathon
|
Marathon
|
||||||
@@ -924,8 +1177,10 @@ function App() {
|
|||||||
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className={`text-button ${dungeonEntrySelected('loot') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
onClick={() => setShowLoot((current) => !current)}
|
onClick={() => setShowLoot((current) => !current)}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'loot'))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{showLoot ? 'Hide Loot' : 'View Loot'}
|
{showLoot ? 'Hide Loot' : 'View Loot'}
|
||||||
@@ -934,9 +1189,14 @@ function App() {
|
|||||||
{showLoot && (
|
{showLoot && (
|
||||||
<>
|
<>
|
||||||
<div className="loot-toolbar">
|
<div className="loot-toolbar">
|
||||||
<label>
|
<label className={dungeonEntrySelected('lootSort') ? 'game-selected' : ''}>
|
||||||
<span>Sort</span>
|
<span>Sort</span>
|
||||||
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
|
<select
|
||||||
|
data-controller-nav="skip"
|
||||||
|
onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}
|
||||||
|
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'lootSort'))}
|
||||||
|
value={lootSort}
|
||||||
|
>
|
||||||
<option value="sequence">Encounter order</option>
|
<option value="sequence">Encounter order</option>
|
||||||
<option value="boss">Boss name</option>
|
<option value="boss">Boss name</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1090,6 +1350,13 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{screen === 'hunter-profile' && (
|
||||||
|
<HunterProfileScreen
|
||||||
|
profile={profile}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{screen === 'settings' && (
|
{screen === 'settings' && (
|
||||||
<SettingsScreen onBack={() => setScreen('menu')} />
|
<SettingsScreen onBack={() => setScreen('menu')} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+4
-1
@@ -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>
|
||||||
<AdminScreen onBack={() => window.close()} />
|
<InputProvider>
|
||||||
|
<AdminScreen onBack={() => window.close()} />
|
||||||
|
</InputProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -1230,9 +1270,9 @@ export function CombatScreen({
|
|||||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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<'stats' | 'collection'>('stats')
|
||||||
|
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 = profile.hunterStats?.bossKills ?? {}
|
||||||
|
const 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 focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||||
|
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)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="content-screen hunter-profile-screen">
|
||||||
|
<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' : ''}`}
|
||||||
|
onClick={() => setActiveTab('stats')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'stats'}
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('collection')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'collection'}
|
||||||
|
>
|
||||||
|
Collection Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'stats' && (
|
||||||
|
<div className="hunter-stat-grid">
|
||||||
|
<article className="hunter-stat-tile">
|
||||||
|
<span>Total Boss Kills</span>
|
||||||
|
<strong>{totalBossKills}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="hunter-stat-tile">
|
||||||
|
<span>Most Killed Boss</span>
|
||||||
|
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
|
||||||
|
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
|
||||||
|
</article>
|
||||||
|
<article className="hunter-stat-tile">
|
||||||
|
<span>PvP Matches</span>
|
||||||
|
<strong>{matchesPlayed}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="hunter-stat-tile">
|
||||||
|
<span>PvP Wins</span>
|
||||||
|
<strong>{matchesWon}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="hunter-stat-tile">
|
||||||
|
<span>PvP Win Rate</span>
|
||||||
|
<strong>{winRate}%</strong>
|
||||||
|
</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' : ''}`}
|
||||||
|
key={boss.encounter.id}
|
||||||
|
onClick={() => setSelectedBossId(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'}`}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setFocusedItemKey(item.key)}
|
||||||
|
onFocus={() => setFocusedItemKey(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{item.glyph}</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.chance}</small>
|
||||||
|
<b>x{item.quantity}</b>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
memberHotEffects,
|
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
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -356,6 +362,7 @@ export function PvPRoguelikeScreen({
|
|||||||
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 +405,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 +539,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 +661,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
|
||||||
@@ -701,6 +719,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 +899,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 +1013,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])
|
||||||
@@ -1086,6 +1105,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())
|
||||||
@@ -1339,6 +1372,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 +1398,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,
|
||||||
@@ -1424,13 +1461,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 +1501,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 +1532,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}
|
||||||
|
|||||||
@@ -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>({
|
||||||
@@ -1055,6 +1081,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 +1112,7 @@ export function PvpStadiumScreen({
|
|||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
cpuSide.survivalSeconds,
|
cpuSide.survivalSeconds,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dualScreenFloatingTexts,
|
dualScreenFloatingTexts,
|
||||||
@@ -1164,16 +1194,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 +1218,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 +1252,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 +1282,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 +1306,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
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,
|
||||||
@@ -89,33 +91,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen settings-screen">
|
<section className="content-screen settings-screen">
|
||||||
<div className="screen-heading">
|
<div className="settings-nav">
|
||||||
<div>
|
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
|
||||||
<p className="eyebrow">Game Options</p>
|
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||||
<h1>Settings</h1>
|
{([
|
||||||
</div>
|
{ key: 'display', label: 'Display' },
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
{ key: 'input', label: 'Input' },
|
||||||
|
{ key: 'bindings', label: 'Bindings' },
|
||||||
|
] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
aria-selected={settingsTab === tab.key}
|
||||||
|
className={settingsTab === tab.key ? 'selected' : ''}
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
|
||||||
{([
|
|
||||||
{ key: 'display', label: 'Display' },
|
|
||||||
{ key: 'input', label: 'Input' },
|
|
||||||
{ key: 'bindings', label: 'Bindings' },
|
|
||||||
] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
aria-selected={settingsTab === tab.key}
|
|
||||||
className={settingsTab === tab.key ? 'selected' : ''}
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setSettingsTab(tab.key)}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{settingsTab === 'display' && (
|
{settingsTab === 'display' && (
|
||||||
<section className="dual-screen-settings settings-tab-panel">
|
<section className="dual-screen-settings settings-tab-panel">
|
||||||
<div>
|
<div>
|
||||||
@@ -180,6 +177,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
>
|
>
|
||||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={combatTouchLocked}
|
||||||
|
className={combatTouchLocked ? 'selected' : ''}
|
||||||
|
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
|
||||||
|
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) => (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-39
@@ -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
|
||||||
@@ -145,6 +150,16 @@ 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'
|
||||||
|
}
|
||||||
|
|
||||||
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',
|
||||||
@@ -398,6 +413,11 @@ export function DualScreenBottomDisplay() {
|
|||||||
channel?.close()
|
channel?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGameAction((action) => {
|
||||||
|
if (!shouldRelayBottomAction(action, state)) 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">
|
||||||
@@ -445,7 +465,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 +500,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 +547,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 +660,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 (
|
||||||
|
<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 (
|
return (
|
||||||
<div className="dual-top-main">
|
<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 +699,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>
|
||||||
|
)}
|
||||||
|
{!state.opponentParty && <p>{state.encounterDescription}</p>}
|
||||||
</div>
|
</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 +745,17 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-spell-strip">
|
{!state.opponentParty && (
|
||||||
{state.spells.map((spell, slotIndex) => {
|
<section className="dual-top-spell-strip">
|
||||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
{spellButtons}
|
||||||
const percent = spell.remaining > 0
|
<div className="dual-top-resource">
|
||||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
: 0
|
<div className="bar mana-bar">
|
||||||
return (
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
<button
|
</div>
|
||||||
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">
|
|
||||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
|
||||||
<div className="bar mana-bar">
|
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+151
-10
@@ -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,7 @@ type CharacterData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OfflineSave = {
|
type OfflineSave = {
|
||||||
version: 3
|
version: 4
|
||||||
characterName: string
|
characterName: string
|
||||||
activeClassId: number
|
activeClassId: number
|
||||||
completedDungeonParts: number
|
completedDungeonParts: number
|
||||||
@@ -84,6 +93,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 = {
|
||||||
@@ -124,6 +137,7 @@ 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 {
|
||||||
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName: p.character.name,
|
characterName: p.character.name,
|
||||||
activeClassId: p.character.classId,
|
activeClassId: p.character.classId,
|
||||||
completedDungeonParts: p.completedDungeonParts,
|
completedDungeonParts: p.completedDungeonParts,
|
||||||
@@ -185,14 +199,33 @@ 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,
|
||||||
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,
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +257,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 +277,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 +320,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 +416,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 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
inventory: clone(profile.inventory),
|
inventory: clone(profile.inventory),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName: profile.character.name,
|
characterName: profile.character.name,
|
||||||
activeClassId: profile.character.classId,
|
activeClassId: profile.character.classId,
|
||||||
completedDungeonParts: profile.completedDungeonParts,
|
completedDungeonParts: profile.completedDungeonParts,
|
||||||
@@ -557,6 +599,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 +616,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 +975,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 {
|
||||||
@@ -1172,6 +1266,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,
|
||||||
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||||
unlockedAbilities,
|
unlockedAbilities,
|
||||||
bonusItem,
|
bonusItem,
|
||||||
|
petAwarded,
|
||||||
profile: updatedProfile,
|
profile: updatedProfile,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1446,6 +1552,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 +1606,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)
|
store.writeSave(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',
|
||||||
|
})
|
||||||
|
store.writeSave(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
|
||||||
|
store.writeSave(save)
|
||||||
|
return buildProfile(save)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,6 +1714,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 {
|
||||||
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
|||||||
characters[cid] = emptyCharacterData(cid)
|
characters[cid] = emptyCharacterData(cid)
|
||||||
}
|
}
|
||||||
const save: OfflineSave = {
|
const save: OfflineSave = {
|
||||||
version: 3,
|
version: 4,
|
||||||
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')
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
|
||||||
|
|
||||||
|
export function useCooldownClock(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
active: boolean,
|
||||||
|
intervalMs = 100,
|
||||||
|
) {
|
||||||
|
const [now, setNow] = useState(cooldownNow)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
|
||||||
|
|
||||||
|
let timer = 0
|
||||||
|
let startTimer = 0
|
||||||
|
const tick = () => {
|
||||||
|
const nextNow = cooldownNow()
|
||||||
|
setNow(nextNow)
|
||||||
|
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer = window.setTimeout(() => {
|
||||||
|
tick()
|
||||||
|
timer = window.setInterval(tick, intervalMs)
|
||||||
|
}, 0)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(startTimer)
|
||||||
|
if (timer) window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [active, cooldowns, intervalMs])
|
||||||
|
|
||||||
|
return now
|
||||||
|
}
|
||||||
@@ -15,41 +15,63 @@ type FloatingCombatTextOptions = {
|
|||||||
durationMs?: number
|
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 clearCleanupTimer = useCallback(() => {
|
||||||
|
if (cleanupTimerRef.current === null) return
|
||||||
|
window.clearTimeout(cleanupTimerRef.current)
|
||||||
|
cleanupTimerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scheduleCleanup = useCallback(() => {
|
||||||
|
function scheduleNext() {
|
||||||
|
clearCleanupTimer()
|
||||||
|
let nextExpiry = Infinity
|
||||||
|
for (const expiresAt of expirationsRef.current.values()) {
|
||||||
|
nextExpiry = Math.min(nextExpiry, expiresAt)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextExpiry)) return
|
||||||
|
|
||||||
|
cleanupTimerRef.current = window.setTimeout(() => {
|
||||||
|
cleanupTimerRef.current = null
|
||||||
|
const now = performance.now()
|
||||||
|
let removed = false
|
||||||
|
for (const [id, expiresAt] of expirationsRef.current) {
|
||||||
|
if (expiresAt > now) continue
|
||||||
|
expirationsRef.current.delete(id)
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
if (removed) {
|
||||||
|
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||||
|
}
|
||||||
|
scheduleNext()
|
||||||
|
}, Math.max(0, nextExpiry - performance.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext()
|
||||||
|
}, [clearCleanupTimer])
|
||||||
|
|
||||||
const clearFloatingTexts = useCallback(() => {
|
const clearFloatingTexts = useCallback(() => {
|
||||||
|
clearCleanupTimer()
|
||||||
expirationsRef.current.clear()
|
expirationsRef.current.clear()
|
||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
}, [])
|
}, [clearCleanupTimer])
|
||||||
|
|
||||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||||
if (entry.value <= 0) return
|
if (entry.value <= 0) return
|
||||||
const id = nextId.current++
|
const id = nextId.current++
|
||||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||||
}, [durationMs])
|
scheduleCleanup()
|
||||||
|
}, [durationMs, scheduleCleanup])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
const now = performance.now()
|
|
||||||
let removed = false
|
|
||||||
for (const [id, expiresAt] of expirationsRef.current) {
|
|
||||||
if (expiresAt > now) continue
|
|
||||||
expirationsRef.current.delete(id)
|
|
||||||
removed = true
|
|
||||||
}
|
|
||||||
if (!removed) return
|
|
||||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
|
||||||
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const floatingTextsByMember = useMemo(
|
const floatingTextsByMember = useMemo(
|
||||||
() => groupFloatingTextsByMember(floatingTexts),
|
() => groupFloatingTextsByMember(floatingTexts),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Spell } from '../game'
|
||||||
|
import type { SpellSlot } from '../components/SpellBars'
|
||||||
|
import { cooldownRemaining } from '../combat/spellCasting'
|
||||||
|
import { useCooldownClock } from './useCooldownClock'
|
||||||
|
|
||||||
|
type UseSpellSlotsOptions = {
|
||||||
|
spells: readonly Spell[]
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
active: boolean
|
||||||
|
cost: (spell: Spell) => number
|
||||||
|
abilitySlots?: readonly (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpellSlots({
|
||||||
|
spells,
|
||||||
|
cooldowns,
|
||||||
|
active,
|
||||||
|
cost,
|
||||||
|
abilitySlots,
|
||||||
|
}: UseSpellSlotsOptions) {
|
||||||
|
const now = useCooldownClock(cooldowns, active)
|
||||||
|
|
||||||
|
return useMemo<SpellSlot[]>(() => {
|
||||||
|
const slotCount = abilitySlots?.length ?? spells.length
|
||||||
|
return Array.from({ length: slotCount }, (_, slotIndex) => {
|
||||||
|
if (abilitySlots && !abilitySlots[slotIndex]) return null
|
||||||
|
const spell = spells[slotIndex]
|
||||||
|
if (!spell) return null
|
||||||
|
return {
|
||||||
|
...spell,
|
||||||
|
cost: cost(spell),
|
||||||
|
slotIndex,
|
||||||
|
remaining: cooldownRemaining(cooldowns, spell.id, now),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [abilitySlots, cooldowns, cost, now, spells])
|
||||||
|
}
|
||||||
+3
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+263
-47
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
'toggleSpeed',
|
'toggleSpeed',
|
||||||
|
'toggleTouchLock',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -66,6 +67,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 +95,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 +118,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 +130,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'
|
||||||
|
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 +158,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 +203,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 +232,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 ?? 'xbox',
|
||||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||||
|
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||||
directPartyTargeting: false,
|
directPartyTargeting: false,
|
||||||
|
combatTouchLocked: Capacitor.isNativePlatform(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +257,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 +266,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 +284,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 +309,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 +331,33 @@ 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() {
|
||||||
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 +368,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 +422,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',
|
||||||
@@ -428,6 +549,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 +557,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 +604,57 @@ 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)
|
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 +670,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 +684,6 @@ 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>> = {
|
|
||||||
Button12: 'navigateUp',
|
|
||||||
Button13: 'navigateDown',
|
|
||||||
Button14: 'navigateLeft',
|
|
||||||
Button15: 'navigateRight',
|
|
||||||
}
|
|
||||||
const uiPriority = [
|
const uiPriority = [
|
||||||
'navigateUp',
|
'navigateUp',
|
||||||
'navigateDown',
|
'navigateDown',
|
||||||
@@ -551,8 +701,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 +720,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 +770,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) => {
|
||||||
document.documentElement.dataset.inputDevice = 'pc'
|
if (event.pointerType !== 'touch') {
|
||||||
|
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,8 +800,14 @@ 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
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
@@ -663,22 +824,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 +881,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 +911,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 +951,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 +966,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,6 +1034,11 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -852,7 +1070,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 },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
|
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
|
||||||
<DualScreenBottomDisplay />
|
<InputProvider>
|
||||||
|
<DualScreenBottomDisplay />
|
||||||
|
</InputProvider>
|
||||||
) : (
|
) : (
|
||||||
<DualScreenProvider>
|
<DualScreenProvider>
|
||||||
<DualScreenStartupPrompt />
|
<DualScreenStartupPrompt />
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
|
||||||
|
|||||||
@@ -6905,5 +6905,11 @@
|
|||||||
"full_run": []
|
"full_run": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"hunterStats": {
|
||||||
|
"bossKills": {},
|
||||||
|
"bossPets": {},
|
||||||
|
"pvpMatchesPlayed": 0,
|
||||||
|
"pvpMatchesWon": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
|
|||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PetAward = {
|
||||||
|
encounterId: number
|
||||||
|
petName: string
|
||||||
|
quantity: number
|
||||||
|
duplicate: boolean
|
||||||
|
quantityAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
export type LootRoll = {
|
export type LootRoll = {
|
||||||
encounterId: number
|
encounterId: number
|
||||||
encounterName: string
|
encounterName: string
|
||||||
@@ -157,6 +165,7 @@ export type LootRoll = {
|
|||||||
awarded: boolean
|
awarded: boolean
|
||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
|
petAwarded?: PetAward | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dungeon = {
|
export type Dungeon = {
|
||||||
@@ -232,6 +241,12 @@ export type CharacterProfile = {
|
|||||||
craftingRecipes: CraftingRecipe[]
|
craftingRecipes: CraftingRecipe[]
|
||||||
gearUpgradePaths: GearUpgradePath[]
|
gearUpgradePaths: GearUpgradePath[]
|
||||||
dungeons: Dungeon[]
|
dungeons: Dungeon[]
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: Record<string, number>
|
||||||
|
bossPets: Record<string, number>
|
||||||
|
pvpMatchesPlayed: number
|
||||||
|
pvpMatchesWon: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Account = {
|
export type Account = {
|
||||||
@@ -280,6 +295,7 @@ export type DungeonReward = {
|
|||||||
glyph: string
|
glyph: string
|
||||||
}>
|
}>
|
||||||
bonusItem: BonusItem | null
|
bonusItem: BonusItem | null
|
||||||
|
petAwarded?: PetAward | null
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +381,20 @@ export async function completeRoguelike(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordBossKill(
|
||||||
|
encounterId: number,
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): Promise<{
|
||||||
|
profile: CharacterProfile
|
||||||
|
petAwarded: PetAward | null
|
||||||
|
}> {
|
||||||
|
return activeGameRepository().recordBossKill(encounterId, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordPvpMatch(won: boolean): Promise<CharacterProfile> {
|
||||||
|
return activeGameRepository().recordPvpMatch(won)
|
||||||
|
}
|
||||||
|
|
||||||
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
|
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
|
||||||
return activeGameRepository().allocateTalent(talentId)
|
return activeGameRepository().allocateTalent(talentId)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user