Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a849cd2c69 | ||
|
|
a121b2ef74 | ||
|
|
b7cb572631 | ||
|
|
c6e2c61d0a | ||
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a | ||
|
|
f6d408f594 | ||
|
|
1797154726 | ||
|
|
bbcc67a54a |
@@ -1,9 +1,50 @@
|
|||||||
# Project Notes
|
# Project Notes
|
||||||
|
|
||||||
|
## Target Hardware: AYN Thor
|
||||||
|
|
||||||
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||||
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
|
||||||
|
## Input Requirements
|
||||||
|
|
||||||
|
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
|
||||||
|
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
|
||||||
|
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||||
|
- Focus state must always be visible and predictable when controller navigation is active.
|
||||||
|
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||||
|
|
||||||
|
## Architecture Requirements
|
||||||
|
|
||||||
|
- Keep systems modular and scoped by responsibility.
|
||||||
|
- Prefer small reusable modules/components over large files with mixed concerns.
|
||||||
|
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
|
||||||
- Apply game changes to both web version and mobile app version.
|
- Apply game changes to both web version and mobile app version.
|
||||||
|
|
||||||
|
## I Want To Heal 1 vs I Want To Heal 2
|
||||||
|
|
||||||
|
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
|
||||||
|
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
|
||||||
|
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
|
||||||
|
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
|
||||||
|
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
|
||||||
|
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
|
||||||
|
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||||
|
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||||
|
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||||
|
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
|
||||||
|
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
|
||||||
|
|
||||||
|
## Performance Requirements
|
||||||
|
|
||||||
|
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||||
|
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
|
||||||
|
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
|
||||||
|
- Prefer event-driven updates and cached calculations for UI and game systems.
|
||||||
|
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
|
||||||
|
|
||||||
|
## Build Requirements
|
||||||
|
|
||||||
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 81
|
versionCode 93
|
||||||
versionName "1.1.2"
|
versionName "1.1.14"
|
||||||
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);
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
||||||
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
||||||
created_ip TEXT NOT NULL,
|
created_ip TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||||
@@ -254,6 +255,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
|
|||||||
PRIMARY KEY (character_id, item_id)
|
PRIMARY KEY (character_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_boss_stats (
|
||||||
|
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||||
|
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
|
||||||
|
PRIMARY KEY (character_id, encounter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_pvp_stats (
|
||||||
|
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
|
||||||
|
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS action_characters (
|
CREATE TABLE IF NOT EXISTS action_characters (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
|
|||||||
|
|
||||||
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||||
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
||||||
|
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
|
`).run()
|
||||||
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
||||||
|
|
||||||
migrateCharacterAccountConstraint()
|
migrateCharacterAccountConstraint()
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
|
|||||||
const pvpQueueTtlMs = 15 * 1000
|
const pvpQueueTtlMs = 15 * 1000
|
||||||
const pvpMatchTtlMs = 60 * 60 * 1000
|
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||||
|
|
||||||
|
function addColumnIfMissing(database, table, column, definition) {
|
||||||
|
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
|
||||||
|
if (!columns.some((candidate) => candidate.name === column)) {
|
||||||
|
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRuntimeMigrations(database) {
|
||||||
|
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
|
`).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteDateFromMs(value) {
|
||||||
|
const timestamp = Number(value)
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function epochMsFromSqliteDate(value) {
|
||||||
|
const timestamp = Date.parse(String(value ?? ''))
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(response, status, body, headers = {}) {
|
function sendJson(response, status, body, headers = {}) {
|
||||||
response.statusCode = status
|
response.statusCode = status
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.setHeader('Content-Type', 'application/json')
|
||||||
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
|
|||||||
response.end(JSON.stringify(body))
|
response.end(JSON.stringify(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function touchAccountSave(database, accountId) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMutatingResult(response, database, accountId, body) {
|
||||||
|
touchAccountSave(database, accountId)
|
||||||
|
sendJson(response, 200, body)
|
||||||
|
}
|
||||||
|
|
||||||
function configuredCorsOrigins() {
|
function configuredCorsOrigins() {
|
||||||
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -815,6 +857,21 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
AND completed_parts >= 1
|
AND completed_parts >= 1
|
||||||
GROUP BY dungeon_id
|
GROUP BY dungeon_id
|
||||||
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
||||||
|
const bossStatRows = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
encounter_id AS encounterId,
|
||||||
|
kills,
|
||||||
|
pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(characterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
matches_played AS matchesPlayed,
|
||||||
|
matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(characterId)
|
||||||
|
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
database.prepare('SELECT key, value FROM game_settings').all()
|
database.prepare('SELECT key, value FROM game_settings').all()
|
||||||
@@ -930,6 +987,12 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const account = database.prepare(`
|
const account = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
completed_dungeon_parts AS completedDungeonParts,
|
completed_dungeon_parts AS completedDungeonParts,
|
||||||
completed_raid_phases AS completedRaidPhases
|
completed_raid_phases AS completedRaidPhases,
|
||||||
|
last_saved_at AS lastSavedAt
|
||||||
FROM accounts
|
FROM accounts
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(accountId)
|
`).get(accountId)
|
||||||
@@ -1025,8 +1089,19 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||||
?? characters[0]?.name
|
?? characters[0]?.name
|
||||||
?? 'Mira'
|
?? 'Mira'
|
||||||
|
const bossStats = database.prepare(`
|
||||||
|
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(activeCharacterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(activeCharacterId)
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
|
||||||
characterName,
|
characterName,
|
||||||
activeClassId,
|
activeClassId,
|
||||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
@@ -1038,6 +1113,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
lootRolls: {},
|
lootRolls: {},
|
||||||
|
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,7 +1131,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
if (
|
if (
|
||||||
!save
|
!save
|
||||||
|| typeof save !== 'object'
|
|| typeof save !== 'object'
|
||||||
|| Number(save.version) !== 3
|
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|
||||||
|| typeof save.characterName !== 'string'
|
|| typeof save.characterName !== 'string'
|
||||||
|| !save.characters
|
|| !save.characters
|
||||||
|| typeof save.characters !== 'object'
|
|| typeof save.characters !== 'object'
|
||||||
@@ -1129,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
|
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET completed_dungeon_parts = ?, completed_raid_phases = ?
|
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
||||||
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
||||||
|
sqliteDateFromMs(save.updatedAt),
|
||||||
accountId,
|
accountId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1149,6 +1229,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
|
const insertBossStats = database.prepare(`
|
||||||
|
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
for (const classId of classIds) {
|
for (const classId of classIds) {
|
||||||
const local = save.characters[classId]
|
const local = save.characters[classId]
|
||||||
@@ -1213,6 +1297,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
DELETE FROM character_inventory
|
DELETE FROM character_inventory
|
||||||
WHERE character_id = ?
|
WHERE character_id = ?
|
||||||
`).run(characterId)
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
const inventoryByItemId = new Map()
|
const inventoryByItemId = new Map()
|
||||||
const equippedSlots = new Set()
|
const equippedSlots = new Set()
|
||||||
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
||||||
@@ -1235,6 +1327,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
for (const [itemId, itemState] of inventoryByItemId) {
|
for (const [itemId, itemState] of inventoryByItemId) {
|
||||||
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
if (classId === Number(save.activeClassId)) {
|
||||||
|
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
|
||||||
|
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
|
||||||
|
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(
|
||||||
|
characterId,
|
||||||
|
id,
|
||||||
|
clampInteger(rawKills, 0, 0, 1000000),
|
||||||
|
clampInteger(bossPets[encounterId], 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
|
||||||
|
if (Object.hasOwn(bossKills, encounterId)) continue
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
characterId,
|
||||||
|
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
|
||||||
|
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncedClassId = clampInteger(
|
let syncedClassId = clampInteger(
|
||||||
@@ -2929,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -2985,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -3023,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
if (request.url === '/api/profile' && request.method === 'PUT') {
|
if (request.url === '/api/profile' && request.method === 'PUT') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
||||||
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId))
|
sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3073,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||||
if (dungeonCompletion && request.method === 'POST') {
|
if (dungeonCompletion && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeDungeon(
|
completeDungeon(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
@@ -3090,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeRoguelike(database, session.characterId, session.accountId, payload),
|
completeRoguelike(database, session.characterId, session.accountId, payload),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3100,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
||||||
if (talentAllocation && request.method === 'POST') {
|
if (talentAllocation && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
||||||
sendJson(response, 200, resetTalents(database, session.characterId))
|
sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
||||||
if (itemEquip && request.method === 'POST') {
|
if (itemEquip && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
equipItem(database, session.characterId, Number(itemEquip[1])),
|
equipItem(database, session.characterId, Number(itemEquip[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3125,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
||||||
if (itemDiscard && request.method === 'POST') {
|
if (itemDiscard && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3135,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
||||||
if (itemBreakdown && request.method === 'POST') {
|
if (itemBreakdown && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3145,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
||||||
if (recipeCraft && request.method === 'POST') {
|
if (recipeCraft && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3155,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||||
if (itemUpgrade && request.method === 'POST') {
|
if (itemUpgrade && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3166,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||||
if (encounterLootRoll && request.method === 'POST') {
|
if (encounterLootRoll && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
rollEncounterLoot(
|
rollEncounterLoot(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
import {
|
import {
|
||||||
saveProfile,
|
saveProfile,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type GameClass,
|
type GameClass,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
import { useGameAction, type InputAction } from '../input'
|
||||||
import { EquipmentScreen } from './EquipmentScreen'
|
import { EquipmentScreen } from './EquipmentScreen'
|
||||||
import { TalentScreen } from './TalentScreen'
|
import { TalentScreen } from './TalentScreen'
|
||||||
|
|
||||||
@@ -14,20 +15,121 @@ type Props = {
|
|||||||
onSaved: (profile: CharacterProfile) => void
|
onSaved: (profile: CharacterProfile) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
|
||||||
|
|
||||||
|
type CustomizeNavEntry =
|
||||||
|
| { kind: 'back'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
|
||||||
|
| { kind: 'class'; key: string; row: number; column: number; classId: number }
|
||||||
|
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
|
||||||
|
| { kind: 'clear'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
|
||||||
|
| { kind: 'save'; key: string; row: number; column: number }
|
||||||
|
|
||||||
|
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
|
||||||
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'crafting', label: 'Crafting' },
|
||||||
|
{ key: 'talents', label: 'Talents' },
|
||||||
|
{ key: 'class', label: 'Class' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CLASS_PICKER_COLUMN = 0
|
||||||
|
const CLASS_CONTENT_COLUMN = 2
|
||||||
|
const ABILITY_LIBRARY_COLUMNS = 5
|
||||||
|
const ABILITY_LIBRARY_PAGE_SIZE = 10
|
||||||
|
|
||||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [classId, setClassId] = useState(profile.character.classId)
|
const [classId, setClassId] = useState(profile.character.classId)
|
||||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||||
|
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
|
||||||
|
const [abilityPage, setAbilityPage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
|
const navRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
||||||
const abilityMap = useMemo(
|
const abilityMap = useMemo(
|
||||||
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
||||||
[gameClass],
|
[gameClass],
|
||||||
)
|
)
|
||||||
|
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
|
||||||
|
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
|
||||||
|
const visibleAbilities = useMemo(
|
||||||
|
() => gameClass.spells.slice(
|
||||||
|
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
|
||||||
|
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
|
||||||
|
),
|
||||||
|
[currentAbilityPage, gameClass.spells],
|
||||||
|
)
|
||||||
|
const classNavActive = activeTab === 'class'
|
||||||
|
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
|
||||||
|
const entries: CustomizeNavEntry[] = [
|
||||||
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||||
|
...CUSTOMIZE_TABS.map((tab, index) => ({
|
||||||
|
kind: 'tab' as const,
|
||||||
|
key: `tab:${tab.key}`,
|
||||||
|
row: 0,
|
||||||
|
column: index + 1,
|
||||||
|
tab: tab.key,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
if (activeTab !== 'class') return entries
|
||||||
|
|
||||||
|
entries.push(
|
||||||
|
...profile.classes.map((candidate, index) => ({
|
||||||
|
kind: 'class' as const,
|
||||||
|
key: `class:${candidate.id}`,
|
||||||
|
row: index + 1,
|
||||||
|
column: CLASS_PICKER_COLUMN,
|
||||||
|
classId: candidate.id,
|
||||||
|
})),
|
||||||
|
...slots.map((_, index) => ({
|
||||||
|
kind: 'slot' as const,
|
||||||
|
key: `slot:${index}`,
|
||||||
|
row: 1,
|
||||||
|
column: CLASS_CONTENT_COLUMN + index,
|
||||||
|
slotIndex: index,
|
||||||
|
})),
|
||||||
|
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
|
||||||
|
...(abilityPageCount > 1 && currentAbilityPage > 0
|
||||||
|
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
|
||||||
|
: []),
|
||||||
|
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
|
||||||
|
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
|
||||||
|
: []),
|
||||||
|
...visibleAbilities
|
||||||
|
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||||
|
.map((ability, index) => ({
|
||||||
|
kind: 'ability' as const,
|
||||||
|
key: `ability:${ability.id}`,
|
||||||
|
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
|
||||||
|
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
|
||||||
|
abilityId: ability.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (!saving) {
|
||||||
|
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
|
||||||
|
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
|
||||||
|
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
|
||||||
|
?? navEntries[0]
|
||||||
|
|
||||||
|
function selected(entryKey: string) {
|
||||||
|
return classNavActive && activeEntry?.key === entryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRef(entryKey: string) {
|
||||||
|
return (node: HTMLElement | null) => {
|
||||||
|
navRefs.current[entryKey] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
setClassId(nextClass.id)
|
setClassId(nextClass.id)
|
||||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||||
setSelectedSlot(0)
|
setSelectedSlot(0)
|
||||||
|
setAbilityPage(0)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: CustomizeTab) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
setSelectedNavKey(`tab:${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
function equipAbility(abilityId: number) {
|
function equipAbility(abilityId: number) {
|
||||||
if (slots.includes(abilityId)) {
|
if (slots.includes(abilityId)) {
|
||||||
setMessage('That ability is already equipped.')
|
setMessage('That ability is already equipped.')
|
||||||
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveSelection(action: InputAction) {
|
||||||
|
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
|
||||||
|
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
|
||||||
|
const candidates = navEntries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== activeIndex)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < activeEntry.column
|
||||||
|
if (action === 'navigateRight') return entry.column > activeEntry.column
|
||||||
|
if (action === 'navigateUp') return entry.row < activeEntry.row
|
||||||
|
return entry.row > activeEntry.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - activeEntry.row)
|
||||||
|
: Math.abs(a.entry.column - activeEntry.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - activeEntry.row)
|
||||||
|
: Math.abs(b.entry.column - activeEntry.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(entry: CustomizeNavEntry | undefined) {
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.kind === 'back') onBack()
|
||||||
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||||
|
else if (entry.kind === 'class') {
|
||||||
|
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
|
||||||
|
if (nextClass) chooseClass(nextClass)
|
||||||
|
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
|
||||||
|
else if (entry.kind === 'clear') clearSlot()
|
||||||
|
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
|
||||||
|
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
|
||||||
|
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
|
||||||
|
else if (entry.kind === 'save') void persistChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!classNavActive) return
|
||||||
|
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}, [activeEntry, classNavActive])
|
||||||
|
|
||||||
|
useGameAction((action, device) => {
|
||||||
|
if (device !== 'controller' || !classNavActive) return
|
||||||
|
if (action === 'back') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openEntry(activeEntry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveSelection(action)
|
||||||
|
})
|
||||||
|
|
||||||
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
if (activeTab !== 'class') return null
|
if (activeTab !== 'class') return null
|
||||||
return {
|
return {
|
||||||
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen customize-screen">
|
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
|
||||||
<div className="screen-heading customize-heading">
|
<div className="screen-heading customize-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Workshop</p>
|
<p className="eyebrow">Character Workshop</p>
|
||||||
<h1>Customize Character</h1>
|
<h1>Customize Character</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button
|
||||||
|
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => setSelectedNavKey('back')}
|
||||||
|
ref={navRef('back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||||
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
<button
|
||||||
{([
|
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
|
||||||
{ key: 'equipment', label: 'Equipment' },
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
{ key: 'crafting', label: 'Crafting' },
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
{ key: 'talents', label: 'Talents' },
|
onClick={onBack}
|
||||||
{ key: 'class', label: 'Class' },
|
onPointerDown={() => setSelectedNavKey('back')}
|
||||||
] as const).map((tab) => (
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{CUSTOMIZE_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
aria-selected={activeTab === tab.key}
|
aria-selected={activeTab === tab.key}
|
||||||
className={activeTab === tab.key ? 'active' : ''}
|
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
|
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => selectTab(tab.key)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
|
||||||
|
ref={navRef(`tab:${tab.key}`)}
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<p className="eyebrow">Healing Class</p>
|
<p className="eyebrow">Healing Class</p>
|
||||||
{profile.classes.map((candidate) => (
|
{profile.classes.map((candidate) => (
|
||||||
<button
|
<button
|
||||||
className={candidate.id === classId ? 'active' : ''}
|
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
onClick={() => chooseClass(candidate)}
|
onClick={() => chooseClass(candidate)}
|
||||||
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
|
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
|
||||||
|
ref={navRef(`class:${candidate.id}`)}
|
||||||
|
style={{ '--class-color': candidate.themeColor } as CSSProperties}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{candidate.name[0]}</span>
|
<span>{candidate.name[0]}</span>
|
||||||
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={selectedSlot === index ? 'selected' : ''}
|
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedSlot(index)}
|
onClick={() => setSelectedSlot(index)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
|
||||||
|
ref={navRef(`slot:${index}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<kbd>{index + 1}</kbd>
|
<kbd>{index + 1}</kbd>
|
||||||
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<p className="eyebrow">Class Abilities</p>
|
<p className="eyebrow">Class Abilities</p>
|
||||||
<h2>Ability Library</h2>
|
<h2>Ability Library</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
|
<div className="ability-library-actions">
|
||||||
|
{abilityPageCount > 1 && (
|
||||||
|
<div className="ability-library-pager">
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
|
||||||
|
disabled={currentAbilityPage === 0}
|
||||||
|
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
|
||||||
|
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
|
||||||
|
ref={navRef('ability-page-prev')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
|
||||||
|
disabled={currentAbilityPage >= abilityPageCount - 1}
|
||||||
|
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
|
||||||
|
onPointerDown={() => setSelectedNavKey('ability-page-next')}
|
||||||
|
ref={navRef('ability-page-next')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('clear-slot') ? 'true' : undefined}
|
||||||
|
onClick={clearSlot}
|
||||||
|
onPointerDown={() => setSelectedNavKey('clear-slot')}
|
||||||
|
ref={navRef('clear-slot')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear Selected Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ability-library">
|
<div className="ability-library">
|
||||||
{gameClass.spells.map((ability) => {
|
{visibleAbilities.map((ability) => {
|
||||||
const locked = ability.unlockLevel > profile.character.level
|
const locked = ability.unlockLevel > profile.character.level
|
||||||
const equipped = slots.includes(ability.id)
|
const equipped = slots.includes(ability.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
|
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
key={ability.id}
|
key={ability.id}
|
||||||
onClick={() => equipAbility(ability.id)}
|
onClick={() => equipAbility(ability.id)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
|
||||||
|
ref={navRef(`ability:${ability.id}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{locked ? 'L' : ability.glyph}</span>
|
<span>{locked ? 'L' : ability.glyph}</span>
|
||||||
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<div className="save-row">
|
<div className="save-row">
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('save') ? 'true' : undefined}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={persistChanges}
|
onClick={persistChanges}
|
||||||
|
onPointerDown={() => setSelectedNavKey('save')}
|
||||||
|
ref={navRef('save')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Character'}
|
{saving ? 'Saving...' : 'Save Character'}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
import { useGameAction, type InputAction } from '../input'
|
||||||
|
|
||||||
|
type HunterProfileScreenProps = {
|
||||||
|
profile: CharacterProfile
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type BossEntry = {
|
||||||
|
encounter: DungeonEncounter
|
||||||
|
dungeonId: number
|
||||||
|
dungeonName: string
|
||||||
|
contentType: 'dungeon' | 'raid'
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionItem = {
|
||||||
|
key: string
|
||||||
|
glyph: string
|
||||||
|
name: string
|
||||||
|
chance: string
|
||||||
|
quantity: number
|
||||||
|
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HunterProfileTab = 'stats' | 'collection'
|
||||||
|
|
||||||
|
type HunterProfileNavEntry =
|
||||||
|
| { kind: 'back'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
|
||||||
|
| { kind: 'stat'; key: string; row: number; column: number; index: number }
|
||||||
|
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
|
||||||
|
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
|
||||||
|
|
||||||
|
const HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
|
|
||||||
|
function bossInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropChanceLabel(chance: number) {
|
||||||
|
if (chance <= 0) return 'Unavailable'
|
||||||
|
const denominator = Math.round(1 / chance)
|
||||||
|
if (denominator >= 10) return `1 in ${denominator}`
|
||||||
|
return `${Math.round(chance * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function purplePetKey(encounterId: number) {
|
||||||
|
return `purple:${encounterId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||||
|
const screenRef = useRef<HTMLElement | null>(null)
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
|
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
|
||||||
|
dungeon.encounters
|
||||||
|
.filter((encounter) => encounter.isBoss)
|
||||||
|
.map((encounter) => ({
|
||||||
|
encounter,
|
||||||
|
dungeonId: dungeon.id,
|
||||||
|
dungeonName: dungeon.name,
|
||||||
|
contentType: dungeon.contentType,
|
||||||
|
})),
|
||||||
|
), [profile.dungeons])
|
||||||
|
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
|
||||||
|
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
||||||
|
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
|
||||||
|
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
|
||||||
|
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
|
||||||
|
const inventoryQuantities = useMemo(
|
||||||
|
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
|
||||||
|
[profile.inventory],
|
||||||
|
)
|
||||||
|
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
|
||||||
|
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
|
||||||
|
if (!best) return boss
|
||||||
|
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
|
||||||
|
? boss
|
||||||
|
: best
|
||||||
|
}, null)
|
||||||
|
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
|
||||||
|
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
|
||||||
|
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
|
||||||
|
const collectionItems = useMemo<CollectionItem[]>(() => {
|
||||||
|
if (!selectedBoss) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: `kills:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: 'K',
|
||||||
|
name: 'Boss Kills',
|
||||||
|
chance: 'Defeats',
|
||||||
|
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'stat',
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
},
|
||||||
|
...selectedBoss.encounter.lootTables.map((drop) => ({
|
||||||
|
key: `drop:${drop.difficultyId}:${drop.id}`,
|
||||||
|
glyph: drop.glyph,
|
||||||
|
name: drop.name,
|
||||||
|
chance: dropChanceLabel(drop.dropChance),
|
||||||
|
quantity: inventoryQuantities.get(drop.id) ?? 0,
|
||||||
|
rarity: drop.rarity,
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
key: `pet:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: '*',
|
||||||
|
name: `${selectedBoss.encounter.enemyName} Pet`,
|
||||||
|
chance: '1 in 500',
|
||||||
|
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'legendary',
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `purple-pet:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: 'P',
|
||||||
|
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
|
||||||
|
chance: '1 in 500',
|
||||||
|
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'epic',
|
||||||
|
source: `${selectedBoss.encounter.enemyName} Roguelike`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
|
||||||
|
const statTiles = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'stat:total-kills',
|
||||||
|
label: 'Total Boss Kills',
|
||||||
|
value: totalBossKills,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:most-killed',
|
||||||
|
label: 'Most Killed Boss',
|
||||||
|
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
|
||||||
|
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-matches',
|
||||||
|
label: 'PvP Matches',
|
||||||
|
value: matchesPlayed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-wins',
|
||||||
|
label: 'PvP Wins',
|
||||||
|
value: matchesWon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-win-rate',
|
||||||
|
label: 'PvP Win Rate',
|
||||||
|
value: `${winRate}%`,
|
||||||
|
},
|
||||||
|
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
|
||||||
|
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||||
|
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
|
||||||
|
const entries: HunterProfileNavEntry[] = [
|
||||||
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||||
|
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
|
||||||
|
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
|
||||||
|
]
|
||||||
|
if (activeTab === 'stats') {
|
||||||
|
entries.push(...statTiles.map((tile, index) => ({
|
||||||
|
kind: 'stat' as const,
|
||||||
|
key: tile.key,
|
||||||
|
row: 1,
|
||||||
|
column: index,
|
||||||
|
index,
|
||||||
|
})))
|
||||||
|
} else {
|
||||||
|
entries.push(
|
||||||
|
...bosses.map((boss, index) => ({
|
||||||
|
kind: 'boss' as const,
|
||||||
|
key: `boss:${boss.encounter.id}`,
|
||||||
|
row: index + 1,
|
||||||
|
column: 0,
|
||||||
|
bossId: boss.encounter.id,
|
||||||
|
})),
|
||||||
|
...collectionItems.map((item, index) => ({
|
||||||
|
kind: 'item' as const,
|
||||||
|
key: `item:${item.key}`,
|
||||||
|
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||||
|
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||||
|
itemKey: item.key,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}, [activeTab, bosses, collectionItems, statTiles])
|
||||||
|
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
||||||
|
const activeEntry = navEntries[activeIndex]
|
||||||
|
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
|
||||||
|
return {
|
||||||
|
mode: 'collection',
|
||||||
|
title: focusedItem.name,
|
||||||
|
subtitle: selectedBoss.encounter.enemyName,
|
||||||
|
summary: `Owned x${focusedItem.quantity}`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: focusedItem.glyph,
|
||||||
|
title: 'Drop Rate',
|
||||||
|
meta: focusedItem.chance,
|
||||||
|
detail: focusedItem.source,
|
||||||
|
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Boss Kills',
|
||||||
|
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
|
||||||
|
detail: selectedBoss.dungeonName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [activeTab, bossKills, focusedItem, selectedBoss])
|
||||||
|
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
|
||||||
|
|
||||||
|
function selected(entryKey: string) {
|
||||||
|
return activeEntry?.key === entryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectKey(entryKey: string) {
|
||||||
|
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
||||||
|
if (index >= 0) setSelectedIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: HunterProfileTab) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
setSelectedIndex(tab === 'stats' ? 1 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBoss(bossId: number) {
|
||||||
|
setSelectedBossId(bossId)
|
||||||
|
setFocusedItemKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(action: InputAction) {
|
||||||
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||||
|
setSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, navEntries.length - 1)
|
||||||
|
const active = navEntries[bounded]
|
||||||
|
if (!active) return 0
|
||||||
|
const candidates = navEntries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== bounded)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return bounded
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - active.row)
|
||||||
|
: Math.abs(a.entry.column - active.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - active.row)
|
||||||
|
: Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? bounded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(entry: HunterProfileNavEntry | undefined) {
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.kind === 'back') onBack()
|
||||||
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||||
|
else if (entry.kind === 'boss') selectBoss(entry.bossId)
|
||||||
|
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntry) return
|
||||||
|
screenRef.current
|
||||||
|
?.querySelector<HTMLElement>('[data-game-selected="true"]')
|
||||||
|
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}, [activeEntry])
|
||||||
|
|
||||||
|
useGameAction((action, inputDevice) => {
|
||||||
|
if (inputDevice !== 'controller') return
|
||||||
|
if (action === 'back') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openEntry(activeEntry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveSelection(action)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
|
||||||
|
<div className="screen-heading hunter-profile-heading">
|
||||||
|
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('tab:stats') ? 'true' : undefined}
|
||||||
|
onClick={() => selectTab('stats')}
|
||||||
|
onPointerDown={() => selectKey('tab:stats')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'stats'}
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('tab:collection') ? 'true' : undefined}
|
||||||
|
onClick={() => selectTab('collection')}
|
||||||
|
onPointerDown={() => selectKey('tab:collection')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'collection'}
|
||||||
|
>
|
||||||
|
Collection Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => selectKey('back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'stats' && (
|
||||||
|
<div className="hunter-stat-grid">
|
||||||
|
{statTiles.map((tile) => (
|
||||||
|
<article
|
||||||
|
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={selected(tile.key) ? 'true' : undefined}
|
||||||
|
key={tile.key}
|
||||||
|
onPointerDown={() => selectKey(tile.key)}
|
||||||
|
>
|
||||||
|
<span>{tile.label}</span>
|
||||||
|
<strong>{tile.value}</strong>
|
||||||
|
{tile.detail && <small>{tile.detail}</small>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'collection' && (
|
||||||
|
<div className="collection-log-layout">
|
||||||
|
<div className="collection-boss-list" aria-label="Bosses">
|
||||||
|
{bosses.map((boss) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
|
||||||
|
key={boss.encounter.id}
|
||||||
|
onClick={() => selectBoss(boss.encounter.id)}
|
||||||
|
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{bossInitials(boss.encounter.enemyName)}</span>
|
||||||
|
<strong>{boss.encounter.enemyName}</strong>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBoss && (
|
||||||
|
<article className="collection-boss-detail">
|
||||||
|
<div className="collection-drop-list">
|
||||||
|
{collectionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setFocusedItemKey(item.key)}
|
||||||
|
onFocus={() => setFocusedItemKey(item.key)}
|
||||||
|
onPointerDown={() => selectKey(`item:${item.key}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{item.glyph}</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.chance}</small>
|
||||||
|
<b>x{item.quantity}</b>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
memberHotEffects,
|
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,
|
||||||
@@ -146,6 +152,14 @@ type LivePvpMatch = {
|
|||||||
opponentClassName: string
|
opponentClassName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PvpOverlayNavEntry =
|
||||||
|
| { kind: 'queueBack'; row: number; column: number }
|
||||||
|
| { kind: 'pauseResume'; row: number; column: number }
|
||||||
|
| { kind: 'pauseLeave'; row: number; column: number }
|
||||||
|
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
|
||||||
|
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
|
||||||
|
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
|
||||||
|
|
||||||
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
|
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
|
||||||
id: 'revive-party-members',
|
id: 'revive-party-members',
|
||||||
name: 'Revive Party Members',
|
name: 'Revive Party Members',
|
||||||
@@ -350,12 +364,14 @@ export function PvPRoguelikeScreen({
|
|||||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||||
|
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
|
||||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const elapsedTicksRef = useRef(0)
|
const elapsedTicksRef = useRef(0)
|
||||||
const recordedRunRef = useRef(false)
|
const recordedRunRef = useRef(false)
|
||||||
|
const matchStatsRecordedRef = useRef(false)
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
const matchWinRewardClaimedRef = useRef(false)
|
const matchWinRewardClaimedRef = useRef(false)
|
||||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||||
@@ -398,12 +414,16 @@ export function PvPRoguelikeScreen({
|
|||||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||||
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
||||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
const playerSpellSlotCost = useCallback(
|
||||||
...spell,
|
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||||
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
|
||||||
slotIndex,
|
)
|
||||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
const playerSpellSlots = useSpellSlots({
|
||||||
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
spells: starterSpells,
|
||||||
|
cooldowns: playerSide.cooldowns,
|
||||||
|
active: status === 'playing' && !paused,
|
||||||
|
cost: playerSpellSlotCost,
|
||||||
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||||
[cpuSide.buffs, selfBuffChoicesCatalog],
|
[cpuSide.buffs, selfBuffChoicesCatalog],
|
||||||
@@ -528,6 +548,12 @@ export function PvPRoguelikeScreen({
|
|||||||
'loot',
|
'loot',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (result.petAwarded) {
|
||||||
|
addLog(
|
||||||
|
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||||
|
'loot',
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setRewardError(
|
setRewardError(
|
||||||
@@ -644,6 +670,7 @@ export function PvPRoguelikeScreen({
|
|||||||
pendingLiveUpgradeRef.current = null
|
pendingLiveUpgradeRef.current = null
|
||||||
loggedOpponentDoneRef.current = false
|
loggedOpponentDoneRef.current = false
|
||||||
recordedRunRef.current = false
|
recordedRunRef.current = false
|
||||||
|
matchStatsRecordedRef.current = false
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
matchWinRewardClaimedRef.current = false
|
matchWinRewardClaimedRef.current = false
|
||||||
cpuDefeatedRef.current = false
|
cpuDefeatedRef.current = false
|
||||||
@@ -677,6 +704,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setStartStage(setup.startStage)
|
setStartStage(setup.startStage)
|
||||||
setStage(setup.startStage)
|
setStage(setup.startStage)
|
||||||
setStatus('queueing')
|
setStatus('queueing')
|
||||||
|
setOverlaySelectedIndex(0)
|
||||||
setPlayerSide(setup.playerSide)
|
setPlayerSide(setup.playerSide)
|
||||||
setCpuSide(setup.opponentSide)
|
setCpuSide(setup.opponentSide)
|
||||||
setSelectedTargetId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
@@ -701,6 +729,7 @@ export function PvPRoguelikeScreen({
|
|||||||
pendingLiveUpgradeRef.current = null
|
pendingLiveUpgradeRef.current = null
|
||||||
loggedOpponentDoneRef.current = false
|
loggedOpponentDoneRef.current = false
|
||||||
recordedRunRef.current = false
|
recordedRunRef.current = false
|
||||||
|
matchStatsRecordedRef.current = false
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
matchWinRewardClaimedRef.current = false
|
matchWinRewardClaimedRef.current = false
|
||||||
cpuDefeatedRef.current = false
|
cpuDefeatedRef.current = false
|
||||||
@@ -880,9 +909,9 @@ export function PvPRoguelikeScreen({
|
|||||||
damageReductionTargets: new Set<string>(),
|
damageReductionTargets: new Set<string>(),
|
||||||
groupTargets,
|
groupTargets,
|
||||||
}
|
}
|
||||||
const nextCooldowns = { ...current.cooldowns }
|
let nextCooldowns = current.cooldowns
|
||||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
|
||||||
}
|
}
|
||||||
return applyPvpSpellCast({
|
return applyPvpSpellCast({
|
||||||
current,
|
current,
|
||||||
@@ -994,7 +1023,7 @@ export function PvPRoguelikeScreen({
|
|||||||
...side,
|
...side,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: regenerateResource(side.resource, 2.4, maxResource),
|
resource: regenerateResource(side.resource, 2.4, maxResource),
|
||||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||||
}
|
}
|
||||||
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
||||||
@@ -1006,6 +1035,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(null)
|
||||||
setSelectedDebuff(null)
|
setSelectedDebuff(null)
|
||||||
|
setOverlaySelectedIndex(0)
|
||||||
setStatus('upgrade-choice')
|
setStatus('upgrade-choice')
|
||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
|
||||||
|
|
||||||
@@ -1086,6 +1116,20 @@ export function PvPRoguelikeScreen({
|
|||||||
})
|
})
|
||||||
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'won' && status !== 'lost') return
|
||||||
|
if (matchStatsRecordedRef.current) return
|
||||||
|
matchStatsRecordedRef.current = true
|
||||||
|
recordPvpMatch(status === 'won')
|
||||||
|
.then(onProfileUpdated)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
addLog(
|
||||||
|
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [addLog, onProfileUpdated, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'upgrade-choice') return
|
if (status !== 'upgrade-choice') return
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
@@ -1279,13 +1323,140 @@ export function PvPRoguelikeScreen({
|
|||||||
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||||
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||||
|
|
||||||
|
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
|
||||||
|
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
|
||||||
|
if (paused) {
|
||||||
|
return [
|
||||||
|
{ kind: 'pauseResume', row: 0, column: 0 },
|
||||||
|
{ kind: 'pauseLeave', row: 1, column: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (status !== 'upgrade-choice') return []
|
||||||
|
const entries: PvpOverlayNavEntry[] = [
|
||||||
|
...playerBuffChoices.map((_, index) => ({
|
||||||
|
kind: 'upgradeBuff' as const,
|
||||||
|
index,
|
||||||
|
row: index,
|
||||||
|
column: 0,
|
||||||
|
})),
|
||||||
|
...playerDebuffChoices.map((_, index) => ({
|
||||||
|
kind: 'upgradeDebuff' as const,
|
||||||
|
index,
|
||||||
|
row: index,
|
||||||
|
column: 1,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
entries.push({
|
||||||
|
kind: 'upgradeContinue',
|
||||||
|
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
|
||||||
|
column: 1,
|
||||||
|
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
|
||||||
|
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
|
||||||
|
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
|
||||||
|
const active = entries[bounded]
|
||||||
|
if (active && !pvpOverlayEntryDisabled(active)) return active
|
||||||
|
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
|
||||||
|
const active = activePvpOverlayEntry()
|
||||||
|
if (!active || active.kind !== kind) return false
|
||||||
|
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
|
||||||
|
const entries = pvpOverlayEntries()
|
||||||
|
const nextIndex = entries.findIndex((entry) => {
|
||||||
|
if (entry.kind !== kind) return false
|
||||||
|
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePvpOverlaySelection(action: string) {
|
||||||
|
const entries = pvpOverlayEntries()
|
||||||
|
const enabledEntries = entries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
|
||||||
|
if (enabledEntries.length === 0) return
|
||||||
|
setOverlaySelectedIndex((current) => {
|
||||||
|
const candidate = entries[Math.min(current, entries.length - 1)]
|
||||||
|
const active = candidate && !pvpOverlayEntryDisabled(candidate)
|
||||||
|
? candidate
|
||||||
|
: enabledEntries[0]?.entry
|
||||||
|
if (!active) return current
|
||||||
|
const candidates = enabledEntries.filter(({ entry }) => {
|
||||||
|
if (entry === active) return false
|
||||||
|
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
|
||||||
|
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
||||||
|
if (entry.kind === 'queueBack') onExit()
|
||||||
|
else if (entry.kind === 'pauseResume') setPaused(false)
|
||||||
|
else if (entry.kind === 'pauseLeave') onExit()
|
||||||
|
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
||||||
|
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
||||||
|
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
||||||
|
}
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
|
if (status === 'queueing' || status === 'round-countdown') {
|
||||||
|
if (action === 'back' || action === 'pause') onExit()
|
||||||
|
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (paused) {
|
||||||
|
if (action === 'back' || action === 'pause') {
|
||||||
|
setPaused(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 'upgrade-choice') {
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'toggleSpeed') {
|
if (action === 'toggleSpeed') {
|
||||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'pause' || action === 'back') {
|
if (action === 'pause' || action === 'back') {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') {
|
||||||
|
setOverlaySelectedIndex(0)
|
||||||
|
setPaused((value) => !value)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (paused || status !== 'playing') return
|
if (paused || status !== 'playing') return
|
||||||
@@ -1339,6 +1510,9 @@ export function PvPRoguelikeScreen({
|
|||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: cpuSide.enemyHealth,
|
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||||
|
opponentResource: cpuSide.resource,
|
||||||
|
opponentMaxResource: maxResource,
|
||||||
|
opponentResourceName: gameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1362,6 +1536,7 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
cpuSide.enemyHealth,
|
cpuSide.enemyHealth,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
encounter.description,
|
encounter.description,
|
||||||
encounter.enemyName,
|
encounter.enemyName,
|
||||||
@@ -1397,9 +1572,18 @@ export function PvPRoguelikeScreen({
|
|||||||
>
|
>
|
||||||
<section className="content-screen pvp-match-screen">
|
<section className="content-screen pvp-match-screen">
|
||||||
{status === 'queueing' && (
|
{status === 'queueing' && (
|
||||||
<div className="placeholder-panel">
|
<div className="placeholder-panel pvp-match-queue-panel" data-game-nav-active="true">
|
||||||
<div className="placeholder-runes">P V P</div>
|
<div className="placeholder-runes">P V P</div>
|
||||||
<p>{queueMessage}</p>
|
<p>{queueMessage}</p>
|
||||||
|
<button
|
||||||
|
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
onClick={onExit}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('queueBack')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1424,13 +1608,13 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="pvp-clear-wrap">
|
<div className="pvp-clear-wrap">
|
||||||
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
<span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1464,12 +1648,12 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="pvp-clear-wrap">
|
<div className="pvp-clear-wrap">
|
||||||
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
<span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1495,6 +1679,7 @@ export function PvPRoguelikeScreen({
|
|||||||
bindings={activeBindings}
|
bindings={activeBindings}
|
||||||
canCast={status === 'playing' && !playerDone && playerAlive}
|
canCast={status === 'playing' && !playerDone && playerAlive}
|
||||||
className="pvp-bottom-spell-bar"
|
className="pvp-bottom-spell-bar"
|
||||||
|
focusable={false}
|
||||||
iconStyle={controllerIconStyle}
|
iconStyle={controllerIconStyle}
|
||||||
onCast={castPlayerSpell}
|
onCast={castPlayerSpell}
|
||||||
resource={playerSide.resource}
|
resource={playerSide.resource}
|
||||||
@@ -1514,7 +1699,7 @@ export function PvPRoguelikeScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen" data-game-nav-active="true">
|
||||||
<div className="pvp-upgrade-dialog">
|
<div className="pvp-upgrade-dialog">
|
||||||
<div className="pvp-upgrade-header">
|
<div className="pvp-upgrade-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1529,9 +1714,11 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="upgrade-choice-grid">
|
<div className="upgrade-choice-grid">
|
||||||
{playerBuffChoices.map((choice) => (
|
{playerBuffChoices.map((choice) => (
|
||||||
<button
|
<button
|
||||||
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
|
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
key={choice.id}
|
key={choice.id}
|
||||||
onClick={() => setSelectedBuff(choice)}
|
onClick={() => setSelectedBuff(choice)}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>{choice.name}</strong>
|
<strong>{choice.name}</strong>
|
||||||
@@ -1545,9 +1732,11 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="upgrade-choice-grid">
|
<div className="upgrade-choice-grid">
|
||||||
{playerDebuffChoices.map((choice) => (
|
{playerDebuffChoices.map((choice) => (
|
||||||
<button
|
<button
|
||||||
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
|
className={`${selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
key={choice.id}
|
key={choice.id}
|
||||||
onClick={() => setSelectedDebuff(choice)}
|
onClick={() => setSelectedDebuff(choice)}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>{choice.name}</strong>
|
<strong>{choice.name}</strong>
|
||||||
@@ -1558,7 +1747,14 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
||||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
<button
|
||||||
|
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
|
||||||
|
onClick={() => confirmUpgradeChoices()}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1566,12 +1762,28 @@ export function PvPRoguelikeScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{paused && (
|
{paused && (
|
||||||
<div className="pause-screen">
|
<div className="pause-screen" data-game-nav-active="true">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Paused</p>
|
<p className="eyebrow">Paused</p>
|
||||||
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||||
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
<button
|
||||||
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
|
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
onClick={() => setPaused(false)}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
onClick={onExit}
|
||||||
|
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import {
|
|||||||
type PartyMember,
|
type PartyMember,
|
||||||
type Spell,
|
type Spell,
|
||||||
} from '../game'
|
} from '../game'
|
||||||
import { completeRoguelike } from '../profile'
|
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||||
import type { CharacterProfile } from '../profile'
|
import type { CharacterProfile } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
import { SpellBar } from './SpellBars'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||||
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||||
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
||||||
@@ -31,10 +33,13 @@ import {
|
|||||||
spellPowerMultiplier,
|
spellPowerMultiplier,
|
||||||
spellResourceCost as modifiedSpellResourceCost,
|
spellResourceCost as modifiedSpellResourceCost,
|
||||||
} from '../combat/spellModifiers'
|
} from '../combat/spellModifiers'
|
||||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
import { regenerateResource } from '../combat/combatTick'
|
||||||
import { advanceMemberTick } from '../combat/combatEngine'
|
import { advanceMemberTick } from '../combat/combatEngine'
|
||||||
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
||||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||||
|
import {
|
||||||
|
pruneExpiredCooldowns,
|
||||||
|
} from '../combat/spellCasting'
|
||||||
import {
|
import {
|
||||||
appendCombatLog,
|
appendCombatLog,
|
||||||
STADIUM_COMBAT_LOG_LIMIT,
|
STADIUM_COMBAT_LOG_LIMIT,
|
||||||
@@ -386,6 +391,7 @@ export function PvpStadiumScreen({
|
|||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const submittedShopRef = useRef(false)
|
const submittedShopRef = useRef(false)
|
||||||
const awardedXpRef = useRef(new Set<string>())
|
const awardedXpRef = useRef(new Set<string>())
|
||||||
|
const matchStatsRecordedRef = useRef(false)
|
||||||
const queuedMatchRef = useRef(false)
|
const queuedMatchRef = useRef(false)
|
||||||
const roundResolvedRef = useRef(false)
|
const roundResolvedRef = useRef(false)
|
||||||
const loggedOpponentRoundRef = useRef('')
|
const loggedOpponentRoundRef = useRef('')
|
||||||
@@ -400,12 +406,16 @@ export function PvpStadiumScreen({
|
|||||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
const playerSpellSlotCost = useCallback(
|
||||||
...spell,
|
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||||
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
[playerBuffCounts, playerSide.freeCastReady],
|
||||||
slotIndex,
|
)
|
||||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
const playerSpellSlots = useSpellSlots({
|
||||||
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
spells: starterSpells,
|
||||||
|
cooldowns: playerSide.cooldowns,
|
||||||
|
active: status === 'playing' && !paused,
|
||||||
|
cost: playerSpellSlotCost,
|
||||||
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||||
[buffCatalog, cpuSide.buffs],
|
[buffCatalog, cpuSide.buffs],
|
||||||
@@ -496,6 +506,7 @@ export function PvpStadiumScreen({
|
|||||||
queuedMatchRef.current = true
|
queuedMatchRef.current = true
|
||||||
nextLogId.current = 2
|
nextLogId.current = 2
|
||||||
awardedXpRef.current = new Set()
|
awardedXpRef.current = new Set()
|
||||||
|
matchStatsRecordedRef.current = false
|
||||||
roundResolvedRef.current = false
|
roundResolvedRef.current = false
|
||||||
setPlayerSide(setup.playerSide)
|
setPlayerSide(setup.playerSide)
|
||||||
setCpuSide(setup.opponentSide)
|
setCpuSide(setup.opponentSide)
|
||||||
@@ -532,6 +543,7 @@ export function PvpStadiumScreen({
|
|||||||
queuedMatchRef.current = true
|
queuedMatchRef.current = true
|
||||||
nextLogId.current = 2
|
nextLogId.current = 2
|
||||||
awardedXpRef.current = new Set()
|
awardedXpRef.current = new Set()
|
||||||
|
matchStatsRecordedRef.current = false
|
||||||
roundResolvedRef.current = false
|
roundResolvedRef.current = false
|
||||||
setPlayerSide(setup.playerSide)
|
setPlayerSide(setup.playerSide)
|
||||||
setCpuSide(setup.opponentSide)
|
setCpuSide(setup.opponentSide)
|
||||||
@@ -790,7 +802,7 @@ export function PvpStadiumScreen({
|
|||||||
...side,
|
...side,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
||||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||||
survivalSeconds: nextSurvival,
|
survivalSeconds: nextSurvival,
|
||||||
dampeningPercent,
|
dampeningPercent,
|
||||||
}
|
}
|
||||||
@@ -873,6 +885,20 @@ export function PvpStadiumScreen({
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
|
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'won' && status !== 'lost') return
|
||||||
|
if (matchStatsRecordedRef.current) return
|
||||||
|
matchStatsRecordedRef.current = true
|
||||||
|
recordPvpMatch(status === 'won')
|
||||||
|
.then(onProfileUpdated)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
addLog(
|
||||||
|
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [addLog, onProfileUpdated, status])
|
||||||
|
|
||||||
const startNextRound = useCallback(() => {
|
const startNextRound = useCallback(() => {
|
||||||
const nextRound = roundIndex + 1
|
const nextRound = roundIndex + 1
|
||||||
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
|
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
|
||||||
@@ -918,24 +944,23 @@ export function PvpStadiumScreen({
|
|||||||
|
|
||||||
const finishShop = useCallback(() => {
|
const finishShop = useCallback(() => {
|
||||||
if (shopReady || status !== 'shop') return
|
if (shopReady || status !== 'shop') return
|
||||||
setShopReady(true)
|
const liveMatch = liveMatchRef.current
|
||||||
submittedShopRef.current = true
|
if (!liveMatch) {
|
||||||
setPlayerSide((current) => {
|
startNextRound()
|
||||||
const next = { ...current, shopReady: true }
|
|
||||||
playerRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
if (liveMatchRef.current) {
|
|
||||||
submitPvpUpgradeChoice(liveMatchRef.current.id, {
|
|
||||||
encounterIndex: roundIndex,
|
|
||||||
buffId: 'stadium-shop',
|
|
||||||
debuffId: '',
|
|
||||||
purchases: playerRef.current.buffs,
|
|
||||||
shopReady: true,
|
|
||||||
}).catch(() => undefined)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startNextRound()
|
setShopReady(true)
|
||||||
|
submittedShopRef.current = true
|
||||||
|
const readyPlayer = { ...playerRef.current, shopReady: true }
|
||||||
|
playerRef.current = readyPlayer
|
||||||
|
setPlayerSide(readyPlayer)
|
||||||
|
submitPvpUpgradeChoice(liveMatch.id, {
|
||||||
|
encounterIndex: roundIndex,
|
||||||
|
buffId: 'stadium-shop',
|
||||||
|
debuffId: '',
|
||||||
|
purchases: playerRef.current.buffs,
|
||||||
|
shopReady: true,
|
||||||
|
}).catch(() => undefined)
|
||||||
}, [roundIndex, shopReady, startNextRound, status])
|
}, [roundIndex, shopReady, startNextRound, status])
|
||||||
|
|
||||||
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
|
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
|
||||||
@@ -1055,6 +1080,9 @@ export function PvpStadiumScreen({
|
|||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: 0,
|
opponentEnemyHealth: 0,
|
||||||
|
opponentResource: cpuSide.resource,
|
||||||
|
opponentMaxResource: MAX_RESOURCE,
|
||||||
|
opponentResourceName: gameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1083,6 +1111,7 @@ export function PvpStadiumScreen({
|
|||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
cpuSide.survivalSeconds,
|
cpuSide.survivalSeconds,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dualScreenFloatingTexts,
|
dualScreenFloatingTexts,
|
||||||
@@ -1164,16 +1193,16 @@ export function PvpStadiumScreen({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-spell-strip">
|
<section className="dual-top-spell-strip">
|
||||||
{starterSpells.map((spell, slotIndex) => {
|
{playerSpellSlots.map((spell, slotIndex) => {
|
||||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
if (!spell) return null
|
||||||
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
|
const remaining = spell.remaining
|
||||||
const percent = remaining > 0
|
const percent = remaining > 0
|
||||||
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
: 0
|
: 0
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="dual-top-spell"
|
className="dual-top-spell"
|
||||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
|
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
|
||||||
key={spell.id}
|
key={spell.id}
|
||||||
onClick={() => castPlayerSpell(spell)}
|
onClick={() => castPlayerSpell(spell)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1188,7 +1217,7 @@ export function PvpStadiumScreen({
|
|||||||
<div className="dual-top-resource">
|
<div className="dual-top-resource">
|
||||||
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
|
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1222,7 +1251,7 @@ export function PvpStadiumScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid pvp-party-grid">
|
<div className="party-grid pvp-party-grid">
|
||||||
@@ -1252,7 +1281,7 @@ export function PvpStadiumScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid pvp-party-grid">
|
<div className="party-grid pvp-party-grid">
|
||||||
@@ -1276,6 +1305,7 @@ export function PvpStadiumScreen({
|
|||||||
bindings={activeBindings}
|
bindings={activeBindings}
|
||||||
canCast={status === 'playing' && playerAlive}
|
canCast={status === 'playing' && playerAlive}
|
||||||
className="pvp-bottom-spell-bar"
|
className="pvp-bottom-spell-bar"
|
||||||
|
focusable={false}
|
||||||
iconStyle={controllerIconStyle}
|
iconStyle={controllerIconStyle}
|
||||||
onCast={castPlayerSpell}
|
onCast={castPlayerSpell}
|
||||||
resource={playerSide.resource}
|
resource={playerSide.resource}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ACTION_LABELS,
|
ACTION_LABELS,
|
||||||
INPUT_ACTIONS,
|
INPUT_ACTIONS,
|
||||||
useInput,
|
useInput,
|
||||||
|
useGameAction,
|
||||||
|
type InputAction,
|
||||||
type InputDevice,
|
type InputDevice,
|
||||||
} from '../input'
|
} from '../input'
|
||||||
import {
|
import {
|
||||||
ControllerBindingLabel,
|
ControllerBindingLabel,
|
||||||
ControllerStylePreview,
|
ControllerStylePreview,
|
||||||
} from './ControllerIcons'
|
} from './ControllerIcons'
|
||||||
|
|
||||||
const CONTROLLER_STYLE_LABELS = {
|
|
||||||
xbox: 'Xbox',
|
|
||||||
playstation: 'PlayStation',
|
|
||||||
nintendo: 'Nintendo',
|
|
||||||
} as const
|
|
||||||
import { useDualScreen } from '../dualScreen'
|
import { useDualScreen } from '../dualScreen'
|
||||||
import {
|
import {
|
||||||
getNativeDisplays,
|
getNativeDisplays,
|
||||||
@@ -22,21 +18,62 @@ import {
|
|||||||
type AndroidDisplay,
|
type AndroidDisplay,
|
||||||
} from '../nativeDualScreen'
|
} from '../nativeDualScreen'
|
||||||
|
|
||||||
|
type SettingsTab = 'display' | 'input' | 'bindings'
|
||||||
|
|
||||||
|
type SettingsNavEntry =
|
||||||
|
| { kind: 'back'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab }
|
||||||
|
| { kind: 'dualToggle'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'openCompanion'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'directTargeting'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'touchLock'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS }
|
||||||
|
| { kind: 'device'; key: string; row: number; column: number; device: InputDevice }
|
||||||
|
| { kind: 'binding'; key: string; row: number; column: number; action: InputAction }
|
||||||
|
| { kind: 'resetBindings'; key: string; row: number; column: number }
|
||||||
|
|
||||||
|
const CONTROLLER_STYLE_LABELS = {
|
||||||
|
xbox: 'Xbox',
|
||||||
|
playstation: 'PlayStation',
|
||||||
|
nintendo: 'Nintendo',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const
|
||||||
|
const SETTINGS_TAB_LABELS: Record<SettingsTab, string> = {
|
||||||
|
display: 'Display',
|
||||||
|
input: 'Input',
|
||||||
|
bindings: 'Bindings',
|
||||||
|
}
|
||||||
|
const SETTINGS_BINDING_COLUMNS = 2
|
||||||
|
const DIRECT_TARGET_ACTIONS = new Set<InputAction>([
|
||||||
|
'targetParty1',
|
||||||
|
'targetParty2',
|
||||||
|
'targetParty3',
|
||||||
|
'targetParty4',
|
||||||
|
'targetParty5',
|
||||||
|
'targetParty6',
|
||||||
|
'toggleTargetGroup',
|
||||||
|
])
|
||||||
|
|
||||||
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||||
const [device, setDevice] = useState<InputDevice>('controller')
|
const [device, setDevice] = useState<InputDevice>('controller')
|
||||||
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
|
const [settingsTab, setSettingsTab] = useState<SettingsTab>('display')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||||
const [displayMessage, setDisplayMessage] = useState('')
|
const [displayMessage, setDisplayMessage] = useState('')
|
||||||
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
||||||
|
const navRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||||
const {
|
const {
|
||||||
bindings,
|
bindings,
|
||||||
capture,
|
capture,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
|
combatTouchLocked,
|
||||||
beginCapture,
|
beginCapture,
|
||||||
cancelCapture,
|
cancelCapture,
|
||||||
resetBindings,
|
resetBindings,
|
||||||
setControllerIconStyle,
|
setControllerIconStyle,
|
||||||
setDirectPartyTargeting,
|
setDirectPartyTargeting,
|
||||||
|
setCombatTouchLocked,
|
||||||
} = useInput()
|
} = useInput()
|
||||||
const {
|
const {
|
||||||
enabled: dualScreenEnabled,
|
enabled: dualScreenEnabled,
|
||||||
@@ -45,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
openTopDisplay,
|
openTopDisplay,
|
||||||
} = useDualScreen()
|
} = useDualScreen()
|
||||||
const nativeDualScreen = hasNativeDualScreenBridge()
|
const nativeDualScreen = hasNativeDualScreenBridge()
|
||||||
const directTargetActions = new Set([
|
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
|
||||||
'targetParty1',
|
|
||||||
'targetParty2',
|
|
||||||
'targetParty3',
|
|
||||||
'targetParty4',
|
|
||||||
'targetParty5',
|
|
||||||
'targetParty6',
|
|
||||||
'toggleTargetGroup',
|
|
||||||
])
|
|
||||||
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
|
||||||
directPartyTargeting
|
directPartyTargeting
|
||||||
? action !== 'previousTarget' && action !== 'nextTarget'
|
? action !== 'previousTarget' && action !== 'nextTarget'
|
||||||
: !directTargetActions.has(action)
|
: !DIRECT_TARGET_ACTIONS.has(action)
|
||||||
))
|
)), [directPartyTargeting])
|
||||||
|
const navEntries = useMemo<SettingsNavEntry[]>(() => {
|
||||||
|
const entries: SettingsNavEntry[] = [
|
||||||
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||||
|
...SETTINGS_TAB_ORDER.map((tab, index) => ({
|
||||||
|
kind: 'tab' as const,
|
||||||
|
key: `tab:${tab}`,
|
||||||
|
row: 0,
|
||||||
|
column: index + 1,
|
||||||
|
tab,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
if (settingsTab === 'display') {
|
||||||
|
entries.push(
|
||||||
|
{ kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 },
|
||||||
|
{ kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 },
|
||||||
|
)
|
||||||
|
} else if (settingsTab === 'input') {
|
||||||
|
entries.push(
|
||||||
|
{ kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 },
|
||||||
|
{ kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 },
|
||||||
|
{ kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' },
|
||||||
|
{ kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' },
|
||||||
|
{ kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
entries.push(
|
||||||
|
{ kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' },
|
||||||
|
{ kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' },
|
||||||
|
...visibleActions.map((action, index) => ({
|
||||||
|
kind: 'binding' as const,
|
||||||
|
key: `bindings:action:${action}`,
|
||||||
|
row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2,
|
||||||
|
column: index % SETTINGS_BINDING_COLUMNS,
|
||||||
|
action,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
kind: 'resetBindings',
|
||||||
|
key: 'bindings:reset',
|
||||||
|
row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3,
|
||||||
|
column: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}, [settingsTab, visibleActions])
|
||||||
|
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
||||||
|
const activeEntry = navEntries[activeIndex]
|
||||||
|
|
||||||
|
function selected(entryKey: string) {
|
||||||
|
return activeEntry?.key === entryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRef(entryKey: string) {
|
||||||
|
return (node: HTMLElement | null) => {
|
||||||
|
navRefs.current[entryKey] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectKey(entryKey: string) {
|
||||||
|
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
||||||
|
if (index >= 0) setSelectedIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: SettingsTab) {
|
||||||
|
setSettingsTab(tab)
|
||||||
|
const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab)
|
||||||
|
setSelectedIndex(tabIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstContentKey(tab: SettingsTab) {
|
||||||
|
if (tab === 'display') return 'display:dual-toggle'
|
||||||
|
if (tab === 'input') return 'input:direct-targeting'
|
||||||
|
return 'bindings:device:controller'
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(action: InputAction) {
|
||||||
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||||
|
setSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, navEntries.length - 1)
|
||||||
|
const active = navEntries[bounded]
|
||||||
|
if (!active) return 0
|
||||||
|
if (action === 'navigateDown' && active.row === 0) {
|
||||||
|
const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab))
|
||||||
|
if (nextIndex >= 0) return nextIndex
|
||||||
|
}
|
||||||
|
if (action === 'navigateUp' && active.row === 1) {
|
||||||
|
const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`)
|
||||||
|
if (tabIndex >= 0) return tabIndex
|
||||||
|
}
|
||||||
|
const candidates = navEntries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== bounded)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return bounded
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - active.row)
|
||||||
|
: Math.abs(a.entry.column - active.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - active.row)
|
||||||
|
: Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? bounded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(entry: SettingsNavEntry | undefined) {
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.kind === 'back') onBack()
|
||||||
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||||
|
else if (entry.kind === 'dualToggle') {
|
||||||
|
setDualScreenEnabled(!dualScreenEnabled)
|
||||||
|
setDisplayMessage('')
|
||||||
|
} else if (entry.kind === 'openCompanion') {
|
||||||
|
void launchTopDisplay()
|
||||||
|
} else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting)
|
||||||
|
else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked)
|
||||||
|
else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style)
|
||||||
|
else if (entry.kind === 'device') setDevice(entry.device)
|
||||||
|
else if (entry.kind === 'binding') beginCapture(device, entry.action)
|
||||||
|
else if (entry.kind === 'resetBindings') resetBindings(device)
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshNativeDisplays() {
|
async function refreshNativeDisplays() {
|
||||||
if (!nativeDualScreen) return
|
if (!nativeDualScreen) return
|
||||||
@@ -77,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
.catch(() => setAndroidDisplays([]))
|
.catch(() => setAndroidDisplays([]))
|
||||||
}, [nativeDualScreen])
|
}, [nativeDualScreen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntry) return
|
||||||
|
navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}, [activeEntry])
|
||||||
|
|
||||||
|
useGameAction((action, inputDevice) => {
|
||||||
|
if (inputDevice !== 'controller' || capture) return
|
||||||
|
if (action === 'back') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openEntry(activeEntry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveSelection(action)
|
||||||
|
})
|
||||||
|
|
||||||
async function launchTopDisplay() {
|
async function launchTopDisplay() {
|
||||||
const opened = await openTopDisplay()
|
const opened = await openTopDisplay()
|
||||||
setDisplayMessage(opened
|
setDisplayMessage(opened
|
||||||
@@ -88,34 +264,39 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen settings-screen">
|
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
|
||||||
<div className="screen-heading">
|
<div className="settings-nav">
|
||||||
<div>
|
<button
|
||||||
<p className="eyebrow">Game Options</p>
|
className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||||
<h1>Settings</h1>
|
data-controller-nav="skip"
|
||||||
</div>
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
onClick={onBack}
|
||||||
|
onPointerDown={() => selectKey('back')}
|
||||||
|
ref={navRef('back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||||
|
{SETTINGS_TAB_ORDER.map((tab) => (
|
||||||
|
<button
|
||||||
|
aria-selected={settingsTab === tab}
|
||||||
|
className={`${settingsTab === tab ? 'selected' : ''} ${selected(`tab:${tab}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`tab:${tab}`) ? 'true' : undefined}
|
||||||
|
key={tab}
|
||||||
|
onClick={() => selectTab(tab)}
|
||||||
|
onPointerDown={() => selectKey(`tab:${tab}`)}
|
||||||
|
ref={navRef(`tab:${tab}`)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{SETTINGS_TAB_LABELS[tab]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</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>
|
||||||
@@ -128,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="dual-screen-actions">
|
<div className="dual-screen-actions">
|
||||||
<button
|
<button
|
||||||
className={dualScreenEnabled ? 'selected' : ''}
|
className={`${dualScreenEnabled ? 'selected' : ''} ${selected('display:dual-toggle') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('display:dual-toggle') ? 'true' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDualScreenEnabled(!dualScreenEnabled)
|
setDualScreenEnabled(!dualScreenEnabled)
|
||||||
setDisplayMessage('')
|
setDisplayMessage('')
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={() => selectKey('display:dual-toggle')}
|
||||||
|
ref={navRef('display:dual-toggle')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={launchTopDisplay} type="button">
|
<button
|
||||||
|
className={selected('display:open-companion') ? 'game-selected' : ''}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('display:open-companion') ? 'true' : undefined}
|
||||||
|
onClick={launchTopDisplay}
|
||||||
|
onPointerDown={() => selectKey('display:open-companion')}
|
||||||
|
ref={navRef('display:open-companion')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,20 +367,40 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-pressed={directPartyTargeting}
|
aria-pressed={directPartyTargeting}
|
||||||
className={directPartyTargeting ? 'selected' : ''}
|
className={`${directPartyTargeting ? 'selected' : ''} ${selected('input:direct-targeting') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('input:direct-targeting') ? 'true' : undefined}
|
||||||
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
||||||
|
onPointerDown={() => selectKey('input:direct-targeting')}
|
||||||
|
ref={navRef('input:direct-targeting')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={combatTouchLocked}
|
||||||
|
className={`${combatTouchLocked ? 'selected' : ''} ${selected('input:touch-lock') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('input:touch-lock') ? 'true' : undefined}
|
||||||
|
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
|
||||||
|
onPointerDown={() => selectKey('input:touch-lock')}
|
||||||
|
ref={navRef('input:touch-lock')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
|
||||||
|
</button>
|
||||||
<div className="controller-icon-options">
|
<div className="controller-icon-options">
|
||||||
<span>Controller Icons</span>
|
<span>Controller Icons</span>
|
||||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||||
<button
|
<button
|
||||||
aria-pressed={controllerIconStyle === style}
|
aria-pressed={controllerIconStyle === style}
|
||||||
className={controllerIconStyle === style ? 'selected' : ''}
|
className={`${controllerIconStyle === style ? 'selected' : ''} ${selected(`input:icons:${style}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`input:icons:${style}`) ? 'true' : undefined}
|
||||||
key={style}
|
key={style}
|
||||||
onClick={() => setControllerIconStyle(style)}
|
onClick={() => setControllerIconStyle(style)}
|
||||||
|
onPointerDown={() => selectKey(`input:icons:${style}`)}
|
||||||
|
ref={navRef(`input:icons:${style}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ControllerStylePreview iconStyle={style} />
|
<ControllerStylePreview iconStyle={style} />
|
||||||
@@ -210,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
|
|
||||||
<div className="binding-tabs">
|
<div className="binding-tabs">
|
||||||
<button
|
<button
|
||||||
className={device === 'controller' ? 'selected' : ''}
|
className={`${device === 'controller' ? 'selected' : ''} ${selected('bindings:device:controller') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('bindings:device:controller') ? 'true' : undefined}
|
||||||
onClick={() => setDevice('controller')}
|
onClick={() => setDevice('controller')}
|
||||||
|
onPointerDown={() => selectKey('bindings:device:controller')}
|
||||||
|
ref={navRef('bindings:device:controller')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Controller
|
Controller
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={device === 'pc' ? 'selected' : ''}
|
className={`${device === 'pc' ? 'selected' : ''} ${selected('bindings:device:pc') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('bindings:device:pc') ? 'true' : undefined}
|
||||||
onClick={() => setDevice('pc')}
|
onClick={() => setDevice('pc')}
|
||||||
|
onPointerDown={() => selectKey('bindings:device:pc')}
|
||||||
|
ref={navRef('bindings:device:pc')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
PC
|
PC
|
||||||
@@ -228,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
<div className="binding-list">
|
<div className="binding-list">
|
||||||
{visibleActions.map((action) => (
|
{visibleActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
className={capture?.device === device && capture.action === action ? 'listening' : ''}
|
className={`${capture?.device === device && capture.action === action ? 'listening' : ''} ${selected(`bindings:action:${action}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`bindings:action:${action}`) ? 'true' : undefined}
|
||||||
key={action}
|
key={action}
|
||||||
onClick={() => beginCapture(device, action)}
|
onClick={() => beginCapture(device, action)}
|
||||||
|
onPointerDown={() => selectKey(`bindings:action:${action}`)}
|
||||||
|
ref={navRef(`bindings:action:${action}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{ACTION_LABELS[action]}</span>
|
<span>{ACTION_LABELS[action]}</span>
|
||||||
@@ -250,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
|
|
||||||
<footer className="settings-footer">
|
<footer className="settings-footer">
|
||||||
<span>Bindings are saved automatically on this device.</span>
|
<span>Bindings are saved automatically on this device.</span>
|
||||||
<button className="text-button" onClick={() => resetBindings(device)} type="button">
|
<button
|
||||||
|
className={`text-button ${selected('bindings:reset') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('bindings:reset') ? 'true' : undefined}
|
||||||
|
onClick={() => resetBindings(device)}
|
||||||
|
onPointerDown={() => selectKey('bindings:reset')}
|
||||||
|
ref={navRef('bindings:reset')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
dispatchExternalGameAction,
|
dispatchExternalGameAction,
|
||||||
type ControllerIconStyle,
|
type ControllerIconStyle,
|
||||||
type InputAction,
|
type InputAction,
|
||||||
|
useGameAction,
|
||||||
} from './input'
|
} from './input'
|
||||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||||
import { PartyMemberFrame } from './components/PartyFrames'
|
import { PartyMemberFrame } from './components/PartyFrames'
|
||||||
|
import { barFillStyle } from './components/barStyles'
|
||||||
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
||||||
|
|
||||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||||
@@ -47,6 +49,9 @@ export type DualScreenCombatState = {
|
|||||||
opponentClassName?: string
|
opponentClassName?: string
|
||||||
opponentParty?: PartyMember[]
|
opponentParty?: PartyMember[]
|
||||||
opponentEnemyHealth?: number
|
opponentEnemyHealth?: number
|
||||||
|
opponentResource?: number
|
||||||
|
opponentMaxResource?: number
|
||||||
|
opponentResourceName?: string
|
||||||
opponentBuffSummary?: string
|
opponentBuffSummary?: string
|
||||||
opponentDebuffSummary?: string
|
opponentDebuffSummary?: string
|
||||||
floatingTexts: Array<{
|
floatingTexts: Array<{
|
||||||
@@ -79,7 +84,7 @@ export type DualScreenCombatState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DualScreenWorkshopState = {
|
export type DualScreenWorkshopState = {
|
||||||
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
summary?: string
|
summary?: string
|
||||||
@@ -92,14 +97,34 @@ export type DualScreenWorkshopState = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DualScreenSetupState = {
|
||||||
|
contentType: 'dungeon' | 'raid'
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
description: string
|
||||||
|
initials: string
|
||||||
|
difficultyName: string
|
||||||
|
itemLevel: number
|
||||||
|
experience: number
|
||||||
|
lockedReason?: string
|
||||||
|
stats: {
|
||||||
|
health: string
|
||||||
|
damage: string
|
||||||
|
xp: string
|
||||||
|
loot: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type DualScreenMessage =
|
type DualScreenMessage =
|
||||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||||
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||||
|
| { type: 'setup-state'; state: DualScreenSetupState }
|
||||||
| { type: 'companion-ready' }
|
| { type: 'companion-ready' }
|
||||||
| { type: 'companion-heartbeat' }
|
| { type: 'companion-heartbeat' }
|
||||||
| { type: 'control-action'; action: InputAction }
|
| { type: 'control-action'; action: InputAction }
|
||||||
| { type: 'combat-ended' }
|
| { type: 'combat-ended' }
|
||||||
| { type: 'workshop-ended' }
|
| { type: 'workshop-ended' }
|
||||||
|
| { type: 'setup-ended' }
|
||||||
|
|
||||||
type DualScreenContextValue = {
|
type DualScreenContextValue = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -145,6 +170,24 @@ function formatDualTime(seconds: number) {
|
|||||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRelayBottomAction(
|
||||||
|
action: InputAction,
|
||||||
|
state: DualScreenCombatState | null,
|
||||||
|
) {
|
||||||
|
if (!state) return false
|
||||||
|
if (action === 'confirm' || action === 'toggleTouchLock') return false
|
||||||
|
if (state.status === 'playing') return true
|
||||||
|
return action === 'pause' || action === 'back'
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRelaySetupAction(
|
||||||
|
action: InputAction,
|
||||||
|
state: DualScreenSetupState | null,
|
||||||
|
) {
|
||||||
|
if (!state) return false
|
||||||
|
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||||
const [enabled, setEnabledState] = useState(
|
const [enabled, setEnabledState] = useState(
|
||||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||||
@@ -346,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
|
|||||||
}
|
}
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === 'companion-ready') publish()
|
if (event.data.type === 'companion-ready') publish()
|
||||||
|
if (event.data.type === 'control-action') {
|
||||||
|
dispatchExternalGameAction(event.data.action, 'controller')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
publish()
|
publish()
|
||||||
return () => {
|
return () => {
|
||||||
@@ -362,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
|
|||||||
}, [enabled, state])
|
}, [enabled, state])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDualScreenSetupPublisher(
|
||||||
|
state: DualScreenSetupState | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const stateRef = useRef(state)
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
if (!channel) return
|
||||||
|
const publish = () => {
|
||||||
|
if (stateRef.current) {
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'setup-state',
|
||||||
|
state: stateRef.current,
|
||||||
|
} satisfies DualScreenMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
|
if (event.data.type === 'companion-ready') publish()
|
||||||
|
if (event.data.type === 'control-action') {
|
||||||
|
dispatchExternalGameAction(event.data.action, 'controller')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return () => {
|
||||||
|
channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
}, [enabled, state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = createChannel()
|
||||||
|
if (!enabled || !channel) return
|
||||||
|
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
|
||||||
|
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
|
||||||
|
channel.close()
|
||||||
|
}, [enabled, state])
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenBottomDisplay() {
|
export function DualScreenBottomDisplay() {
|
||||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||||
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||||
|
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const channel = createChannel()
|
const channel = createChannel()
|
||||||
@@ -374,13 +464,21 @@ export function DualScreenBottomDisplay() {
|
|||||||
if (event.data.type === 'combat-state') {
|
if (event.data.type === 'combat-state') {
|
||||||
setState(event.data.state)
|
setState(event.data.state)
|
||||||
setWorkshopState(null)
|
setWorkshopState(null)
|
||||||
|
setSetupState(null)
|
||||||
}
|
}
|
||||||
if (event.data.type === 'workshop-state') {
|
if (event.data.type === 'workshop-state') {
|
||||||
setWorkshopState(event.data.state)
|
setWorkshopState(event.data.state)
|
||||||
setState(null)
|
setState(null)
|
||||||
|
setSetupState(null)
|
||||||
|
}
|
||||||
|
if (event.data.type === 'setup-state') {
|
||||||
|
setSetupState(event.data.state)
|
||||||
|
setState(null)
|
||||||
|
setWorkshopState(null)
|
||||||
}
|
}
|
||||||
if (event.data.type === 'combat-ended') setState(null)
|
if (event.data.type === 'combat-ended') setState(null)
|
||||||
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||||
|
if (event.data.type === 'setup-ended') setSetupState(null)
|
||||||
}
|
}
|
||||||
announce()
|
announce()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -398,6 +496,14 @@ export function DualScreenBottomDisplay() {
|
|||||||
channel?.close()
|
channel?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGameAction((action) => {
|
||||||
|
if (
|
||||||
|
!shouldRelayBottomAction(action, state)
|
||||||
|
&& !shouldRelaySetupAction(action, setupState)
|
||||||
|
) return
|
||||||
|
sendAction(action)
|
||||||
|
})
|
||||||
|
|
||||||
if (!state && workshopState) {
|
if (!state && workshopState) {
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display workshop-bottom-display">
|
<main className="dual-bottom-display workshop-bottom-display">
|
||||||
@@ -432,6 +538,42 @@ export function DualScreenBottomDisplay() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!state && setupState) {
|
||||||
|
return (
|
||||||
|
<main className="dual-bottom-display setup-bottom-display">
|
||||||
|
<header className="dual-controls-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{setupState.contentType}</p>
|
||||||
|
<h1>{setupState.title}</h1>
|
||||||
|
<small>{setupState.subtitle}</small>
|
||||||
|
</div>
|
||||||
|
<div className="dual-controls-progress">
|
||||||
|
<span>{setupState.difficultyName}</span>
|
||||||
|
<span>iLvl {setupState.itemLevel}</span>
|
||||||
|
<span>{setupState.experience} XP</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section className="setup-bottom-summary">
|
||||||
|
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
|
{setupState.initials}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Selected Run</p>
|
||||||
|
<h2>{setupState.title}</h2>
|
||||||
|
<p>{setupState.description}</p>
|
||||||
|
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<dl className="setup-bottom-stats">
|
||||||
|
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
|
||||||
|
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
|
||||||
|
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
|
||||||
|
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display dual-bottom-waiting">
|
<main className="dual-bottom-display dual-bottom-waiting">
|
||||||
@@ -445,7 +587,10 @@ export function DualScreenBottomDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
<main
|
||||||
|
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
|
||||||
|
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
|
||||||
|
>
|
||||||
<header className="dual-controls-header">
|
<header className="dual-controls-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||||
@@ -477,8 +622,19 @@ export function DualScreenBottomDisplay() {
|
|||||||
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
<span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
|
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
|
||||||
|
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -513,7 +669,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -626,8 +782,35 @@ export function DualScreenTopCombat({
|
|||||||
[state.floatingTexts],
|
[state.floatingTexts],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const spellButtons = state.spells.map((spell, slotIndex) => {
|
||||||
|
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||||
|
const percent = spell.remaining > 0
|
||||||
|
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<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 +821,25 @@ export function DualScreenTopCombat({
|
|||||||
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar enemy-health">
|
<div className="bar enemy-health">
|
||||||
<span style={{ width: `${enemyPercent}%` }} />
|
<span style={barFillStyle(enemyPercent)} />
|
||||||
</div>
|
</div>
|
||||||
<p>{state.encounterDescription}</p>
|
{state.opponentParty && (
|
||||||
|
<div className="dual-top-resource">
|
||||||
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!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 +867,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
LootRoll,
|
LootRoll,
|
||||||
Item,
|
Item,
|
||||||
EquipmentSlot,
|
EquipmentSlot,
|
||||||
|
PetAward,
|
||||||
} from './profile'
|
} from './profile'
|
||||||
|
|
||||||
export type GameMode = 'online' | 'offline'
|
export type GameMode = 'online' | 'offline'
|
||||||
@@ -64,6 +65,14 @@ export interface GameRepository {
|
|||||||
difficultyId: number,
|
difficultyId: number,
|
||||||
runToken: string,
|
runToken: string,
|
||||||
): Promise<LootRoll>
|
): Promise<LootRoll>
|
||||||
|
recordBossKill(
|
||||||
|
encounterId: number,
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): Promise<{
|
||||||
|
profile: CharacterProfile
|
||||||
|
petAwarded: PetAward | null
|
||||||
|
}>
|
||||||
|
recordPvpMatch(won: boolean): Promise<CharacterProfile>
|
||||||
}
|
}
|
||||||
|
|
||||||
type CharacterData = {
|
type CharacterData = {
|
||||||
@@ -76,7 +85,8 @@ type CharacterData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OfflineSave = {
|
type OfflineSave = {
|
||||||
version: 3
|
version: 4
|
||||||
|
updatedAt: number
|
||||||
characterName: string
|
characterName: string
|
||||||
activeClassId: number
|
activeClassId: number
|
||||||
completedDungeonParts: number
|
completedDungeonParts: number
|
||||||
@@ -84,6 +94,10 @@ type OfflineSave = {
|
|||||||
dungeonCompletions?: Record<string, number>
|
dungeonCompletions?: Record<string, number>
|
||||||
characters: Record<number, CharacterData>
|
characters: Record<number, CharacterData>
|
||||||
lootRolls: Record<string, LootRoll>
|
lootRolls: Record<string, LootRoll>
|
||||||
|
bossKills: Record<string, number>
|
||||||
|
bossPets: Record<string, number>
|
||||||
|
pvpMatchesPlayed: number
|
||||||
|
pvpMatchesWon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnlineCache = {
|
type OnlineCache = {
|
||||||
@@ -104,6 +118,22 @@ export type CloudSyncStatus = {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown'
|
||||||
|
|
||||||
|
export type CloudSaveSummary = {
|
||||||
|
updatedAt: number | null
|
||||||
|
characterName: string
|
||||||
|
activeClassId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudSyncComparison = {
|
||||||
|
relation: CloudSaveAge
|
||||||
|
local: CloudSaveSummary
|
||||||
|
server: CloudSaveSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudSyncChoice = 'local' | 'server'
|
||||||
|
|
||||||
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
|
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
|
||||||
|
|
||||||
type NetworkError = Error & {
|
type NetworkError = Error & {
|
||||||
@@ -124,12 +154,37 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
|||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
const ABILITY_SLOT_COUNT = 6
|
const ABILITY_SLOT_COUNT = 6
|
||||||
|
const BOSS_PET_DROP_RATE = 1 / 500
|
||||||
let activeCatalogCache: CatalogCache | null = null
|
let activeCatalogCache: CatalogCache | null = null
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
return structuredClone(value)
|
return structuredClone(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedSaveTimestamp(value: unknown): number {
|
||||||
|
const timestamp = Number(value)
|
||||||
|
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave {
|
||||||
|
save.updatedAt = updatedAt
|
||||||
|
return save
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSummary(save: OfflineSave): CloudSaveSummary {
|
||||||
|
return {
|
||||||
|
updatedAt: save.updatedAt > 0 ? save.updatedAt : null,
|
||||||
|
characterName: save.characterName,
|
||||||
|
activeClassId: save.activeClassId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge {
|
||||||
|
if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown'
|
||||||
|
if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same'
|
||||||
|
return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer'
|
||||||
|
}
|
||||||
|
|
||||||
function toGameMode(mode: RepositoryMode): GameMode {
|
function toGameMode(mode: RepositoryMode): GameMode {
|
||||||
return mode === 'online' ? 'online' : 'offline'
|
return mode === 'online' ? 'online' : 'offline'
|
||||||
}
|
}
|
||||||
@@ -175,7 +230,8 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: 0,
|
||||||
characterName: p.character.name,
|
characterName: p.character.name,
|
||||||
activeClassId: p.character.classId,
|
activeClassId: p.character.classId,
|
||||||
completedDungeonParts: p.completedDungeonParts,
|
completedDungeonParts: p.completedDungeonParts,
|
||||||
@@ -185,14 +241,35 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
),
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: v1.lootRolls ?? {},
|
lootRolls: v1.lootRolls ?? {},
|
||||||
|
bossKills: p.hunterStats?.bossKills ?? {},
|
||||||
|
bossPets: p.hunterStats?.bossPets ?? {},
|
||||||
|
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
|
||||||
return normalizeSaveAbilitySlots({
|
return normalizeSaveAbilitySlots({
|
||||||
...v2,
|
...v2,
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
|
||||||
|
return normalizeSaveAbilitySlots({
|
||||||
|
...v3,
|
||||||
|
version: 4,
|
||||||
|
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||||
|
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
|
||||||
for (const character of Object.values(save.characters)) {
|
for (const character of Object.values(save.characters)) {
|
||||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||||
}
|
}
|
||||||
@@ -224,9 +302,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
|||||||
profile?: CharacterProfile
|
profile?: CharacterProfile
|
||||||
lootRolls?: Record<string, LootRoll>
|
lootRolls?: Record<string, LootRoll>
|
||||||
}
|
}
|
||||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||||
|
if (candidate.version === 3) {
|
||||||
|
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
|
||||||
|
}
|
||||||
if (candidate.version === 2) {
|
if (candidate.version === 2) {
|
||||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
|
||||||
}
|
}
|
||||||
if (candidate.version === 1 && candidate.profile) {
|
if (candidate.version === 1 && candidate.profile) {
|
||||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||||
@@ -241,7 +322,7 @@ function readSaveKey(key: string): OfflineSave | null {
|
|||||||
const raw = JSON.parse(serialized)
|
const raw = JSON.parse(serialized)
|
||||||
const save = normalizeOfflineSave(raw)
|
const save = normalizeOfflineSave(raw)
|
||||||
if (!save) return null
|
if (!save) return null
|
||||||
if (raw.version !== 3) {
|
if (raw.version !== 4) {
|
||||||
localStorage.setItem(key, JSON.stringify(save))
|
localStorage.setItem(key, JSON.stringify(save))
|
||||||
}
|
}
|
||||||
return save
|
return save
|
||||||
@@ -284,7 +365,7 @@ function readOnlineCache(): OnlineCache | null {
|
|||||||
save,
|
save,
|
||||||
dirty: Boolean(raw.dirty),
|
dirty: Boolean(raw.dirty),
|
||||||
}
|
}
|
||||||
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
|
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
|
||||||
writeOnlineCache(cache)
|
writeOnlineCache(cache)
|
||||||
}
|
}
|
||||||
return cache
|
return cache
|
||||||
@@ -380,6 +461,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
|
|||||||
...dungeon,
|
...dungeon,
|
||||||
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
||||||
}))
|
}))
|
||||||
|
static_.hunterStats = {
|
||||||
|
bossKills: clone(save.bossKills ?? {}),
|
||||||
|
bossPets: clone(save.bossPets ?? {}),
|
||||||
|
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: save.pvpMatchesWon ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
return static_
|
return static_
|
||||||
}
|
}
|
||||||
@@ -547,7 +634,8 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
inventory: clone(profile.inventory),
|
inventory: clone(profile.inventory),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: existingSave?.updatedAt ?? Date.now(),
|
||||||
characterName: profile.character.name,
|
characterName: profile.character.name,
|
||||||
activeClassId: profile.character.classId,
|
activeClassId: profile.character.classId,
|
||||||
completedDungeonParts: profile.completedDungeonParts,
|
completedDungeonParts: profile.completedDungeonParts,
|
||||||
@@ -557,6 +645,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
),
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
||||||
|
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
|
||||||
|
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
|
||||||
|
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +662,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
|
|||||||
return entries[entries.length - 1]
|
return entries[entries.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bossPetName(encounterName: string) {
|
||||||
|
return `${encounterName} Pet`
|
||||||
|
}
|
||||||
|
|
||||||
|
function purpleBossPetName(encounterName: string) {
|
||||||
|
return `Purple ${encounterName} Pet`
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeAwardBossPet(
|
||||||
|
save: OfflineSave,
|
||||||
|
encounter: { id: number; enemyName: string },
|
||||||
|
variant: 'normal' | 'purple' = 'normal',
|
||||||
|
): PetAward | null {
|
||||||
|
if (Math.random() >= BOSS_PET_DROP_RATE) return null
|
||||||
|
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
|
||||||
|
const previousQuantity = save.bossPets?.[key] ?? 0
|
||||||
|
save.bossPets = {
|
||||||
|
...(save.bossPets ?? {}),
|
||||||
|
[key]: previousQuantity + 1,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
encounterId: encounter.id,
|
||||||
|
petName: variant === 'purple'
|
||||||
|
? purpleBossPetName(encounter.enemyName)
|
||||||
|
: bossPetName(encounter.enemyName),
|
||||||
|
quantity: 1,
|
||||||
|
duplicate: previousQuantity > 0,
|
||||||
|
quantityAfter: previousQuantity + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordBossKillInSave(
|
||||||
|
save: OfflineSave,
|
||||||
|
encounter: { id: number; enemyName: string },
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): PetAward | null {
|
||||||
|
const key = String(encounter.id)
|
||||||
|
save.bossKills = {
|
||||||
|
...(save.bossKills ?? {}),
|
||||||
|
[key]: (save.bossKills?.[key] ?? 0) + 1,
|
||||||
|
}
|
||||||
|
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
|
||||||
|
}
|
||||||
|
|
||||||
function awardRoguelikeCoin(
|
function awardRoguelikeCoin(
|
||||||
profile: CharacterProfile,
|
profile: CharacterProfile,
|
||||||
sourceEncounterId: number | undefined,
|
sourceEncounterId: number | undefined,
|
||||||
@@ -885,6 +1021,10 @@ const serverRepository: GameRepository = {
|
|||||||
cachedOnlineLocalRepository.upgradeItem(itemId),
|
cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||||
|
recordBossKill: (encounterId, options) =>
|
||||||
|
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||||
|
recordPvpMatch: (won) =>
|
||||||
|
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCharacterData(classId: number): CharacterData {
|
function emptyCharacterData(classId: number): CharacterData {
|
||||||
@@ -914,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
|
|||||||
return save
|
return save
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
|
||||||
|
store.writeSave(stampSave(save))
|
||||||
|
}
|
||||||
|
|
||||||
function createLocalRepository(store: LocalSaveStore): GameRepository {
|
function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||||
return {
|
return {
|
||||||
async loadSession() {
|
async loadSession() {
|
||||||
@@ -961,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
save.characters[classId].abilitySlots = slots
|
save.characters[classId].abilitySlots = slots
|
||||||
save.activeClassId = classId
|
save.activeClassId = classId
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
||||||
@@ -1067,7 +1211,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
const updatedProfile = buildProfile(save)
|
const updatedProfile = buildProfile(save)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1172,6 +1316,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
profile.maxTalentPoints,
|
profile.maxTalentPoints,
|
||||||
cd.talentPoints + levelsGained,
|
cd.talentPoints + levelsGained,
|
||||||
)
|
)
|
||||||
|
let petAwarded: PetAward | null = null
|
||||||
|
if (options?.lootSourceEncounterId) {
|
||||||
|
const sourceEncounter = profile.dungeons
|
||||||
|
.flatMap((candidate) => candidate.encounters)
|
||||||
|
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
|
||||||
|
if (sourceEncounter) {
|
||||||
|
petAwarded = recordBossKillInSave(save, sourceEncounter, {
|
||||||
|
petVariant: 'purple',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
const bonusItem = awardRoguelikeCoin(
|
const bonusItem = awardRoguelikeCoin(
|
||||||
profile,
|
profile,
|
||||||
options?.lootSourceEncounterId,
|
options?.lootSourceEncounterId,
|
||||||
@@ -1179,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
)
|
)
|
||||||
cd.inventory = profile.inventory
|
cd.inventory = profile.inventory
|
||||||
|
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
const updatedProfile = buildProfile(save)
|
const updatedProfile = buildProfile(save)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1196,6 +1351,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||||
unlockedAbilities,
|
unlockedAbilities,
|
||||||
bonusItem,
|
bonusItem,
|
||||||
|
petAwarded,
|
||||||
profile: updatedProfile,
|
profile: updatedProfile,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1233,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
cd.talentRanks[String(talentId)] = 1
|
cd.talentRanks[String(talentId)] = 1
|
||||||
}
|
}
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
}
|
}
|
||||||
if (cd.talentPoints <= 0) {
|
if (cd.talentPoints <= 0) {
|
||||||
@@ -1261,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
||||||
cd.talentPoints -= 1
|
cd.talentPoints -= 1
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async resetTalents() {
|
async resetTalents() {
|
||||||
@@ -1284,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
cd.talentPoints + refunded,
|
cd.talentPoints + refunded,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async equipItem(itemId) {
|
async equipItem(itemId) {
|
||||||
@@ -1296,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
||||||
}
|
}
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async discardExtraItem(itemId) {
|
async discardExtraItem(itemId) {
|
||||||
@@ -1307,7 +1463,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
|
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
|
||||||
item.quantity -= 1
|
item.quantity -= 1
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async breakdownItem(itemId) {
|
async breakdownItem(itemId) {
|
||||||
@@ -1350,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async craftItem(recipeId) {
|
async craftItem(recipeId) {
|
||||||
@@ -1378,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
|
|
||||||
addInventoryItem(profile.inventory, recipe.item, 1)
|
addInventoryItem(profile.inventory, recipe.item, 1)
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async upgradeItem(itemId) {
|
async upgradeItem(itemId) {
|
||||||
@@ -1421,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (upgraded) upgraded.equipped = true
|
if (upgraded) upgraded.equipped = true
|
||||||
}
|
}
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
||||||
@@ -1446,6 +1602,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('This encounter has no configured loot.')
|
throw new Error('This encounter has no configured loot.')
|
||||||
}
|
}
|
||||||
const dropChance = entries[0].dropChance
|
const dropChance = entries[0].dropChance
|
||||||
|
const petAwarded = encounter.isBoss
|
||||||
|
? recordBossKillInSave(save, encounter)
|
||||||
|
: null
|
||||||
const items: LootRoll['items'] = []
|
const items: LootRoll['items'] = []
|
||||||
|
|
||||||
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
||||||
@@ -1497,12 +1656,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
awarded: Boolean(item),
|
awarded: Boolean(item),
|
||||||
duplicate: items.some((candidate) => candidate.duplicate),
|
duplicate: items.some((candidate) => candidate.duplicate),
|
||||||
quantityAfter: item?.quantityAfter ?? 0,
|
quantityAfter: item?.quantityAfter ?? 0,
|
||||||
|
petAwarded,
|
||||||
}
|
}
|
||||||
save.lootRolls[rollKey] = result
|
save.lootRolls[rollKey] = result
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return clone(result)
|
return clone(result)
|
||||||
},
|
},
|
||||||
|
async recordBossKill(encounterId, options) {
|
||||||
|
const save = requireStoredSave(store)
|
||||||
|
const profile = buildProfile(save)
|
||||||
|
const encounter = profile.dungeons
|
||||||
|
.flatMap((dungeon) => dungeon.encounters)
|
||||||
|
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
|
||||||
|
if (!encounter) throw new Error('Boss encounter not found.')
|
||||||
|
const petAwarded = recordBossKillInSave(save, encounter, {
|
||||||
|
petVariant: options?.petVariant ?? 'normal',
|
||||||
|
})
|
||||||
|
writeStoreSave(store, save)
|
||||||
|
return {
|
||||||
|
profile: buildProfile(save),
|
||||||
|
petAwarded,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async recordPvpMatch(won) {
|
||||||
|
const save = requireStoredSave(store)
|
||||||
|
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
||||||
|
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
||||||
|
writeStoreSave(store, save)
|
||||||
|
return buildProfile(save)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,6 +1764,10 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
|
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||||
|
recordBossKill: (encounterId, options) =>
|
||||||
|
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||||
|
recordPvpMatch: (won) =>
|
||||||
|
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGameMode(): GameMode {
|
export function getGameMode(): GameMode {
|
||||||
@@ -1613,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
return buildProfile(synced.save)
|
return buildProfile(synced.save)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
|
||||||
|
const cache = readOnlineCache()
|
||||||
|
if (!cache) {
|
||||||
|
throw new Error('No signed-in save is available for cloud sync.')
|
||||||
|
}
|
||||||
|
await refreshCatalogFromServer()
|
||||||
|
const serverSave = await loadServerSyncSave()
|
||||||
|
return {
|
||||||
|
relation: compareSaveAge(cache.save, serverSave),
|
||||||
|
local: saveSummary(cache.save),
|
||||||
|
server: saveSummary(serverSave),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise<CharacterProfile> {
|
||||||
|
const cache = readOnlineCache()
|
||||||
|
if (!cache) {
|
||||||
|
throw new Error('No signed-in save is available for cloud sync.')
|
||||||
|
}
|
||||||
|
await refreshCatalogFromServer()
|
||||||
|
if (choice === 'server') {
|
||||||
|
const serverSave = await loadServerSyncSave()
|
||||||
|
writeOnlineCache({
|
||||||
|
version: 1,
|
||||||
|
account: cache.account,
|
||||||
|
save: serverSave,
|
||||||
|
dirty: false,
|
||||||
|
})
|
||||||
|
writeMode('online')
|
||||||
|
return buildProfile(serverSave)
|
||||||
|
}
|
||||||
|
const synced = await pushServerSyncSave(cache.save)
|
||||||
|
await refreshCatalogFromServer()
|
||||||
|
writeOnlineCache({
|
||||||
|
version: 1,
|
||||||
|
account: cache.account,
|
||||||
|
save: synced.save,
|
||||||
|
dirty: false,
|
||||||
|
})
|
||||||
|
writeMode('online')
|
||||||
|
return buildProfile(synced.save)
|
||||||
|
}
|
||||||
|
|
||||||
export function selectOnlineMode() {
|
export function selectOnlineMode() {
|
||||||
writeMode('online')
|
writeMode('online')
|
||||||
}
|
}
|
||||||
@@ -1627,13 +1857,18 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
|||||||
characters[cid] = emptyCharacterData(cid)
|
characters[cid] = emptyCharacterData(cid)
|
||||||
}
|
}
|
||||||
const save: OfflineSave = {
|
const save: OfflineSave = {
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: Date.now(),
|
||||||
characterName: name,
|
characterName: name,
|
||||||
activeClassId: 1,
|
activeClassId: 1,
|
||||||
completedDungeonParts: 0,
|
completedDungeonParts: 0,
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
characters,
|
characters,
|
||||||
lootRolls: {},
|
lootRolls: {},
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
}
|
}
|
||||||
writeOfflineSave(save)
|
writeOfflineSave(save)
|
||||||
writeMode('offline-local')
|
writeMode('offline-local')
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,34 @@ function currentFocusableControl(candidates = focusableElements()) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
|
||||||
|
if (endA < startB) return startB - endA
|
||||||
|
if (endB < startA) return startA - endB
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
|
||||||
|
const options = Array.from(select.options).filter((option) => !option.disabled)
|
||||||
|
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
|
||||||
|
const nextOption = options[currentIndex + direction]
|
||||||
|
if (!nextOption) return false
|
||||||
|
select.selectedIndex = nextOption.index
|
||||||
|
select.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function focusFirstControl() {
|
export function focusFirstControl() {
|
||||||
|
if (hasDedicatedGameNavigation()) return null
|
||||||
const first = focusableElements()[0]
|
const first = focusableElements()[0]
|
||||||
if (first) focusControl(first)
|
if (first) focusControl(first)
|
||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveFocus(action: InputAction) {
|
function moveFocus(action: InputAction, preferControllerFocus = false) {
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
const current = currentFocusableControl(candidates)
|
const current = currentFocusableControl(candidates, preferControllerFocus)
|
||||||
if (!current) {
|
if (!current) {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
return
|
return
|
||||||
@@ -286,21 +369,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 +423,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 +550,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 +558,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 +605,58 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
const uiOverlay = hasUiOverlay()
|
const uiOverlay = hasUiOverlay()
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
|
const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
|
||||||
|
const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
|
||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
if (action.startsWith('navigate')) {
|
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||||
if (uiOverlay || !combatActive) moveFocus(action)
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
|
dispatchGameAction(action, device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'toggleTouchLock') {
|
||||||
|
setPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
combatTouchLocked: !current.combatTouchLocked,
|
||||||
|
}))
|
||||||
|
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||||
|
const nextLocked = !preferencesRef.current.combatTouchLocked
|
||||||
|
setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
|
||||||
|
touchLockMessageTimerRef.current = window.setTimeout(() => {
|
||||||
|
setTouchLockMessage('')
|
||||||
|
}, 1400)
|
||||||
|
} else if (action.startsWith('navigate')) {
|
||||||
|
if (uiOverlay || !combatActive) {
|
||||||
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
|
if (
|
||||||
|
active instanceof HTMLSelectElement
|
||||||
|
&& (action === 'navigateUp' || action === 'navigateDown')
|
||||||
|
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
||||||
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveFocus(action, controllerUiInput)
|
||||||
|
}
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
if (isTextInput(active)) {
|
if (isTextInput(active)) {
|
||||||
setKeyboardInput(active)
|
setKeyboardInput(active)
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
|
} else if (active instanceof HTMLSelectElement) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
|
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
||||||
|
if (select.showPicker) select.showPicker()
|
||||||
|
else active.click()
|
||||||
} else if (
|
} else if (
|
||||||
active
|
active
|
||||||
&& active.matches('button:not(:disabled), [role="button"]')
|
&& active.matches('button:not(:disabled), [role="button"]')
|
||||||
&& isVisible(active)
|
&& isVisible(active)
|
||||||
) {
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
active.click()
|
active.click()
|
||||||
} else {
|
} else {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
@@ -512,9 +672,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 +686,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 +703,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 +722,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 +772,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,10 +802,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||||
}, [dispatchControllerToken])
|
}, [dispatchControllerToken])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let focusFrame = 0
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
|
focusFrame = 0
|
||||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||||
if (combatActive) return
|
if (combatActive) return
|
||||||
|
if (hasDedicatedGameNavigation()) return
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
const activeControl = currentFocusableControl(candidates)
|
const activeControl = currentFocusableControl(candidates)
|
||||||
if (
|
if (
|
||||||
@@ -663,22 +827,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 +884,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 +914,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 +954,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 +969,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 +1037,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 +1073,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 },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||