Compare commits

...
6 Commits
Author SHA1 Message Date
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
40 changed files with 2560 additions and 386 deletions
+27 -1
View File
@@ -1,9 +1,35 @@
# Project Notes # Project Notes
## Target Hardware: AYN Thor
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. - AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels. - AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540. - Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport. - Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested.
## Input Requirements
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
## Architecture Requirements
- Keep systems modular and scoped by responsibility.
- Prefer small reusable modules/components over large files with mixed concerns.
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
- Prefer event-driven updates and cached calculations for UI and game systems.
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
## Build Requirements
- User rebuilds app; do not rebuild APK unless explicitly requested.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 82 versionCode 88
versionName "1.1.3" versionName "1.1.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -0,0 +1,51 @@
package com.warren.iwanttoheal;
import android.view.Display;
import android.view.Window;
import android.view.WindowManager;
final class AndroidDisplayPerformance {
private static final float TARGET_REFRESH_RATE = 60.0f;
private AndroidDisplayPerformance() {}
static void preferBatteryRefreshRate(Window window, Display display) {
if (window == null || display == null) return;
Display.Mode currentMode = display.getMode();
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
if (selectedMode == null) return;
WindowManager.LayoutParams attributes = window.getAttributes();
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
attributes.preferredDisplayModeId = selectedMode.getModeId();
window.setAttributes(attributes);
}
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
Display.Mode selectedMode = null;
float selectedScore = Float.MAX_VALUE;
for (Display.Mode mode : display.getSupportedModes()) {
if (
currentMode != null
&& (
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
)
) {
continue;
}
float refreshRate = mode.getRefreshRate();
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
if (score < selectedScore) {
selectedMode = mode;
selectedScore = score;
}
}
return selectedMode;
}
}
@@ -1,26 +1,45 @@
package com.warren.iwanttoheal; package com.warren.iwanttoheal;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File; import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public abstract class ControllerBridgeActivity extends BridgeActivity { public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 220; private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private long lastDpadDispatchAt = 0; private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long DPAD_THROTTLE_MS = 55;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers(); boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) { if (bridge != null) {
if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true); bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
} }
loadIntentUrl(); loadIntentUrl();
} }
@@ -35,7 +54,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override @Override
public void onWindowFocusChanged(boolean hasFocus) { public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus); super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode(); if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
} }
@Override @Override
@@ -74,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
deleteIfExists(new File(webViewData, "Service Worker")); deleteIfExists(new File(webViewData, "Service Worker"));
} }
private boolean shouldClearWebViewCacheForCurrentVersion() {
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
!= currentVersionCode();
}
private void markWebViewCacheClearedForCurrentVersion() {
performancePreferences()
.edit()
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
.apply();
}
private SharedPreferences performancePreferences() {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
}
private long currentVersionCode() {
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return packageInfo.getLongVersionCode();
}
return packageInfo.versionCode;
} catch (PackageManager.NameNotFoundException exception) {
return -1L;
}
}
private void deleteIfExists(File file) { private void deleteIfExists(File file) {
if (!file.exists()) return; if (!file.exists()) return;
if (file.isDirectory()) { if (file.isDirectory()) {
@@ -94,7 +147,88 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true; if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
"Button14",
"Button15"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
"Button12",
"Button13"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_X),
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y),
"Axis1-",
"Axis1+"
);
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
long now = SystemClock.uptimeMillis();
for (String token : currentTokens) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
if (currentTokens.isEmpty() && !hadMotionTokens) {
return super.dispatchGenericMotionEvent(event);
}
return true;
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(
Set<String> tokens,
float value,
String negativeToken,
String positiveToken
) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script = String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -105,13 +239,12 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
} }
); );
} }
return true;
}
private boolean shouldThrottleDpad() { private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now; lastDpadDispatchAt[buttonIndex] = now;
return false; return false;
} }
@@ -125,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
private String controllerToken(int keyCode) { private String controllerToken(int keyCode) {
switch (keyCode) { switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A: case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0"; return "Button0";
case KeyEvent.KEYCODE_BUTTON_B: case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1"; return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: case KeyEvent.KEYCODE_BUTTON_X:
return "Button2"; return "Button2";
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation { private final class TopDisplayPresentation extends Presentation {
private final String initialUrl; private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) { TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display); super(context, display);
this.display = display;
this.initialUrl = initialUrl; this.initialUrl = initialUrl;
} }
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
); );
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext()); FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK); container.setBackgroundColor(Color.BLACK);
+14
View File
@@ -254,6 +254,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
PRIMARY KEY (character_id, item_id) PRIMARY KEY (character_id, item_id)
); );
CREATE TABLE IF NOT EXISTS character_boss_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
PRIMARY KEY (character_id, encounter_id)
);
CREATE TABLE IF NOT EXISTS character_pvp_stats (
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
);
CREATE TABLE IF NOT EXISTS action_characters ( CREATE TABLE IF NOT EXISTS action_characters (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
+64
View File
@@ -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",
+1
View File
@@ -34,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",
+6
View File
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
})), })),
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe), craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
dungeons: profile.dungeons.map(normalizeDungeon), dungeons: profile.dungeons.map(normalizeDungeon),
hunterStats: {
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
},
} }
} }
+77 -2
View File
@@ -815,6 +815,21 @@ export function getProfile(database, characterId, accountId) {
AND completed_parts >= 1 AND completed_parts >= 1
GROUP BY dungeon_id GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count])) `).all(characterId).map((row) => [row.dungeonId, row.count]))
const bossStatRows = database.prepare(`
SELECT
encounter_id AS encounterId,
kills,
pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(characterId)
const pvpStats = database.prepare(`
SELECT
matches_played AS matchesPlayed,
matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(characterId)
const settings = Object.fromEntries( const settings = Object.fromEntries(
database.prepare('SELECT key, value FROM game_settings').all() database.prepare('SELECT key, value FROM game_settings').all()
@@ -930,6 +945,12 @@ export function getProfile(database, characterId, accountId) {
.map((run) => ({ ...run, rank: Number(run.rank) })), .map((run) => ({ ...run, rank: Number(run.rank) })),
}, },
})), })),
hunterStats: {
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
},
} }
} }
@@ -1025,8 +1046,18 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
?? characters[0]?.name ?? characters[0]?.name
?? 'Mira' ?? 'Mira'
const bossStats = database.prepare(`
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(activeCharacterId)
const pvpStats = database.prepare(`
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(activeCharacterId)
return { return {
version: 3, version: 4,
characterName, characterName,
activeClassId, activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0, completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1038,6 +1069,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
]), ]),
), ),
lootRolls: {}, lootRolls: {},
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
} }
} }
@@ -1052,7 +1087,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
if ( if (
!save !save
|| typeof save !== 'object' || typeof save !== 'object'
|| Number(save.version) !== 3 || (Number(save.version) !== 4 && Number(save.version) !== 3)
|| typeof save.characterName !== 'string' || typeof save.characterName !== 'string'
|| !save.characters || !save.characters
|| typeof save.characters !== 'object' || typeof save.characters !== 'object'
@@ -1149,6 +1184,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`) `)
const insertBossStats = database.prepare(`
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) { for (const classId of classIds) {
const local = save.characters[classId] const local = save.characters[classId]
@@ -1213,6 +1252,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
DELETE FROM character_inventory DELETE FROM character_inventory
WHERE character_id = ? WHERE character_id = ?
`).run(characterId) `).run(characterId)
database.prepare(`
DELETE FROM character_boss_stats
WHERE character_id = ?
`).run(characterId)
database.prepare(`
DELETE FROM character_pvp_stats
WHERE character_id = ?
`).run(characterId)
const inventoryByItemId = new Map() const inventoryByItemId = new Map()
const equippedSlots = new Set() const equippedSlots = new Set()
for (const item of Array.isArray(local.inventory) ? local.inventory : []) { for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
@@ -1235,6 +1282,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
for (const [itemId, itemState] of inventoryByItemId) { for (const [itemId, itemState] of inventoryByItemId) {
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0) insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
} }
if (classId === Number(save.activeClassId)) {
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(
characterId,
id,
clampInteger(rawKills, 0, 0, 1000000),
clampInteger(bossPets[encounterId], 0, 0, 1000000),
)
}
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
if (Object.hasOwn(bossKills, encounterId)) continue
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
}
database.prepare(`
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
VALUES (?, ?, ?)
`).run(
characterId,
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
)
}
} }
let syncedClassId = clampInteger( let syncedClassId = clampInteger(
+968 -145
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -20,10 +20,12 @@ import {
type GameMode, type GameMode,
} from './gameRepository' } from './gameRepository'
import { focusFirstControl } from './input.tsx' import { focusFirstControl } from './input.tsx'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen }))) const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen })))
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen }))) const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen }))) const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen }))) const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
@@ -38,6 +40,7 @@ type Screen =
| 'pvp' | 'pvp'
| 'customize' | 'customize'
| 'equipment' | 'equipment'
| 'hunter-profile'
| 'talents' | 'talents'
| 'settings' | 'settings'
@@ -51,6 +54,7 @@ const MENU_ITEMS: Array<{
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' }, { screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' }, { screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' }, { screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' }, { screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' }, { screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
] ]
@@ -455,7 +459,8 @@ function App() {
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
return ( return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}> <main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
{screen !== 'hunter-profile' && (
<header className="topbar app-header"> <header className="topbar app-header">
<button <button
className="brand-button" className="brand-button"
@@ -469,13 +474,14 @@ function App() {
<small>Level {profile.character.level}</small> <small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small> <small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}> <div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} /> <span style={barFillStyle(experiencePercent)} />
</div> </div>
<button className="logout-button" onClick={signOut} type="button"> <button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`} {gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
</button> </button>
</div> </div>
</header> </header>
)}
{screen === 'menu' && ( {screen === 'menu' && (
<section className="menu-screen"> <section className="menu-screen">
@@ -1090,6 +1096,13 @@ function App() {
/> />
)} )}
{screen === 'hunter-profile' && (
<HunterProfileScreen
profile={profile}
onBack={() => setScreen('menu')}
/>
)}
{screen === 'settings' && ( {screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} /> <SettingsScreen onBack={() => setScreen('menu')} />
)} )}
+3
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css' import './App.css'
import { AdminScreen } from './components/AdminScreen' import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<InputProvider>
<AdminScreen onBack={() => window.close()} /> <AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>, </StrictMode>,
) )
+60 -2
View File
@@ -1,21 +1,79 @@
import type { Spell } from '../game' import type { Spell } from '../game'
export function cooldownNow() {
return Date.now()
}
export function cooldownRemaining(
cooldowns: Record<string, number>,
spellId: string,
now = cooldownNow(),
) {
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
}
export function hasActiveCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
}
export function pruneExpiredCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
let changed = false
const nextCooldowns: Record<string, number> = {}
for (const id in cooldowns) {
if (cooldowns[id] <= now) {
changed = true
continue
}
nextCooldowns[id] = cooldowns[id]
}
return changed ? nextCooldowns : cooldowns
}
export function reduceCooldown(
cooldowns: Record<string, number>,
spellId: string,
seconds: number,
now = cooldownNow(),
) {
const readyAtMs = cooldowns[spellId]
if (!readyAtMs || readyAtMs <= now) return cooldowns
const nextReadyAtMs = readyAtMs - seconds * 1000
const nextCooldowns = { ...cooldowns }
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
else nextCooldowns[spellId] = nextReadyAtMs
return nextCooldowns
}
export function canCastSpell( export function canCastSpell(
spell: Spell, spell: Spell,
resource: number, resource: number,
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
resourceCost: number, resourceCost: number,
now = cooldownNow(),
) { ) {
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0 return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
} }
export function putSpellOnCooldown( export function putSpellOnCooldown(
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
spell: Spell, spell: Spell,
cooldownMultiplier = 1, cooldownMultiplier = 1,
now = cooldownNow(),
) { ) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return { return {
...cooldowns, ...cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier, [spell.id]: now + cooldownMs,
} }
} }
+60 -20
View File
@@ -3,6 +3,7 @@ import {
completeDungeon, completeDungeon,
completeRoguelike, completeRoguelike,
loadProfile, loadProfile,
recordBossKill,
type DungeonReward, type DungeonReward,
rollEncounterLoot, rollEncounterLoot,
type LootRoll, type LootRoll,
@@ -46,7 +47,7 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine' import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions' import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads' import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
@@ -58,7 +59,11 @@ import {
appendCombatLog, appendCombatLog,
type BasicFloatingCombatText, type BasicFloatingCombatText,
} from '../combat/combatPresentation' } from '../combat/combatPresentation'
import { canCastSpell } from '../combat/spellCasting' import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
applySpellEffectProfile, applySpellEffectProfile,
buildSpellTargetPlan, buildSpellTargetPlan,
@@ -69,9 +74,11 @@ import {
useInput, useInput,
} from '../input' } from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText' import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars' import { ResourceBar, SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels' import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen' import { ResultScreen } from './ResultScreen'
import { import {
@@ -365,6 +372,7 @@ export function CombatScreen({
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
@@ -510,6 +518,12 @@ export function CombatScreen({
: `${result.encounterName} dropped no components.`, : `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system', result.dropped ? 'loot' : 'system',
) )
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
addLog( addLog(
@@ -521,6 +535,29 @@ export function CombatScreen({
[addLog, difficulty.id], [addLog, difficulty.id],
) )
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
if (!isRoguelike || !encounter.isBoss) return
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
if (recordedBossKillIdsRef.current.has(key)) return
recordedBossKillIdsRef.current.add(key)
recordBossKill(encounter.id, { petVariant: 'purple' })
.then((result) => {
onProfileUpdated(result.profile)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
'danger',
)
})
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
const resetRun = useCallback(() => { const resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode const nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
@@ -552,6 +589,7 @@ export function CombatScreen({
rewardClaimedRef.current = false rewardClaimedRef.current = false
profileRefreshedRef.current = false profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set() rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID() runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0 marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent resourceSpentRef.current = setup.defaults.resourceSpent
@@ -668,10 +706,10 @@ export function CombatScreen({
}) })
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value)) floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost resourceSpentRef.current += effectiveCost
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) { if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spellByName.get('Radiance') const radiance = spellByName.get('Radiance')
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2) if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
} }
setCombat(applyCastStateUpdate({ setCombat(applyCastStateUpdate({
current, current,
@@ -855,7 +893,7 @@ export function CombatScreen({
const runCombatTick = useCallback(() => { const runCombatTick = useCallback(() => {
const current = combatRef.current const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1 const nextElapsedTicks = current.elapsedTicks + 1
const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS)) const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
let nextResource = regenerateResource(current.resource, 2.4, maxResource) let nextResource = regenerateResource(current.resource, 2.4, maxResource)
const living = current.party.filter((member) => member.health > 0) const living = current.party.filter((member) => member.health > 0)
@@ -985,6 +1023,7 @@ export function CombatScreen({
requestLootRoll(encounter.id, rollIndex) requestLootRoll(encounter.id, rollIndex)
} }
} }
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({ setCombat({
@@ -1094,6 +1133,7 @@ export function CombatScreen({
maxResource, maxResource,
gameClass.resourceName, gameClass.resourceName,
requestLootRoll, requestLootRoll,
recordRoguelikeBossKill,
profile.character.name, profile.character.name,
setCombat, setCombat,
startPart, startPart,
@@ -1161,17 +1201,17 @@ export function CombatScreen({
}).reverse(), }).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth], [enemyCount, enemyHealth, encounter.maxHealth],
) )
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => { const spellSlotCost = useCallback(
const spell = spellByKey.get(String(slotIndex + 1)) (spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
return abilityId && spell [freeCastReady, roguelikeUpgradeCounts],
? { )
...spell, const spellSlots = useSpellSlots({
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady), spells,
slotIndex, abilitySlots: profile.abilitySlots,
remaining: cooldowns[spell.id] ?? 0, cooldowns,
} active: status === 'playing' && !paused,
: null cost: spellSlotCost,
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey]) })
const dualScreenState = useMemo(() => buildCombatDualScreenState({ const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: dungeon.name,
@@ -1232,7 +1272,7 @@ export function CombatScreen({
return ( return (
<main <main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`} className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'} data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
@@ -1266,13 +1306,13 @@ export function CombatScreen({
<div className="hard-enemy-bars"> <div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => ( {enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}> <div className="bar enemy-health" key={segment.index}>
<span style={{ width: `${segment.percent}%` }} /> <span style={barFillStyle(segment.percent)} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em> <em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div> <div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
)} )}
<p>{encounter.description}</p> <p>{encounter.description}</p>
</div> </div>
+240
View File
@@ -0,0 +1,240 @@
import { useMemo, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
return (
<section className="content-screen hunter-profile-screen">
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
</article>
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
key={boss.encounter.id}
onClick={() => setSelectedBossId(boss.encounter.id)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</strong>
</button>
)
})}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
+3 -2
View File
@@ -7,6 +7,7 @@ import {
memberHotEffects, memberHotEffects,
} from '../combat/rules' } from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = { export type FloatingCombatText = {
id: number id: number
@@ -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">
+54 -16
View File
@@ -9,13 +9,15 @@ import {
type PartyMember, type PartyMember,
type Spell, type Spell,
} from '../game' } from '../game'
import { completeRoguelike, type DungeonReward } from '../profile' import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile' import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { SpellBar, type SpellSlot } from './SpellBars' import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput } from '../input' import { focusFirstControl, useGameAction, useInput } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { import {
@@ -37,10 +39,14 @@ import {
spellExtraTargets, spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads' import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import { import {
nextPvpRoguelikeStage, nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome, resolvePvpRoguelikeCombatOutcome,
@@ -356,6 +362,7 @@ export function PvPRoguelikeScreen({
const nextLogId = useRef(2) const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0) const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false) const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false) const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>()) const bossRewardClaimedRef = useRef(new Set<number>())
@@ -398,12 +405,16 @@ export function PvPRoguelikeScreen({
const cpuAlive = cpuSide.party.some((member) => member.health > 0) const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs]) const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none', () => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog], [cpuSide.buffs, selfBuffChoicesCatalog],
@@ -528,6 +539,12 @@ export function PvPRoguelikeScreen({
'loot', 'loot',
) )
} }
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
setRewardError( setRewardError(
@@ -644,6 +661,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
recordedRunRef.current = false recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -701,6 +719,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false loggedOpponentDoneRef.current = false
recordedRunRef.current = false recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
@@ -880,9 +899,9 @@ export function PvPRoguelikeScreen({
damageReductionTargets: new Set<string>(), damageReductionTargets: new Set<string>(),
groupTargets, groupTargets,
} }
const nextCooldowns = { ...current.cooldowns } let nextCooldowns = current.cooldowns
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) { if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2) nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
} }
return applyPvpSpellCast({ return applyPvpSpellCast({
current, current,
@@ -994,7 +1013,7 @@ export function PvPRoguelikeScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, 2.4, maxResource), resource: regenerateResource(side.resource, 2.4, maxResource),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), cooldowns: pruneExpiredCooldowns(side.cooldowns),
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
} }
}, [activeSpellEffects, addFloatingHeal, maxResource]) }, [activeSpellEffects, addFloatingHeal, maxResource])
@@ -1086,6 +1105,20 @@ export function PvPRoguelikeScreen({
}) })
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status]) }, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
useEffect(() => { useEffect(() => {
if (status !== 'upgrade-choice') return if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
@@ -1339,6 +1372,9 @@ export function PvPRoguelikeScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: cpuSide.enemyHealth, opponentEnemyHealth: cpuSide.enemyHealth,
opponentResource: cpuSide.resource,
opponentMaxResource: maxResource,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1362,6 +1398,7 @@ export function PvPRoguelikeScreen({
cpuDifficulty, cpuDifficulty,
cpuSide.enemyHealth, cpuSide.enemyHealth,
cpuSide.party, cpuSide.party,
cpuSide.resource,
directPartyTargeting, directPartyTargeting,
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
@@ -1424,13 +1461,13 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span> <span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} /> <span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
</div> </div>
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1464,12 +1501,12 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap"> <div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span> <span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} /> <span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
</div> </div>
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1495,6 +1532,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive} canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+48 -17
View File
@@ -7,13 +7,15 @@ import {
type PartyMember, type PartyMember,
type Spell, type Spell,
} from '../game' } from '../game'
import { completeRoguelike } from '../profile' import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile' import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames' import { PartyMemberFrame } from './PartyFrames'
import { SpellBar, type SpellSlot } from './SpellBars' import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input' import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer' import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText' import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting' import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { useDualScreen, useDualScreenPublisher } from '../dualScreen' import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
@@ -31,10 +33,13 @@ import {
spellPowerMultiplier, spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost, spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers' } from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick' import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine' import { advanceMemberTick } from '../combat/combatEngine'
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads' import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting' import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
} from '../combat/spellCasting'
import { import {
appendCombatLog, appendCombatLog,
STADIUM_COMBAT_LOG_LIMIT, STADIUM_COMBAT_LOG_LIMIT,
@@ -386,6 +391,7 @@ export function PvpStadiumScreen({
const nextLogId = useRef(2) const nextLogId = useRef(2)
const submittedShopRef = useRef(false) const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>()) const awardedXpRef = useRef(new Set<string>())
const matchStatsRecordedRef = useRef(false)
const queuedMatchRef = useRef(false) const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false) const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('') const loggedOpponentRoundRef = useRef('')
@@ -400,12 +406,16 @@ export function PvpStadiumScreen({
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const playerAlive = playerSide.party.some((member) => member.health > 0) const playerAlive = playerSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs]) const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({ const playerSpellSlotCost = useCallback(
...spell, (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady), [playerBuffCounts, playerSide.freeCastReady],
slotIndex, )
remaining: playerSide.cooldowns[spell.id] ?? 0, const playerSpellSlots = useSpellSlots({
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells]) spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo( const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog), () => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs], [buffCatalog, cpuSide.buffs],
@@ -496,6 +506,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false roundResolvedRef.current = false
setPlayerSide(setup.playerSide) setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide) setCpuSide(setup.opponentSide)
@@ -532,6 +543,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false roundResolvedRef.current = false
setPlayerSide(setup.playerSide) setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide) setCpuSide(setup.opponentSide)
@@ -790,7 +802,7 @@ export function PvpStadiumScreen({
...side, ...side,
party: nextParty, party: nextParty,
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE), resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)), cooldowns: pruneExpiredCooldowns(side.cooldowns),
survivalSeconds: nextSurvival, survivalSeconds: nextSurvival,
dampeningPercent, dampeningPercent,
} }
@@ -873,6 +885,20 @@ export function PvpStadiumScreen({
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status]) }, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
useEffect(() => {
if (status !== 'won' && status !== 'lost') return
if (matchStatsRecordedRef.current) return
matchStatsRecordedRef.current = true
recordPvpMatch(status === 'won')
.then(onProfileUpdated)
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
'danger',
)
})
}, [addLog, onProfileUpdated, status])
const startNextRound = useCallback(() => { const startNextRound = useCallback(() => {
const nextRound = roundIndex + 1 const nextRound = roundIndex + 1
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({ const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
@@ -1055,6 +1081,9 @@ export function PvpStadiumScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'), opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party, opponentParty: cpuSide.party,
opponentEnemyHealth: 0, opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary, opponentBuffSummary,
opponentDebuffSummary, opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts, floatingTexts: dualScreenFloatingTexts,
@@ -1083,6 +1112,7 @@ export function PvpStadiumScreen({
controllerIconStyle, controllerIconStyle,
cpuDifficulty, cpuDifficulty,
cpuSide.party, cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds, cpuSide.survivalSeconds,
directPartyTargeting, directPartyTargeting,
dualScreenFloatingTexts, dualScreenFloatingTexts,
@@ -1164,16 +1194,16 @@ export function PvpStadiumScreen({
</section> </section>
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => { {playerSpellSlots.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0 if (!spell) return null
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady) const remaining = spell.remaining
const percent = remaining > 0 const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100) ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0 : 0
return ( return (
<button <button
className="dual-top-spell" className="dual-top-spell"
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused} disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
key={spell.id} key={spell.id}
onClick={() => castPlayerSpell(spell)} onClick={() => castPlayerSpell(spell)}
type="button" type="button"
@@ -1188,7 +1218,7 @@ export function PvpStadiumScreen({
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong> <strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /> <span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -1222,7 +1252,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
</div> </div>
</div> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
@@ -1252,7 +1282,7 @@ export function PvpStadiumScreen({
</div> </div>
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span> <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
</div> </div>
</div> </div>
<div className="party-grid pvp-party-grid"> <div className="party-grid pvp-party-grid">
@@ -1276,6 +1306,7 @@ export function PvpStadiumScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && playerAlive} canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+7 -6
View File
@@ -100,14 +100,15 @@ export function LootRollList({
return ( return (
<div className="run-loot-rolls"> <div className="run-loot-rolls">
{rolls.map((roll) => ( {rolls.map((roll) => (
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}> <div className={roll.dropped || roll.petAwarded ? 'dropped' : 'empty'} key={roll.encounterId}>
<strong>{roll.encounterName}</strong> <strong>{roll.encounterName}</strong>
<span> <span>
{roll.items.length > 0 {[
? roll.items ...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) ...(roll.petAwarded
.join(', ') ? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
: 'No components dropped'} : []),
].join(', ') || 'No components dropped'}
</span> </span>
</div> </div>
))} ))}
+13 -8
View File
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
capture, capture,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
combatTouchLocked,
beginCapture, beginCapture,
cancelCapture, cancelCapture,
resetBindings, resetBindings,
setControllerIconStyle, setControllerIconStyle,
setDirectPartyTargeting, setDirectPartyTargeting,
setCombatTouchLocked,
} = useInput() } = useInput()
const { const {
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
@@ -89,14 +91,8 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
return ( return (
<section className="content-screen settings-screen"> <section className="content-screen settings-screen">
<div className="screen-heading"> <div className="settings-nav">
<div> <button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
<p className="eyebrow">Game Options</p>
<h1>Settings</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections"> <nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([ {([
{ key: 'display', label: 'Display' }, { key: 'display', label: 'Display' },
@@ -115,6 +111,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</button> </button>
))} ))}
</nav> </nav>
</div>
{settingsTab === 'display' && ( {settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel"> <section className="dual-screen-settings settings-tab-panel">
@@ -180,6 +177,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
> >
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'} {directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button> </button>
<button
aria-pressed={combatTouchLocked}
className={combatTouchLocked ? 'selected' : ''}
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
type="button"
>
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
</button>
<div className="controller-icon-options"> <div className="controller-icon-options">
<span>Controller Icons</span> <span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => ( {(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
+9 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import type { ControllerIconStyle } from '../input' import type { ControllerIconStyle } from '../input'
import type { Spell } from '../game' import type { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons' import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type SpellSlot = (Spell & { export type SpellSlot = (Spell & {
cost: number cost: number
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`} {unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span> </span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={barFillStyle((resource / maxResource) * 100)} /></div>
</div> </div>
) )
}) })
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle, iconStyle,
resourceName, resourceName,
disabled, disabled,
focusable = true,
onCast, onCast,
emptyKeyPrefix = 'empty', emptyKeyPrefix = 'empty',
}: { }: {
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle iconStyle: ControllerIconStyle
resourceName: string resourceName: string
disabled?: boolean disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
emptyKeyPrefix?: string emptyKeyPrefix?: string
}) { }) {
@@ -60,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return ( return (
<button <button
className="spell" className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled} disabled={disabled}
key={spell.id} key={spell.id}
onClick={() => onCast(spell)} onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description} title={spell.description}
type="button" type="button"
> >
@@ -94,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast, canCast,
onCast, onCast,
className = 'spell-bar six-slots vertical-spell-bar', className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: { }: {
spells: SpellSlot[] spells: SpellSlot[]
bindings: Record<string, string> bindings: Record<string, string>
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean canCast: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
className?: string className?: string
focusable?: boolean
}) { }) {
return ( return (
<div className={className}> <div className={className}>
@@ -118,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton <SpellButton
binding={bindings[`ability${slotIndex + 1}`]} binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0} disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle} iconStyle={iconStyle}
key={spell.id} key={spell.id}
onCast={onCast} onCast={onCast}
+16
View File
@@ -0,0 +1,16 @@
import type { CSSProperties } from 'react'
function clampScale(value: number) {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}
export function barFillStyle(
percent: number,
origin: 'left' | 'right' = 'left',
): CSSProperties {
return {
transform: `scaleX(${clampScale(percent / 100)})`,
transformOrigin: `${origin} center`,
}
}
+87 -33
View File
@@ -19,9 +19,11 @@ import {
dispatchExternalGameAction, dispatchExternalGameAction,
type ControllerIconStyle, type ControllerIconStyle,
type InputAction, type InputAction,
useGameAction,
} from './input' } from './input'
import { ControllerBindingLabel } from './components/ControllerIcons' import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames' import { PartyMemberFrame } from './components/PartyFrames'
import { barFillStyle } from './components/barStyles'
import { groupFloatingTextsByMember } from './combat/combatPresentation' import { groupFloatingTextsByMember } from './combat/combatPresentation'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled' const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
@@ -47,6 +49,9 @@ export type DualScreenCombatState = {
opponentClassName?: string opponentClassName?: string
opponentParty?: PartyMember[] opponentParty?: PartyMember[]
opponentEnemyHealth?: number opponentEnemyHealth?: number
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
opponentBuffSummary?: string opponentBuffSummary?: string
opponentDebuffSummary?: string opponentDebuffSummary?: string
floatingTexts: Array<{ floatingTexts: Array<{
@@ -79,7 +84,7 @@ export type DualScreenCombatState = {
} }
export type DualScreenWorkshopState = { export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents' mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
title: string title: string
subtitle: string subtitle: string
summary?: string summary?: string
@@ -145,6 +150,16 @@ function formatDualTime(seconds: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
} }
function shouldRelayBottomAction(
action: InputAction,
state: DualScreenCombatState | null,
) {
if (!state) return false
if (action === 'confirm' || action === 'toggleTouchLock') return false
if (state.status === 'playing') return true
return action === 'pause' || action === 'back'
}
export function DualScreenProvider({ children }: { children: ReactNode }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -398,6 +413,11 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
useGameAction((action) => {
if (!shouldRelayBottomAction(action, state)) return
sendAction(action)
})
if (!state && workshopState) { if (!state && workshopState) {
return ( return (
<main className="dual-bottom-display workshop-bottom-display"> <main className="dual-bottom-display workshop-bottom-display">
@@ -445,7 +465,10 @@ export function DualScreenBottomDisplay() {
} }
return ( return (
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}> <main
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
>
<header className="dual-controls-header"> <header className="dual-controls-header">
<div> <div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p> <p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -477,8 +500,19 @@ export function DualScreenBottomDisplay() {
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong> <strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div> </div>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} /> <span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
</div> </div>
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
<>
<div>
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
</div>
<div className="bar mana-bar">
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
</div>
</>
) : null}
</section> </section>
)} )}
@@ -513,7 +547,7 @@ export function DualScreenBottomDisplay() {
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span> <span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>} {state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
@@ -626,8 +660,35 @@ export function DualScreenTopCombat({
[state.floatingTexts], [state.floatingTexts],
) )
const spellButtons = state.spells.map((spell, slotIndex) => {
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return ( return (
<div className="dual-top-main"> <button
className="dual-top-spell"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})
return (
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
<section className="dual-top-enemy"> <section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true"> <div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'} {state.encounterIsBoss ? 'B' : 'M'}
@@ -638,10 +699,25 @@ export function DualScreenTopCombat({
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span> <span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div> </div>
<div className="bar enemy-health"> <div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} /> <span style={barFillStyle(enemyPercent)} />
</div> </div>
<p>{state.encounterDescription}</p> {state.opponentParty && (
<div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar">
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div>
)}
{!state.opponentParty && <p>{state.encounterDescription}</p>}
</div>
{state.opponentParty && (
<div className="dual-top-side-status">
<div className="dual-top-cooldowns" aria-label="Cooldowns">
{spellButtons}
</div>
</div>
)}
</section> </section>
<section className="dual-top-party"> <section className="dual-top-party">
@@ -669,39 +745,17 @@ export function DualScreenTopCombat({
</div> </div>
</section> </section>
{!state.opponentParty && (
<section className="dual-top-spell-strip"> <section className="dual-top-spell-strip">
{state.spells.map((spell, slotIndex) => { {spellButtons}
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
const percent = spell.remaining > 0
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
className="dual-top-spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => onCastSpell?.(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
</button>
)
})}
<div className="dual-top-resource"> <div className="dual-top-resource">
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong> <strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div> </div>
</div> </div>
</section> </section>
)}
</div> </div>
) )
+151 -10
View File
@@ -17,6 +17,7 @@ import type {
LootRoll, LootRoll,
Item, Item,
EquipmentSlot, EquipmentSlot,
PetAward,
} from './profile' } from './profile'
export type GameMode = 'online' | 'offline' export type GameMode = 'online' | 'offline'
@@ -64,6 +65,14 @@ export interface GameRepository {
difficultyId: number, difficultyId: number,
runToken: string, runToken: string,
): Promise<LootRoll> ): Promise<LootRoll>
recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}>
recordPvpMatch(won: boolean): Promise<CharacterProfile>
} }
type CharacterData = { type CharacterData = {
@@ -76,7 +85,7 @@ type CharacterData = {
} }
type OfflineSave = { type OfflineSave = {
version: 3 version: 4
characterName: string characterName: string
activeClassId: number activeClassId: number
completedDungeonParts: number completedDungeonParts: number
@@ -84,6 +93,10 @@ type OfflineSave = {
dungeonCompletions?: Record<string, number> dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData> characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll> lootRolls: Record<string, LootRoll>
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
} }
type OnlineCache = { type OnlineCache = {
@@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1' const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6 const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null let activeCatalogCache: CatalogCache | null = null
function clone<T>(value: T): T { function clone<T>(value: T): T {
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
} }
} }
return { return {
version: 3, version: 4,
characterName: p.character.name, characterName: p.character.name,
activeClassId: p.character.classId, activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts, completedDungeonParts: p.completedDungeonParts,
@@ -185,14 +199,33 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
), ),
characters, characters,
lootRolls: v1.lootRolls ?? {}, lootRolls: v1.lootRolls ?? {},
bossKills: p.hunterStats?.bossKills ?? {},
bossPets: p.hunterStats?.bossPets ?? {},
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
} }
} }
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave { function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
return normalizeSaveAbilitySlots({ return normalizeSaveAbilitySlots({
...v2, ...v2,
version: 3, version: 4,
completedRaidPhases: 0, completedRaidPhases: 0,
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
})
}
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
return normalizeSaveAbilitySlots({
...v3,
version: 4,
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
}) })
} }
@@ -224,9 +257,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile profile?: CharacterProfile
lootRolls?: Record<string, LootRoll> lootRolls?: Record<string, LootRoll>
} }
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave) if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 3) {
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
}
if (candidate.version === 2) { if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }) return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
} }
if (candidate.version === 1 && candidate.profile) { if (candidate.version === 1 && candidate.profile) {
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })) return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
@@ -241,7 +277,7 @@ function readSaveKey(key: string): OfflineSave | null {
const raw = JSON.parse(serialized) const raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw) const save = normalizeOfflineSave(raw)
if (!save) return null if (!save) return null
if (raw.version !== 3) { if (raw.version !== 4) {
localStorage.setItem(key, JSON.stringify(save)) localStorage.setItem(key, JSON.stringify(save))
} }
return save return save
@@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null {
save, save,
dirty: Boolean(raw.dirty), dirty: Boolean(raw.dirty),
} }
if ((raw.save as { version?: number } | undefined)?.version !== 3) { if ((raw.save as { version?: number } | undefined)?.version !== 4) {
writeOnlineCache(cache) writeOnlineCache(cache)
} }
return cache return cache
@@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
...dungeon, ...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0, completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
})) }))
static_.hunterStats = {
bossKills: clone(save.bossKills ?? {}),
bossPets: clone(save.bossPets ?? {}),
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
pvpMatchesWon: save.pvpMatchesWon ?? 0,
}
return static_ return static_
} }
@@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
inventory: clone(profile.inventory), inventory: clone(profile.inventory),
} }
return { return {
version: 3, version: 4,
characterName: profile.character.name, characterName: profile.character.name,
activeClassId: profile.character.classId, activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts, completedDungeonParts: profile.completedDungeonParts,
@@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
), ),
characters, characters,
lootRolls: clone(existingSave?.lootRolls ?? {}), lootRolls: clone(existingSave?.lootRolls ?? {}),
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
} }
} }
@@ -570,6 +616,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1] return entries[entries.length - 1]
} }
function bossPetName(encounterName: string) {
return `${encounterName} Pet`
}
function purpleBossPetName(encounterName: string) {
return `Purple ${encounterName} Pet`
}
function maybeAwardBossPet(
save: OfflineSave,
encounter: { id: number; enemyName: string },
variant: 'normal' | 'purple' = 'normal',
): PetAward | null {
if (Math.random() >= BOSS_PET_DROP_RATE) return null
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
const previousQuantity = save.bossPets?.[key] ?? 0
save.bossPets = {
...(save.bossPets ?? {}),
[key]: previousQuantity + 1,
}
return {
encounterId: encounter.id,
petName: variant === 'purple'
? purpleBossPetName(encounter.enemyName)
: bossPetName(encounter.enemyName),
quantity: 1,
duplicate: previousQuantity > 0,
quantityAfter: previousQuantity + 1,
}
}
function recordBossKillInSave(
save: OfflineSave,
encounter: { id: number; enemyName: string },
options?: { petVariant?: 'normal' | 'purple' },
): PetAward | null {
const key = String(encounter.id)
save.bossKills = {
...(save.bossKills ?? {}),
[key]: (save.bossKills?.[key] ?? 0) + 1,
}
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
}
function awardRoguelikeCoin( function awardRoguelikeCoin(
profile: CharacterProfile, profile: CharacterProfile,
sourceEncounterId: number | undefined, sourceEncounterId: number | undefined,
@@ -885,6 +975,10 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.upgradeItem(itemId), cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
} }
function emptyCharacterData(classId: number): CharacterData { function emptyCharacterData(classId: number): CharacterData {
@@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints, profile.maxTalentPoints,
cd.talentPoints + levelsGained, cd.talentPoints + levelsGained,
) )
let petAwarded: PetAward | null = null
if (options?.lootSourceEncounterId) {
const sourceEncounter = profile.dungeons
.flatMap((candidate) => candidate.encounters)
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
if (sourceEncounter) {
petAwarded = recordBossKillInSave(save, sourceEncounter, {
petVariant: 'purple',
})
}
}
const bonusItem = awardRoguelikeCoin( const bonusItem = awardRoguelikeCoin(
profile, profile,
options?.lootSourceEncounterId, options?.lootSourceEncounterId,
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
averageItemLevel: updatedProfile.gearStats.averageItemLevel, averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem, bonusItem,
petAwarded,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
@@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('This encounter has no configured loot.') throw new Error('This encounter has no configured loot.')
} }
const dropChance = entries[0].dropChance const dropChance = entries[0].dropChance
const petAwarded = encounter.isBoss
? recordBossKillInSave(save, encounter)
: null
const items: LootRoll['items'] = [] const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>() const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
@@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
awarded: Boolean(item), awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate), duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0, quantityAfter: item?.quantityAfter ?? 0,
petAwarded,
} }
save.lootRolls[rollKey] = result save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save) store.writeSave(save)
return clone(result) return clone(result)
}, },
async recordBossKill(encounterId, options) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
if (!encounter) throw new Error('Boss encounter not found.')
const petAwarded = recordBossKillInSave(save, encounter, {
petVariant: options?.petVariant ?? 'normal',
})
store.writeSave(save)
return {
profile: buildProfile(save),
petAwarded,
}
},
async recordPvpMatch(won) {
const save = requireStoredSave(store)
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
store.writeSave(save)
return buildProfile(save)
},
} }
} }
@@ -1581,6 +1714,10 @@ const cachedOnlineRepository: GameRepository = {
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId), upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
recordBossKill: (encounterId, options) =>
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
recordPvpMatch: (won) =>
cachedOnlineLocalRepository.recordPvpMatch(won),
} }
export function getGameMode(): GameMode { export function getGameMode(): GameMode {
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
characters[cid] = emptyCharacterData(cid) characters[cid] = emptyCharacterData(cid)
} }
const save: OfflineSave = { const save: OfflineSave = {
version: 3, version: 4,
characterName: name, characterName: name,
activeClassId: 1, activeClassId: 1,
completedDungeonParts: 0, completedDungeonParts: 0,
completedRaidPhases: 0, completedRaidPhases: 0,
characters, characters,
lootRolls: {}, lootRolls: {},
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
} }
writeOfflineSave(save) writeOfflineSave(save)
writeMode('offline-local') writeMode('offline-local')
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
export function useCooldownClock(
cooldowns: Record<string, number>,
active: boolean,
intervalMs = 100,
) {
const [now, setNow] = useState(cooldownNow)
useEffect(() => {
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
let timer = 0
let startTimer = 0
const tick = () => {
const nextNow = cooldownNow()
setNow(nextNow)
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
window.clearInterval(timer)
timer = 0
}
}
startTimer = window.setTimeout(() => {
tick()
timer = window.setInterval(tick, intervalMs)
}, 0)
return () => {
window.clearTimeout(startTimer)
if (timer) window.clearInterval(timer)
}
}, [active, cooldowns, intervalMs])
return now
}
+39 -17
View File
@@ -15,29 +15,31 @@ type FloatingCombatTextOptions = {
durationMs?: number durationMs?: number
} }
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900, durationMs = 900,
}: FloatingCombatTextOptions = {}) { }: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([]) const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1) const nextId = useRef(1)
const expirationsRef = useRef(new Map<number, number>()) const expirationsRef = useRef(new Map<number, number>())
const cleanupTimerRef = useRef<number | null>(null)
const clearFloatingTexts = useCallback(() => { const clearCleanupTimer = useCallback(() => {
expirationsRef.current.clear() if (cleanupTimerRef.current === null) return
setFloatingTexts([]) window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, []) }, [])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => { const scheduleCleanup = useCallback(() => {
if (entry.value <= 0) return function scheduleNext() {
const id = nextId.current++ clearCleanupTimer()
expirationsRef.current.set(id, performance.now() + durationMs) let nextExpiry = Infinity
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText)) for (const expiresAt of expirationsRef.current.values()) {
}, [durationMs]) nextExpiry = Math.min(nextExpiry, expiresAt)
}
if (!Number.isFinite(nextExpiry)) return
useEffect(() => { cleanupTimerRef.current = window.setTimeout(() => {
const timer = window.setInterval(() => { cleanupTimerRef.current = null
const now = performance.now() const now = performance.now()
let removed = false let removed = false
for (const [id, expiresAt] of expirationsRef.current) { for (const [id, expiresAt] of expirationsRef.current) {
@@ -45,11 +47,31 @@ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
expirationsRef.current.delete(id) expirationsRef.current.delete(id)
removed = true removed = true
} }
if (!removed) return if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id))) setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS) }
return () => window.clearInterval(timer) scheduleNext()
}, []) }, Math.max(0, nextExpiry - performance.now()))
}
scheduleNext()
}, [clearCleanupTimer])
const clearFloatingTexts = useCallback(() => {
clearCleanupTimer()
expirationsRef.current.clear()
setFloatingTexts([])
}, [clearCleanupTimer])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
if (entry.value <= 0) return
const id = nextId.current++
expirationsRef.current.set(id, performance.now() + durationMs)
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo( const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts), () => groupFloatingTextsByMember(floatingTexts),
+38
View File
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import type { Spell } from '../game'
import type { SpellSlot } from '../components/SpellBars'
import { cooldownRemaining } from '../combat/spellCasting'
import { useCooldownClock } from './useCooldownClock'
type UseSpellSlotsOptions = {
spells: readonly Spell[]
cooldowns: Record<string, number>
active: boolean
cost: (spell: Spell) => number
abilitySlots?: readonly (number | null)[]
}
export function useSpellSlots({
spells,
cooldowns,
active,
cost,
abilitySlots,
}: UseSpellSlotsOptions) {
const now = useCooldownClock(cooldowns, active)
return useMemo<SpellSlot[]>(() => {
const slotCount = abilitySlots?.length ?? spells.length
return Array.from({ length: slotCount }, (_, slotIndex) => {
if (abilitySlots && !abilitySlots[slotIndex]) return null
const spell = spells[slotIndex]
if (!spell) return null
return {
...spell,
cost: cost(spell),
slotIndex,
remaining: cooldownRemaining(cooldowns, spell.id, now),
}
})
}, [abilitySlots, cooldowns, cost, now, spells])
}
+3 -1
View File
@@ -3,7 +3,9 @@
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)), linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px); repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8; color: #f4eed8;
font-family: 'VT323', Consolas, monospace; --body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
--pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
font-family: var(--body-font);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
+224 -34
View File
@@ -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,6 +309,7 @@ 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()) {
@@ -266,6 +324,23 @@ function currentFocusableControl(candidates = focusableElements()) {
return null return null
} }
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
if (endA < startB) return startB - endA
if (endB < startA) return startA - endB
return 0
}
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
const options = Array.from(select.options).filter((option) => !option.disabled)
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
const nextOption = options[currentIndex + direction]
if (!nextOption) return false
select.selectedIndex = nextOption.index
select.dispatchEvent(new Event('input', { bubbles: true }))
select.dispatchEvent(new Event('change', { bubbles: true }))
return true
}
export function focusFirstControl() { export function focusFirstControl() {
const first = focusableElements()[0] const first = focusableElements()[0]
if (first) focusControl(first) if (first) focusControl(first)
@@ -286,21 +361,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)
} }
@@ -428,6 +530,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 +538,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
@@ -485,13 +589,38 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
if (action.startsWith('navigate')) { if (action === 'toggleTouchLock') {
if (uiOverlay || !combatActive) moveFocus(action) 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()
if (
active instanceof HTMLSelectElement
&& (action === 'navigateUp' || action === 'navigateDown')
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
) {
return
}
moveFocus(action)
}
} else if (action === 'confirm') { } else if (action === 'confirm') {
const active = currentFocusableControl() const active = currentFocusableControl()
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLSelectElement) {
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"]')
@@ -528,12 +657,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 +674,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 +693,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,
) )
@@ -621,7 +746,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
rememberFocusableControl(target) rememberFocusableControl(target)
} }
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
document.documentElement.dataset.inputDevice = 'pc' document.documentElement.dataset.inputDevice = 'pc'
}
const target = event.target const target = event.target
if (!(target instanceof Element)) return if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR) const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
@@ -645,8 +772,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener) return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken]) }, [dispatchControllerToken])
useEffect(() => () => {
window.clearTimeout(touchLockMessageTimerRef.current)
}, [])
useEffect(() => { useEffect(() => {
let focusFrame = 0
const ensureFocus = () => { const ensureFocus = () => {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]') const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return if (combatActive) return
const candidates = focusableElements() const candidates = focusableElements()
@@ -663,22 +796,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 +853,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 +883,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 +923,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 +938,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 +1006,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>
) )
} }
+2
View File
@@ -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' ? (
<InputProvider>
<DualScreenBottomDisplay /> <DualScreenBottomDisplay />
</InputProvider>
) : ( ) : (
<DualScreenProvider> <DualScreenProvider>
<DualScreenStartupPrompt /> <DualScreenStartupPrompt />
+1 -1
View File
@@ -1 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937' export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
+7 -1
View File
@@ -6905,5 +6905,11 @@
"full_run": [] "full_run": []
} }
} }
] ],
"hunterStats": {
"bossKills": {},
"bossPets": {},
"pvpMatchesPlayed": 0,
"pvpMatchesWon": 0
}
} }
+30
View File
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
quantityAfter: number 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)
} }