Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 |
@@ -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.
@@ -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 86
|
versionCode 89
|
||||||
versionName "1.1.7"
|
versionName "1.1.10"
|
||||||
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.
|
||||||
|
|||||||
@@ -254,6 +254,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
|
|||||||
PRIMARY KEY (character_id, item_id)
|
PRIMARY KEY (character_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_boss_stats (
|
||||||
|
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||||
|
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
|
||||||
|
PRIMARY KEY (character_id, encounter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_pvp_stats (
|
||||||
|
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
|
||||||
|
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS action_characters (
|
CREATE TABLE IF NOT EXISTS action_characters (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
|||||||
Generated
+64
@@ -16,6 +16,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^8.4.0",
|
"@capacitor/cli": "^8.4.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -801,6 +802,22 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
@@ -2972,6 +2989,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+139
-1
@@ -1,15 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +26,24 @@ GITEA_REPO = "i-want-to-heal"
|
|||||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||||
BRANCH = "main"
|
BRANCH = "main"
|
||||||
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
||||||
|
PLAYER_DATA_TABLES = [
|
||||||
|
"encounter_loot_roll_items",
|
||||||
|
"encounter_loot_rolls",
|
||||||
|
"dungeon_runs",
|
||||||
|
"character_pvp_stats",
|
||||||
|
"character_boss_stats",
|
||||||
|
"character_inventory",
|
||||||
|
"character_talents",
|
||||||
|
"character_ability_slots",
|
||||||
|
"action_encounter_loot_rolls",
|
||||||
|
"action_dungeon_runs",
|
||||||
|
"action_character_inventory",
|
||||||
|
"action_gear_items",
|
||||||
|
"action_characters",
|
||||||
|
"sessions",
|
||||||
|
"characters",
|
||||||
|
"accounts",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||||
@@ -30,6 +51,29 @@ def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> Non
|
|||||||
subprocess.run(args, cwd=cwd, env=env, check=True)
|
subprocess.run(args, cwd=cwd, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-player-data",
|
||||||
|
action="store_true",
|
||||||
|
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-db",
|
||||||
|
type=Path,
|
||||||
|
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes-reset",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.yes_reset and not args.reset_player_data:
|
||||||
|
parser.error("--yes-reset requires --reset-player-data")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def prompt_version() -> str:
|
def prompt_version() -> str:
|
||||||
current = GRADLE_FILE.read_text()
|
current = GRADLE_FILE.read_text()
|
||||||
match = re.search(r'versionName\s+"([^"]+)"', current)
|
match = re.search(r'versionName\s+"([^"]+)"', current)
|
||||||
@@ -151,13 +195,107 @@ def update_truenas() -> None:
|
|||||||
print("Then restart app in TrueNAS UI.")
|
print("Then restart app in TrueNAS UI.")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def default_reset_database_path() -> Path:
|
||||||
|
truenas_database = TRUENAS_PATH / "data" / "game.db"
|
||||||
|
if truenas_database.exists():
|
||||||
|
return truenas_database
|
||||||
|
return REPO_ROOT / "data" / "game.db"
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(database: sqlite3.Connection, table: str) -> bool:
|
||||||
|
return database.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
|
(table,),
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def count_rows(database: sqlite3.Connection, table: str) -> int:
|
||||||
|
if not table_exists(database, table):
|
||||||
|
return 0
|
||||||
|
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def backup_database(database_path: Path) -> Path:
|
||||||
|
backup_dir = database_path.parent / "backups"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
|
||||||
|
|
||||||
|
source = sqlite3.connect(database_path)
|
||||||
|
try:
|
||||||
|
destination = sqlite3.connect(backup_path)
|
||||||
|
try:
|
||||||
|
source.backup(destination)
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
||||||
|
database_path = database_path.expanduser().resolve()
|
||||||
|
if not database_path.exists():
|
||||||
|
raise SystemExit(f"Database not found: {database_path}")
|
||||||
|
|
||||||
|
if not assume_yes:
|
||||||
|
print(f"Reset target: {database_path}")
|
||||||
|
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
|
||||||
|
if confirmation != "RESET":
|
||||||
|
raise SystemExit("Player data reset cancelled.")
|
||||||
|
|
||||||
|
backup_path = backup_database(database_path)
|
||||||
|
database = sqlite3.connect(database_path)
|
||||||
|
try:
|
||||||
|
before = {
|
||||||
|
"accounts": count_rows(database, "accounts"),
|
||||||
|
"characters": count_rows(database, "characters"),
|
||||||
|
"action_characters": count_rows(database, "action_characters"),
|
||||||
|
"dungeon_runs": count_rows(database, "dungeon_runs"),
|
||||||
|
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
|
||||||
|
}
|
||||||
|
database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
database.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
for table in PLAYER_DATA_TABLES:
|
||||||
|
if table_exists(database, table):
|
||||||
|
database.execute(f'DELETE FROM "{table}"')
|
||||||
|
database.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
database.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
after_accounts = count_rows(database, "accounts")
|
||||||
|
after_characters = count_rows(database, "characters")
|
||||||
|
if after_accounts or after_characters:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Database backup: {backup_path}")
|
||||||
|
print(
|
||||||
|
"Player data reset: "
|
||||||
|
f"{before['accounts']} accounts, "
|
||||||
|
f"{before['characters']} characters, "
|
||||||
|
f"{before['action_characters']} action characters, "
|
||||||
|
f"{before['dungeon_runs']} dungeon runs, "
|
||||||
|
f"{before['encounter_loot_rolls']} loot rolls removed."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
os.chdir(REPO_ROOT)
|
os.chdir(REPO_ROOT)
|
||||||
version = prompt_version()
|
version = prompt_version()
|
||||||
apk = build_apk(version)
|
apk = build_apk(version)
|
||||||
commit_and_push(version)
|
commit_and_push(version)
|
||||||
create_gitea_release(version, apk)
|
create_gitea_release(version, apk)
|
||||||
update_truenas()
|
update_truenas()
|
||||||
|
if args.reset_player_data:
|
||||||
|
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
|
||||||
print("Done.")
|
print("Done.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
|
|||||||
})),
|
})),
|
||||||
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
||||||
dungeons: profile.dungeons.map(normalizeDungeon),
|
dungeons: profile.dungeons.map(normalizeDungeon),
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -815,6 +815,21 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
AND completed_parts >= 1
|
AND completed_parts >= 1
|
||||||
GROUP BY dungeon_id
|
GROUP BY dungeon_id
|
||||||
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
||||||
|
const bossStatRows = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
encounter_id AS encounterId,
|
||||||
|
kills,
|
||||||
|
pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(characterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
matches_played AS matchesPlayed,
|
||||||
|
matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(characterId)
|
||||||
|
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
database.prepare('SELECT key, value FROM game_settings').all()
|
database.prepare('SELECT key, value FROM game_settings').all()
|
||||||
@@ -930,6 +945,12 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,8 +1046,18 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||||
?? characters[0]?.name
|
?? characters[0]?.name
|
||||||
?? 'Mira'
|
?? 'Mira'
|
||||||
|
const bossStats = database.prepare(`
|
||||||
|
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(activeCharacterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(activeCharacterId)
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName,
|
characterName,
|
||||||
activeClassId,
|
activeClassId,
|
||||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
@@ -1038,6 +1069,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
lootRolls: {},
|
lootRolls: {},
|
||||||
|
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,7 +1087,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
if (
|
if (
|
||||||
!save
|
!save
|
||||||
|| typeof save !== 'object'
|
|| typeof save !== 'object'
|
||||||
|| Number(save.version) !== 3
|
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|
||||||
|| typeof save.characterName !== 'string'
|
|| typeof save.characterName !== 'string'
|
||||||
|| !save.characters
|
|| !save.characters
|
||||||
|| typeof save.characters !== 'object'
|
|| typeof save.characters !== 'object'
|
||||||
@@ -1149,6 +1184,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
|
const insertBossStats = database.prepare(`
|
||||||
|
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
for (const classId of classIds) {
|
for (const classId of classIds) {
|
||||||
const local = save.characters[classId]
|
const local = save.characters[classId]
|
||||||
@@ -1213,6 +1252,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
DELETE FROM character_inventory
|
DELETE FROM character_inventory
|
||||||
WHERE character_id = ?
|
WHERE character_id = ?
|
||||||
`).run(characterId)
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
const inventoryByItemId = new Map()
|
const inventoryByItemId = new Map()
|
||||||
const equippedSlots = new Set()
|
const equippedSlots = new Set()
|
||||||
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
||||||
@@ -1235,6 +1282,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
for (const [itemId, itemState] of inventoryByItemId) {
|
for (const [itemId, itemState] of inventoryByItemId) {
|
||||||
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
if (classId === Number(save.activeClassId)) {
|
||||||
|
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
|
||||||
|
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
|
||||||
|
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(
|
||||||
|
characterId,
|
||||||
|
id,
|
||||||
|
clampInteger(rawKills, 0, 0, 1000000),
|
||||||
|
clampInteger(bossPets[encounterId], 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
|
||||||
|
if (Object.hasOwn(bossKills, encounterId)) continue
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
characterId,
|
||||||
|
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
|
||||||
|
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncedClassId = clampInteger(
|
let syncedClassId = clampInteger(
|
||||||
|
|||||||
+556
-15
@@ -55,6 +55,21 @@ textarea:focus-visible,
|
|||||||
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-input-device='controller'] button:focus,
|
||||||
|
html[data-input-device='controller'] input:focus,
|
||||||
|
html[data-input-device='controller'] select:focus,
|
||||||
|
html[data-input-device='controller'] textarea:focus,
|
||||||
|
html[data-input-device='controller'] [tabindex]:focus {
|
||||||
|
outline: 3px solid #fff4a8 !important;
|
||||||
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-input-device='controller'] [data-game-nav-active='true'] button:focus:not(.game-selected),
|
||||||
|
html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:focus:not(.game-selected) {
|
||||||
|
outline: 2px solid #42414c !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-heading {
|
.settings-heading {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
border-bottom: 2px solid #34343d;
|
border-bottom: 2px solid #34343d;
|
||||||
@@ -1849,10 +1864,13 @@ h2 {
|
|||||||
|
|
||||||
.main-menu-grid {
|
.main-menu-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 15px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
height: min(100%, 388px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 930px;
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roguelike-mode-grid {
|
.roguelike-mode-grid {
|
||||||
@@ -1901,24 +1919,33 @@ h2 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
min-height: 105px;
|
min-height: 0;
|
||||||
outline: 2px solid #42414c;
|
outline: 2px solid #42414c;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:first-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card:hover {
|
.menu-card:hover {
|
||||||
outline-color: var(--gold);
|
outline-color: var(--gold);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-card.game-selected {
|
||||||
|
outline: 3px solid #fff4a8;
|
||||||
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card.game-selected > span {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #13141a;
|
||||||
|
}
|
||||||
|
|
||||||
.cloud-sync-card {
|
.cloud-sync-card {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1931,6 +1958,13 @@ h2 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-sync-card .text-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cloud-sync-card .text-button:disabled {
|
.cloud-sync-card .text-button:disabled {
|
||||||
@@ -1948,10 +1982,10 @@ h2 {
|
|||||||
border: 2px solid var(--gold);
|
border: 2px solid var(--gold);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 58px;
|
flex: 0 0 48px;
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 19px;
|
font-size: 16px;
|
||||||
height: 58px;
|
height: 48px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1962,13 +1996,13 @@ h2 {
|
|||||||
|
|
||||||
.menu-card strong {
|
.menu-card strong {
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
margin-bottom: 9px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card small {
|
.menu-card small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6977,6 +7011,95 @@ h2 {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
gap: 12px;
|
||||||
|
max-height: calc(100dvh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand,
|
||||||
|
.auth-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand > p:last-child {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs button {
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card form {
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card label {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card input {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .primary-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-message {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-divider {
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-entry {
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-entry p:not(.eyebrow) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-entry h2 {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-resume-button,
|
||||||
|
.offline-new-button {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.screen-heading {
|
.screen-heading {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -7340,6 +7463,127 @@ h2 {
|
|||||||
.ability-library {
|
.ability-library {
|
||||||
max-height: 190px;
|
max-height: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.enemy-card {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-portrait {
|
||||||
|
flex-basis: 48px;
|
||||||
|
font-size: 17px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-info p {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-height: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-layout {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(248px, 0.85fr);
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-panel,
|
||||||
|
.combat-log {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-panel-top {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-grid {
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-member {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-header {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-header small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-health {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-effects {
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-effects span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-side-rail .panel-heading .eyebrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-side-rail .panel-heading h2 {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar,
|
||||||
|
.vertical-spell-bar.six-slots {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar .spell {
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 5px 5px 5px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar .spell-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
height: 22px;
|
||||||
|
left: 6px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar .spell strong {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar .spell small {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-spell-bar .spell kbd {
|
||||||
|
font-size: 8px;
|
||||||
|
min-height: 16px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
right: 3px;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
@@ -9459,3 +9703,300 @@ h2 {
|
|||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hunter-profile-screen {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-profile-heading {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-profile-tabs {
|
||||||
|
align-self: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-tile {
|
||||||
|
display: flex;
|
||||||
|
min-height: 118px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(229, 185, 95, 0.24);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(19, 24, 31, 0.84);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-tile span,
|
||||||
|
.hunter-stat-tile small {
|
||||||
|
color: rgba(238, 232, 210, 0.68);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-tile strong {
|
||||||
|
color: #f4df9b;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-log-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-list {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-list {
|
||||||
|
max-height: min(58vh, 420px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 8px 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(238, 232, 210, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(14, 18, 24, 0.78);
|
||||||
|
color: #eee8d2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-button:hover,
|
||||||
|
.collection-boss-button:focus-visible,
|
||||||
|
.collection-boss-button.selected {
|
||||||
|
border-color: rgba(229, 185, 95, 0.62);
|
||||||
|
background: rgba(46, 40, 28, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-button > span,
|
||||||
|
.collection-boss-header > span,
|
||||||
|
.collection-drop-row > span {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(229, 185, 95, 0.14);
|
||||||
|
color: #f4df9b;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-button strong,
|
||||||
|
.collection-boss-button small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-button small {
|
||||||
|
grid-column: 2;
|
||||||
|
color: rgba(238, 232, 210, 0.66);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-detail {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(229, 185, 95, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(19, 24, 31, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-header > span {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #f4df9b;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-header small {
|
||||||
|
color: rgba(238, 232, 210, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 24px minmax(28px, 1fr) auto auto;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: start;
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid rgba(238, 232, 210, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(9, 12, 17, 0.52);
|
||||||
|
color: #eee8d2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row strong {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row small {
|
||||||
|
color: rgba(238, 232, 210, 0.68);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row b {
|
||||||
|
align-self: end;
|
||||||
|
color: #f4df9b;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row > span {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.missing {
|
||||||
|
border-color: rgba(238, 232, 210, 0.08);
|
||||||
|
background: rgba(10, 12, 16, 0.38);
|
||||||
|
opacity: 0.46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.missing > span {
|
||||||
|
background: rgba(238, 232, 210, 0.08);
|
||||||
|
color: rgba(238, 232, 210, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.missing strong,
|
||||||
|
.collection-drop-row.missing small,
|
||||||
|
.collection-drop-row.missing b {
|
||||||
|
color: rgba(238, 232, 210, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.owned {
|
||||||
|
border-color: rgba(229, 185, 95, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row:focus-visible {
|
||||||
|
outline: 2px solid #f4df9b;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-common {
|
||||||
|
border-color: rgba(238, 232, 210, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-stat {
|
||||||
|
border-color: rgba(229, 185, 95, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-stat > span {
|
||||||
|
background: rgba(229, 185, 95, 0.16);
|
||||||
|
color: #f4df9b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-common > span {
|
||||||
|
background: rgba(238, 232, 210, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-uncommon {
|
||||||
|
border-color: rgba(79, 204, 126, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-uncommon > span {
|
||||||
|
background: rgba(79, 204, 126, 0.14);
|
||||||
|
color: #7feb9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-rare {
|
||||||
|
border-color: rgba(113, 208, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-rare > span {
|
||||||
|
background: rgba(113, 208, 255, 0.14);
|
||||||
|
color: #9bdcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-epic {
|
||||||
|
border-color: rgba(193, 134, 255, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-epic > span {
|
||||||
|
background: rgba(193, 134, 255, 0.16);
|
||||||
|
color: #d9b6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-legendary {
|
||||||
|
border-color: rgba(245, 159, 75, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-drop-row.collection-rarity-legendary > span {
|
||||||
|
background: rgba(245, 159, 75, 0.16);
|
||||||
|
color: #ffc078;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.collection-log-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-stat-tile {
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-boss-list {
|
||||||
|
max-height: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+111
-46
@@ -19,12 +19,13 @@ import {
|
|||||||
syncCloudSave,
|
syncCloudSave,
|
||||||
type GameMode,
|
type GameMode,
|
||||||
} from './gameRepository'
|
} from './gameRepository'
|
||||||
import { focusFirstControl } from './input.tsx'
|
import { focusFirstControl, useGameAction } from './input.tsx'
|
||||||
import { barFillStyle } from './components/barStyles'
|
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 })))
|
||||||
@@ -39,6 +40,7 @@ type Screen =
|
|||||||
| 'pvp'
|
| 'pvp'
|
||||||
| 'customize'
|
| 'customize'
|
||||||
| 'equipment'
|
| 'equipment'
|
||||||
|
| 'hunter-profile'
|
||||||
| 'talents'
|
| 'talents'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
@@ -52,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.' },
|
||||||
]
|
]
|
||||||
@@ -59,6 +62,7 @@ const MENU_ITEMS: Array<{
|
|||||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||||
const SHOW_LEADERBOARDS = false
|
const SHOW_LEADERBOARDS = false
|
||||||
const ACTIVITY_PAGE_SIZE = 4
|
const ACTIVITY_PAGE_SIZE = 4
|
||||||
|
const HOME_MENU_COLUMNS = 2
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -112,6 +116,7 @@ function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||||
const [syncMessage, setSyncMessage] = useState('')
|
const [syncMessage, setSyncMessage] = useState('')
|
||||||
|
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAuthSession()
|
loadAuthSession()
|
||||||
@@ -333,6 +338,49 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloudSync = getCloudSyncStatus()
|
||||||
|
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
||||||
|
const homeMenuOffset = canShowCloudSync ? 1 : 0
|
||||||
|
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||||
|
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||||
|
|
||||||
|
function openHomeMenuIndex(index: number) {
|
||||||
|
if (canShowCloudSync && index === 0) {
|
||||||
|
if (!syncingCloud && cloudSync.dirty) void syncSaveNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||||
|
if (!item) return
|
||||||
|
if (item.screen === 'pvp') {
|
||||||
|
setRoguelikeVariant('pvp')
|
||||||
|
setScreen('roguelike')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setScreen(item.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameAction((action, device) => {
|
||||||
|
if (device !== 'controller' || screen !== 'menu') return
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openHomeMenuIndex(homeActiveIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!action.startsWith('navigate')) return
|
||||||
|
setHomeSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, homeMenuEntryCount - 1)
|
||||||
|
const column = bounded % HOME_MENU_COLUMNS
|
||||||
|
if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded
|
||||||
|
if (action === 'navigateRight') {
|
||||||
|
const next = bounded + 1
|
||||||
|
return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded
|
||||||
|
}
|
||||||
|
if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded
|
||||||
|
const next = bounded + HOME_MENU_COLUMNS
|
||||||
|
if (next < homeMenuEntryCount) return next
|
||||||
|
return column > 0 ? homeMenuEntryCount - 1 : bounded
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
@@ -452,37 +500,40 @@ function App() {
|
|||||||
const activity = selectedActivityOption ?? dungeon
|
const activity = selectedActivityOption ?? dungeon
|
||||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const cloudSync = getCloudSyncStatus()
|
|
||||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
{screen !== 'hunter-profile' && (
|
||||||
<button
|
<header className="topbar app-header">
|
||||||
className="brand-button"
|
<button
|
||||||
onClick={() => setScreen('menu')}
|
className="brand-button"
|
||||||
type="button"
|
onClick={() => setScreen('menu')}
|
||||||
>
|
type="button"
|
||||||
<strong>Menu</strong>
|
>
|
||||||
</button>
|
<strong>Menu</strong>
|
||||||
<div className="character-summary">
|
|
||||||
<strong>{profile.character.name}</strong>
|
|
||||||
<small>Level {profile.character.level}</small>
|
|
||||||
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
|
||||||
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
|
||||||
<span style={barFillStyle(experiencePercent)} />
|
|
||||||
</div>
|
|
||||||
<button className="logout-button" onClick={signOut} type="button">
|
|
||||||
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div className="character-summary">
|
||||||
</header>
|
<strong>{profile.character.name}</strong>
|
||||||
|
<small>Level {profile.character.level}</small>
|
||||||
|
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
||||||
|
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
||||||
|
<span style={barFillStyle(experiencePercent)} />
|
||||||
|
</div>
|
||||||
|
<button className="logout-button" onClick={signOut} type="button">
|
||||||
|
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="menu-screen">
|
<section className="menu-screen" data-game-nav-active="true">
|
||||||
<div className="main-menu-grid">
|
<div className="main-menu-grid">
|
||||||
{canShowCloudSync && (
|
{canShowCloudSync && (
|
||||||
<div className="menu-card cloud-sync-card">
|
<div
|
||||||
|
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
||||||
|
onPointerDown={() => setHomeSelectedIndex(0)}
|
||||||
|
>
|
||||||
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Cloud Save</strong>
|
<strong>Cloud Save</strong>
|
||||||
@@ -495,6 +546,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={syncingCloud || !cloudSync.dirty}
|
disabled={syncingCloud || !cloudSync.dirty}
|
||||||
onClick={syncSaveNow}
|
onClick={syncSaveNow}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -503,27 +555,33 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{MENU_ITEMS.map((item) => (
|
{MENU_ITEMS.map((item, index) => {
|
||||||
<button
|
const homeIndex = index + homeMenuOffset
|
||||||
className="menu-card"
|
return (
|
||||||
key={item.screen}
|
<button
|
||||||
onClick={() => {
|
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
|
||||||
if (item.screen === 'pvp') {
|
data-controller-nav="skip"
|
||||||
setRoguelikeVariant('pvp')
|
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
|
||||||
setScreen('roguelike')
|
key={item.screen}
|
||||||
return
|
onClick={() => {
|
||||||
}
|
if (item.screen === 'pvp') {
|
||||||
setScreen(item.screen)
|
setRoguelikeVariant('pvp')
|
||||||
}}
|
setScreen('roguelike')
|
||||||
type="button"
|
return
|
||||||
>
|
}
|
||||||
<span>{item.glyph}</span>
|
setScreen(item.screen)
|
||||||
<div>
|
}}
|
||||||
<strong>{item.label}</strong>
|
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
|
||||||
<small>{item.description}</small>
|
type="button"
|
||||||
</div>
|
>
|
||||||
</button>
|
<span>{item.glyph}</span>
|
||||||
))}
|
<div>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<small>{item.description}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1091,6 +1149,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,6 +3,7 @@ import {
|
|||||||
completeDungeon,
|
completeDungeon,
|
||||||
completeRoguelike,
|
completeRoguelike,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
recordBossKill,
|
||||||
type DungeonReward,
|
type DungeonReward,
|
||||||
rollEncounterLoot,
|
rollEncounterLoot,
|
||||||
type LootRoll,
|
type LootRoll,
|
||||||
@@ -371,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)
|
||||||
@@ -516,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(
|
||||||
@@ -527,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)
|
||||||
@@ -558,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
|
||||||
@@ -991,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({
|
||||||
@@ -1100,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ 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'
|
||||||
@@ -362,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>())
|
||||||
@@ -538,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(
|
||||||
@@ -654,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
|
||||||
@@ -711,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
|
||||||
@@ -1096,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())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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'
|
||||||
@@ -391,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('')
|
||||||
@@ -505,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)
|
||||||
@@ -541,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)
|
||||||
@@ -882,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>({
|
||||||
|
|||||||
@@ -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
-4
@@ -84,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
|
||||||
@@ -150,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',
|
||||||
@@ -404,8 +414,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (!state || state.opponentParty) return
|
if (!shouldRelayBottomAction(action, state)) return
|
||||||
if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return
|
|
||||||
sendAction(action)
|
sendAction(action)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,7 +467,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
|
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
|
||||||
data-combat-active={!state.opponentParty && state.status === 'playing' && !state.paused ? 'true' : 'false'}
|
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
<header className="dual-controls-header">
|
<header className="dual-controls-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+151
-10
@@ -17,6 +17,7 @@ import type {
|
|||||||
LootRoll,
|
LootRoll,
|
||||||
Item,
|
Item,
|
||||||
EquipmentSlot,
|
EquipmentSlot,
|
||||||
|
PetAward,
|
||||||
} from './profile'
|
} from './profile'
|
||||||
|
|
||||||
export type GameMode = 'online' | 'offline'
|
export type GameMode = 'online' | 'offline'
|
||||||
@@ -64,6 +65,14 @@ export interface GameRepository {
|
|||||||
difficultyId: number,
|
difficultyId: number,
|
||||||
runToken: string,
|
runToken: string,
|
||||||
): Promise<LootRoll>
|
): Promise<LootRoll>
|
||||||
|
recordBossKill(
|
||||||
|
encounterId: number,
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): Promise<{
|
||||||
|
profile: CharacterProfile
|
||||||
|
petAwarded: PetAward | null
|
||||||
|
}>
|
||||||
|
recordPvpMatch(won: boolean): Promise<CharacterProfile>
|
||||||
}
|
}
|
||||||
|
|
||||||
type CharacterData = {
|
type CharacterData = {
|
||||||
@@ -76,7 +85,7 @@ type CharacterData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OfflineSave = {
|
type OfflineSave = {
|
||||||
version: 3
|
version: 4
|
||||||
characterName: string
|
characterName: string
|
||||||
activeClassId: number
|
activeClassId: number
|
||||||
completedDungeonParts: number
|
completedDungeonParts: number
|
||||||
@@ -84,6 +93,10 @@ type OfflineSave = {
|
|||||||
dungeonCompletions?: Record<string, number>
|
dungeonCompletions?: Record<string, number>
|
||||||
characters: Record<number, CharacterData>
|
characters: Record<number, CharacterData>
|
||||||
lootRolls: Record<string, LootRoll>
|
lootRolls: Record<string, LootRoll>
|
||||||
|
bossKills: Record<string, number>
|
||||||
|
bossPets: Record<string, number>
|
||||||
|
pvpMatchesPlayed: number
|
||||||
|
pvpMatchesWon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnlineCache = {
|
type OnlineCache = {
|
||||||
@@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
|||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
const ABILITY_SLOT_COUNT = 6
|
const ABILITY_SLOT_COUNT = 6
|
||||||
|
const BOSS_PET_DROP_RATE = 1 / 500
|
||||||
let activeCatalogCache: CatalogCache | null = null
|
let activeCatalogCache: CatalogCache | null = null
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName: p.character.name,
|
characterName: p.character.name,
|
||||||
activeClassId: p.character.classId,
|
activeClassId: p.character.classId,
|
||||||
completedDungeonParts: p.completedDungeonParts,
|
completedDungeonParts: p.completedDungeonParts,
|
||||||
@@ -185,14 +199,33 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
),
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: v1.lootRolls ?? {},
|
lootRolls: v1.lootRolls ?? {},
|
||||||
|
bossKills: p.hunterStats?.bossKills ?? {},
|
||||||
|
bossPets: p.hunterStats?.bossPets ?? {},
|
||||||
|
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
|
||||||
return normalizeSaveAbilitySlots({
|
return normalizeSaveAbilitySlots({
|
||||||
...v2,
|
...v2,
|
||||||
version: 3,
|
version: 4,
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
|
||||||
|
return normalizeSaveAbilitySlots({
|
||||||
|
...v3,
|
||||||
|
version: 4,
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +257,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
|||||||
profile?: CharacterProfile
|
profile?: CharacterProfile
|
||||||
lootRolls?: Record<string, LootRoll>
|
lootRolls?: Record<string, LootRoll>
|
||||||
}
|
}
|
||||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||||
|
if (candidate.version === 3) {
|
||||||
|
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
|
||||||
|
}
|
||||||
if (candidate.version === 2) {
|
if (candidate.version === 2) {
|
||||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
|
||||||
}
|
}
|
||||||
if (candidate.version === 1 && candidate.profile) {
|
if (candidate.version === 1 && candidate.profile) {
|
||||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||||
@@ -241,7 +277,7 @@ function readSaveKey(key: string): OfflineSave | null {
|
|||||||
const raw = JSON.parse(serialized)
|
const raw = JSON.parse(serialized)
|
||||||
const save = normalizeOfflineSave(raw)
|
const save = normalizeOfflineSave(raw)
|
||||||
if (!save) return null
|
if (!save) return null
|
||||||
if (raw.version !== 3) {
|
if (raw.version !== 4) {
|
||||||
localStorage.setItem(key, JSON.stringify(save))
|
localStorage.setItem(key, JSON.stringify(save))
|
||||||
}
|
}
|
||||||
return save
|
return save
|
||||||
@@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null {
|
|||||||
save,
|
save,
|
||||||
dirty: Boolean(raw.dirty),
|
dirty: Boolean(raw.dirty),
|
||||||
}
|
}
|
||||||
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
|
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
|
||||||
writeOnlineCache(cache)
|
writeOnlineCache(cache)
|
||||||
}
|
}
|
||||||
return cache
|
return cache
|
||||||
@@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
|
|||||||
...dungeon,
|
...dungeon,
|
||||||
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
||||||
}))
|
}))
|
||||||
|
static_.hunterStats = {
|
||||||
|
bossKills: clone(save.bossKills ?? {}),
|
||||||
|
bossPets: clone(save.bossPets ?? {}),
|
||||||
|
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: save.pvpMatchesWon ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
return static_
|
return static_
|
||||||
}
|
}
|
||||||
@@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
inventory: clone(profile.inventory),
|
inventory: clone(profile.inventory),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName: profile.character.name,
|
characterName: profile.character.name,
|
||||||
activeClassId: profile.character.classId,
|
activeClassId: profile.character.classId,
|
||||||
completedDungeonParts: profile.completedDungeonParts,
|
completedDungeonParts: profile.completedDungeonParts,
|
||||||
@@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
),
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
||||||
|
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
|
||||||
|
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
|
||||||
|
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +616,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
|
|||||||
return entries[entries.length - 1]
|
return entries[entries.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bossPetName(encounterName: string) {
|
||||||
|
return `${encounterName} Pet`
|
||||||
|
}
|
||||||
|
|
||||||
|
function purpleBossPetName(encounterName: string) {
|
||||||
|
return `Purple ${encounterName} Pet`
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeAwardBossPet(
|
||||||
|
save: OfflineSave,
|
||||||
|
encounter: { id: number; enemyName: string },
|
||||||
|
variant: 'normal' | 'purple' = 'normal',
|
||||||
|
): PetAward | null {
|
||||||
|
if (Math.random() >= BOSS_PET_DROP_RATE) return null
|
||||||
|
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
|
||||||
|
const previousQuantity = save.bossPets?.[key] ?? 0
|
||||||
|
save.bossPets = {
|
||||||
|
...(save.bossPets ?? {}),
|
||||||
|
[key]: previousQuantity + 1,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
encounterId: encounter.id,
|
||||||
|
petName: variant === 'purple'
|
||||||
|
? purpleBossPetName(encounter.enemyName)
|
||||||
|
: bossPetName(encounter.enemyName),
|
||||||
|
quantity: 1,
|
||||||
|
duplicate: previousQuantity > 0,
|
||||||
|
quantityAfter: previousQuantity + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordBossKillInSave(
|
||||||
|
save: OfflineSave,
|
||||||
|
encounter: { id: number; enemyName: string },
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): PetAward | null {
|
||||||
|
const key = String(encounter.id)
|
||||||
|
save.bossKills = {
|
||||||
|
...(save.bossKills ?? {}),
|
||||||
|
[key]: (save.bossKills?.[key] ?? 0) + 1,
|
||||||
|
}
|
||||||
|
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
|
||||||
|
}
|
||||||
|
|
||||||
function awardRoguelikeCoin(
|
function awardRoguelikeCoin(
|
||||||
profile: CharacterProfile,
|
profile: CharacterProfile,
|
||||||
sourceEncounterId: number | undefined,
|
sourceEncounterId: number | undefined,
|
||||||
@@ -885,6 +975,10 @@ const serverRepository: GameRepository = {
|
|||||||
cachedOnlineLocalRepository.upgradeItem(itemId),
|
cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||||
|
recordBossKill: (encounterId, options) =>
|
||||||
|
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||||
|
recordPvpMatch: (won) =>
|
||||||
|
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCharacterData(classId: number): CharacterData {
|
function emptyCharacterData(classId: number): CharacterData {
|
||||||
@@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
profile.maxTalentPoints,
|
profile.maxTalentPoints,
|
||||||
cd.talentPoints + levelsGained,
|
cd.talentPoints + levelsGained,
|
||||||
)
|
)
|
||||||
|
let petAwarded: PetAward | null = null
|
||||||
|
if (options?.lootSourceEncounterId) {
|
||||||
|
const sourceEncounter = profile.dungeons
|
||||||
|
.flatMap((candidate) => candidate.encounters)
|
||||||
|
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
|
||||||
|
if (sourceEncounter) {
|
||||||
|
petAwarded = recordBossKillInSave(save, sourceEncounter, {
|
||||||
|
petVariant: 'purple',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
const bonusItem = awardRoguelikeCoin(
|
const bonusItem = awardRoguelikeCoin(
|
||||||
profile,
|
profile,
|
||||||
options?.lootSourceEncounterId,
|
options?.lootSourceEncounterId,
|
||||||
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||||
unlockedAbilities,
|
unlockedAbilities,
|
||||||
bonusItem,
|
bonusItem,
|
||||||
|
petAwarded,
|
||||||
profile: updatedProfile,
|
profile: updatedProfile,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('This encounter has no configured loot.')
|
throw new Error('This encounter has no configured loot.')
|
||||||
}
|
}
|
||||||
const dropChance = entries[0].dropChance
|
const dropChance = entries[0].dropChance
|
||||||
|
const petAwarded = encounter.isBoss
|
||||||
|
? recordBossKillInSave(save, encounter)
|
||||||
|
: null
|
||||||
const items: LootRoll['items'] = []
|
const items: LootRoll['items'] = []
|
||||||
|
|
||||||
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
||||||
@@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
awarded: Boolean(item),
|
awarded: Boolean(item),
|
||||||
duplicate: items.some((candidate) => candidate.duplicate),
|
duplicate: items.some((candidate) => candidate.duplicate),
|
||||||
quantityAfter: item?.quantityAfter ?? 0,
|
quantityAfter: item?.quantityAfter ?? 0,
|
||||||
|
petAwarded,
|
||||||
}
|
}
|
||||||
save.lootRolls[rollKey] = result
|
save.lootRolls[rollKey] = result
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return clone(result)
|
return clone(result)
|
||||||
},
|
},
|
||||||
|
async recordBossKill(encounterId, options) {
|
||||||
|
const save = requireStoredSave(store)
|
||||||
|
const profile = buildProfile(save)
|
||||||
|
const encounter = profile.dungeons
|
||||||
|
.flatMap((dungeon) => dungeon.encounters)
|
||||||
|
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
|
||||||
|
if (!encounter) throw new Error('Boss encounter not found.')
|
||||||
|
const petAwarded = recordBossKillInSave(save, encounter, {
|
||||||
|
petVariant: options?.petVariant ?? 'normal',
|
||||||
|
})
|
||||||
|
store.writeSave(save)
|
||||||
|
return {
|
||||||
|
profile: buildProfile(save),
|
||||||
|
petAwarded,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async recordPvpMatch(won) {
|
||||||
|
const save = requireStoredSave(store)
|
||||||
|
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
||||||
|
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
||||||
|
store.writeSave(save)
|
||||||
|
return buildProfile(save)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,6 +1714,10 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
|
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||||
|
recordBossKill: (encounterId, options) =>
|
||||||
|
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||||
|
recordPvpMatch: (won) =>
|
||||||
|
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGameMode(): GameMode {
|
export function getGameMode(): GameMode {
|
||||||
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
|||||||
characters[cid] = emptyCharacterData(cid)
|
characters[cid] = emptyCharacterData(cid)
|
||||||
}
|
}
|
||||||
const save: OfflineSave = {
|
const save: OfflineSave = {
|
||||||
version: 3,
|
version: 4,
|
||||||
characterName: name,
|
characterName: name,
|
||||||
activeClassId: 1,
|
activeClassId: 1,
|
||||||
completedDungeonParts: 0,
|
completedDungeonParts: 0,
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
characters,
|
characters,
|
||||||
lootRolls: {},
|
lootRolls: {},
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
}
|
}
|
||||||
writeOfflineSave(save)
|
writeOfflineSave(save)
|
||||||
writeMode('offline-local')
|
writeMode('offline-local')
|
||||||
|
|||||||
+42
-15
@@ -131,7 +131,7 @@ 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):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 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 = '.menu-screen, .content-screen, .dungeon-run-screen'
|
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
|
||||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
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
|
||||||
@@ -309,12 +309,20 @@ function rememberFocusableControl(element: HTMLElement) {
|
|||||||
function focusControl(element: HTMLElement) {
|
function focusControl(element: HTMLElement) {
|
||||||
rememberFocusableControl(element)
|
rememberFocusableControl(element)
|
||||||
element.focus({ preventScroll: true })
|
element.focus({ preventScroll: true })
|
||||||
|
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFocusableControl(candidates = focusableElements()) {
|
function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) {
|
||||||
|
if (
|
||||||
|
preferControllerFocus
|
||||||
|
&& lastControllerFocus
|
||||||
|
&& candidates.includes(lastControllerFocus)
|
||||||
|
&& isVisible(lastControllerFocus)
|
||||||
|
) {
|
||||||
|
return lastControllerFocus
|
||||||
|
}
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
if (active instanceof HTMLElement && candidates.includes(active)) {
|
if (active instanceof HTMLElement && candidates.includes(active)) {
|
||||||
rememberFocusableControl(active)
|
|
||||||
return active
|
return active
|
||||||
}
|
}
|
||||||
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
||||||
@@ -346,10 +354,10 @@ export function focusFirstControl() {
|
|||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveFocus(action: InputAction) {
|
function moveFocus(action: InputAction, preferControllerFocus = false) {
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
const current = currentFocusableControl(candidates)
|
const current = currentFocusableControl(candidates, preferControllerFocus)
|
||||||
if (!current) {
|
if (!current) {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
return
|
return
|
||||||
@@ -414,6 +422,18 @@ function hasUiOverlay() {
|
|||||||
).some(isVisible)
|
).some(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDedicatedGameNavigation() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
|
||||||
|
).some(isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||||
|
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||||
|
detail: { action, device },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const BUTTON_LABELS: Record<number, string> = {
|
const BUTTON_LABELS: Record<number, string> = {
|
||||||
0: 'A / Cross',
|
0: 'A / Cross',
|
||||||
1: 'B / Circle',
|
1: 'B / Circle',
|
||||||
@@ -584,10 +604,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
const uiOverlay = hasUiOverlay()
|
const uiOverlay = hasUiOverlay()
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
|
const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
|
||||||
|
const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
|
||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
|
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||||
|
dispatchGameAction(action, device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'toggleTouchLock') {
|
if (action === 'toggleTouchLock') {
|
||||||
setPreferences((current) => ({
|
setPreferences((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -601,22 +628,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}, 1400)
|
}, 1400)
|
||||||
} else if (action.startsWith('navigate')) {
|
} else if (action.startsWith('navigate')) {
|
||||||
if (uiOverlay || !combatActive) {
|
if (uiOverlay || !combatActive) {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
if (
|
if (
|
||||||
active instanceof HTMLSelectElement
|
active instanceof HTMLSelectElement
|
||||||
&& (action === 'navigateUp' || action === 'navigateDown')
|
&& (action === 'navigateUp' || action === 'navigateDown')
|
||||||
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
||||||
) {
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
moveFocus(action)
|
moveFocus(action, controllerUiInput)
|
||||||
}
|
}
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
if (isTextInput(active)) {
|
if (isTextInput(active)) {
|
||||||
setKeyboardInput(active)
|
setKeyboardInput(active)
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
} else if (active instanceof HTMLSelectElement) {
|
} else if (active instanceof HTMLSelectElement) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
||||||
if (select.showPicker) select.showPicker()
|
if (select.showPicker) select.showPicker()
|
||||||
else active.click()
|
else active.click()
|
||||||
@@ -625,6 +654,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
&& active.matches('button:not(:disabled), [role="button"]')
|
&& active.matches('button:not(:disabled), [role="button"]')
|
||||||
&& isVisible(active)
|
&& isVisible(active)
|
||||||
) {
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
active.click()
|
active.click()
|
||||||
} else {
|
} else {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
@@ -640,9 +670,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
dispatchGameAction(action, device)
|
||||||
detail: { action, device },
|
|
||||||
}))
|
|
||||||
}, [closeKeyboard])
|
}, [closeKeyboard])
|
||||||
|
|
||||||
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
||||||
@@ -742,6 +770,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const target = event.target
|
const target = event.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
||||||
|
if (document.documentElement.dataset.inputDevice !== 'controller') return
|
||||||
rememberFocusableControl(target)
|
rememberFocusableControl(target)
|
||||||
}
|
}
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
@@ -752,7 +781,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!(target instanceof Element)) return
|
if (!(target instanceof Element)) return
|
||||||
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
if (!control || !isVisible(control)) return
|
if (!control || !isVisible(control)) return
|
||||||
rememberFocusableControl(control)
|
if (event.pointerType !== 'touch') rememberFocusableControl(control)
|
||||||
}
|
}
|
||||||
document.addEventListener('focusin', onFocusIn)
|
document.addEventListener('focusin', onFocusIn)
|
||||||
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||||
@@ -1041,7 +1070,5 @@ export function dispatchExternalGameAction(
|
|||||||
action: InputAction,
|
action: InputAction,
|
||||||
device: InputDevice,
|
device: InputDevice,
|
||||||
) {
|
) {
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
dispatchGameAction(action, device)
|
||||||
detail: { action, device },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
|
||||||
|
|||||||
@@ -6905,5 +6905,11 @@
|
|||||||
"full_run": []
|
"full_run": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"hunterStats": {
|
||||||
|
"bossKills": {},
|
||||||
|
"bossPets": {},
|
||||||
|
"pvpMatchesPlayed": 0,
|
||||||
|
"pvpMatchesWon": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
|
|||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PetAward = {
|
||||||
|
encounterId: number
|
||||||
|
petName: string
|
||||||
|
quantity: number
|
||||||
|
duplicate: boolean
|
||||||
|
quantityAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
export type LootRoll = {
|
export type LootRoll = {
|
||||||
encounterId: number
|
encounterId: number
|
||||||
encounterName: string
|
encounterName: string
|
||||||
@@ -157,6 +165,7 @@ export type LootRoll = {
|
|||||||
awarded: boolean
|
awarded: boolean
|
||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
|
petAwarded?: PetAward | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dungeon = {
|
export type Dungeon = {
|
||||||
@@ -232,6 +241,12 @@ export type CharacterProfile = {
|
|||||||
craftingRecipes: CraftingRecipe[]
|
craftingRecipes: CraftingRecipe[]
|
||||||
gearUpgradePaths: GearUpgradePath[]
|
gearUpgradePaths: GearUpgradePath[]
|
||||||
dungeons: Dungeon[]
|
dungeons: Dungeon[]
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: Record<string, number>
|
||||||
|
bossPets: Record<string, number>
|
||||||
|
pvpMatchesPlayed: number
|
||||||
|
pvpMatchesWon: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Account = {
|
export type Account = {
|
||||||
@@ -280,6 +295,7 @@ export type DungeonReward = {
|
|||||||
glyph: string
|
glyph: string
|
||||||
}>
|
}>
|
||||||
bonusItem: BonusItem | null
|
bonusItem: BonusItem | null
|
||||||
|
petAwarded?: PetAward | null
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +381,20 @@ export async function completeRoguelike(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordBossKill(
|
||||||
|
encounterId: number,
|
||||||
|
options?: { petVariant?: 'normal' | 'purple' },
|
||||||
|
): Promise<{
|
||||||
|
profile: CharacterProfile
|
||||||
|
petAwarded: PetAward | null
|
||||||
|
}> {
|
||||||
|
return activeGameRepository().recordBossKill(encounterId, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordPvpMatch(won: boolean): Promise<CharacterProfile> {
|
||||||
|
return activeGameRepository().recordPvpMatch(won)
|
||||||
|
}
|
||||||
|
|
||||||
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
|
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
|
||||||
return activeGameRepository().allocateTalent(talentId)
|
return activeGameRepository().allocateTalent(talentId)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user