Compare commits

..
3 Commits
Author SHA1 Message Date
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
24 changed files with 1564 additions and 106 deletions
+27 -1
View File
@@ -1,9 +1,35 @@
# Project Notes
## Target Hardware: AYN Thor
- 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 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.
- 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.
## 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.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 86
versionName "1.1.7"
versionCode 89
versionName "1.1.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+14
View File
@@ -254,6 +254,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
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 (
id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
+64
View File
@@ -16,6 +16,7 @@
"devDependencies": {
"@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -801,6 +802,22 @@
"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": {
"version": "1.0.3",
"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"
}
},
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+1
View File
@@ -34,6 +34,7 @@
"devDependencies": {
"@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+139 -1
View File
@@ -1,15 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
@@ -23,6 +26,24 @@ GITEA_REPO = "i-want-to-heal"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
PLAYER_DATA_TABLES = [
"encounter_loot_roll_items",
"encounter_loot_rolls",
"dungeon_runs",
"character_pvp_stats",
"character_boss_stats",
"character_inventory",
"character_talents",
"character_ability_slots",
"action_encounter_loot_rolls",
"action_dungeon_runs",
"action_character_inventory",
"action_gear_items",
"action_characters",
"sessions",
"characters",
"accounts",
]
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
@@ -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)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
parser.add_argument(
"--reset-player-data",
action="store_true",
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
)
parser.add_argument(
"--reset-db",
type=Path,
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
)
parser.add_argument(
"--yes-reset",
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
return args
def prompt_version() -> str:
current = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', current)
@@ -151,13 +195,107 @@ def update_truenas() -> None:
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)
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
create_gitea_release(version, apk)
update_truenas()
if args.reset_player_data:
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
print("Done.")
return 0
+6
View File
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
})),
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
dungeons: profile.dungeons.map(normalizeDungeon),
hunterStats: {
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
},
}
}
+77 -2
View File
@@ -815,6 +815,21 @@ export function getProfile(database, characterId, accountId) {
AND completed_parts >= 1
GROUP BY dungeon_id
`).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(
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) })),
},
})),
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
?? characters[0]?.name
?? '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 {
version: 3,
version: 4,
characterName,
activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1038,6 +1069,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
]),
),
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 (
!save
|| typeof save !== 'object'
|| Number(save.version) !== 3
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|| typeof save.characterName !== 'string'
|| !save.characters
|| typeof save.characters !== 'object'
@@ -1149,6 +1184,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?)
`)
const insertBossStats = database.prepare(`
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) {
const local = save.characters[classId]
@@ -1213,6 +1252,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
DELETE FROM character_inventory
WHERE character_id = ?
`).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 equippedSlots = new Set()
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) {
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(
+556 -15
View File
@@ -55,6 +55,21 @@ textarea:focus-visible,
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 {
align-items: end;
border-bottom: 2px solid #34343d;
@@ -1849,10 +1864,13 @@ h2 {
.main-menu-grid {
display: grid;
gap: 15px;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(0, 1fr));
height: min(100%, 388px);
margin: 0 auto;
max-width: 930px;
max-width: 900px;
width: 100%;
}
.roguelike-mode-grid {
@@ -1901,24 +1919,33 @@ h2 {
color: var(--ink);
cursor: pointer;
display: flex;
gap: 16px;
min-height: 105px;
gap: 12px;
min-height: 0;
outline: 2px solid #42414c;
padding: 16px;
padding: 12px;
text-align: left;
}
.menu-card:first-child {
grid-column: 1 / -1;
}
.menu-card:hover {
outline-color: var(--gold);
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 {
cursor: default;
display: grid;
gap: 10px;
grid-template-columns: auto minmax(0, 1fr);
justify-content: space-between;
}
@@ -1931,6 +1958,13 @@ h2 {
display: grid;
flex: 1;
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 {
@@ -1948,10 +1982,10 @@ h2 {
border: 2px solid var(--gold);
color: var(--gold);
display: flex;
flex: 0 0 58px;
flex: 0 0 48px;
font-family: var(--pixel-font);
font-size: 19px;
height: 58px;
font-size: 16px;
height: 48px;
justify-content: center;
}
@@ -1962,13 +1996,13 @@ h2 {
.menu-card strong {
font-family: var(--pixel-font);
font-size: 11px;
margin-bottom: 9px;
font-size: 10px;
margin-bottom: 5px;
}
.menu-card small {
color: var(--muted);
font-size: 18px;
font-size: 14px;
line-height: 1.05;
}
@@ -6977,6 +7011,95 @@ h2 {
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 {
padding-bottom: 10px;
}
@@ -7340,6 +7463,127 @@ h2 {
.ability-library {
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) {
@@ -9459,3 +9703,300 @@ h2 {
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
View File
@@ -19,12 +19,13 @@ import {
syncCloudSave,
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
import { focusFirstControl, useGameAction } from './input.tsx'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
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 PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
@@ -39,6 +40,7 @@ type Screen =
| 'pvp'
| 'customize'
| 'equipment'
| 'hunter-profile'
| 'talents'
| '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: '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: '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: '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 SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 4
const HOME_MENU_COLUMNS = 2
function activityInitials(name: string) {
return name
@@ -112,6 +116,7 @@ function App() {
const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
useEffect(() => {
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) {
return (
<main className="game-shell">
@@ -452,37 +500,40 @@ function App() {
const activity = selectedActivityOption ?? dungeon
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<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}`}
{screen !== 'hunter-profile' && (
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
</div>
</header>
<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>
</div>
</header>
)}
{screen === 'menu' && (
<section className="menu-screen">
<section className="menu-screen" data-game-nav-active="true">
<div className="main-menu-grid">
{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>
<div>
<strong>Cloud Save</strong>
@@ -495,6 +546,7 @@ function App() {
</div>
<button
className="text-button"
data-controller-nav="skip"
disabled={syncingCloud || !cloudSync.dirty}
onClick={syncSaveNow}
type="button"
@@ -503,27 +555,33 @@ function App() {
</button>
</div>
)}
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
key={item.screen}
onClick={() => {
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
setScreen(item.screen)
}}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
{MENU_ITEMS.map((item, index) => {
const homeIndex = index + homeMenuOffset
return (
<button
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
key={item.screen}
onClick={() => {
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
setScreen(item.screen)
}}
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
)
})}
</div>
</section>
)}
@@ -1091,6 +1149,13 @@ function App() {
/>
)}
{screen === 'hunter-profile' && (
<HunterProfileScreen
profile={profile}
onBack={() => setScreen('menu')}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
+34
View File
@@ -3,6 +3,7 @@ import {
completeDungeon,
completeRoguelike,
loadProfile,
recordBossKill,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
@@ -371,6 +372,7 @@ export function CombatScreen({
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<string>())
const recordedBossKillIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
@@ -516,6 +518,12 @@ export function CombatScreen({
: `${result.encounterName} dropped no components.`,
result.dropped ? 'loot' : 'system',
)
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
addLog(
@@ -527,6 +535,29 @@ export function CombatScreen({
[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 nextRoguelikeEncounters = roguelikeMode
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
@@ -558,6 +589,7 @@ export function CombatScreen({
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
recordedBossKillIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = setup.defaults.resourceSpent
@@ -991,6 +1023,7 @@ export function CombatScreen({
requestLootRoll(encounter.id, rollIndex)
}
}
recordRoguelikeBossKill(encounter)
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
setCombat({
@@ -1100,6 +1133,7 @@ export function CombatScreen({
maxResource,
gameClass.resourceName,
requestLootRoll,
recordRoguelikeBossKill,
profile.character.name,
setCombat,
startPart,
+240
View File
@@ -0,0 +1,240 @@
import { useMemo, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
return (
<section className="content-screen hunter-profile-screen">
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
</article>
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
key={boss.encounter.id}
onClick={() => setSelectedBossId(boss.encounter.id)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</strong>
</button>
)
})}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
+24 -1
View File
@@ -9,7 +9,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -362,6 +362,7 @@ export function PvPRoguelikeScreen({
const nextLogId = useRef(2)
const elapsedTicksRef = useRef(0)
const recordedRunRef = useRef(false)
const matchStatsRecordedRef = useRef(false)
const rewardClaimedRef = useRef(false)
const matchWinRewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
@@ -538,6 +539,12 @@ export function PvPRoguelikeScreen({
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
'loot',
)
}
})
.catch((reason: unknown) => {
setRewardError(
@@ -654,6 +661,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -711,6 +719,7 @@ export function PvPRoguelikeScreen({
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false
matchStatsRecordedRef.current = false
rewardClaimedRef.current = false
matchWinRewardClaimedRef.current = false
cpuDefeatedRef.current = false
@@ -1096,6 +1105,20 @@ export function PvPRoguelikeScreen({
})
}, [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(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
+18 -1
View File
@@ -7,7 +7,7 @@ import {
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike } from '../profile'
import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
@@ -391,6 +391,7 @@ export function PvpStadiumScreen({
const nextLogId = useRef(2)
const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>())
const matchStatsRecordedRef = useRef(false)
const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('')
@@ -505,6 +506,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -541,6 +543,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true
nextLogId.current = 2
awardedXpRef.current = new Set()
matchStatsRecordedRef.current = false
roundResolvedRef.current = false
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
@@ -882,6 +885,20 @@ export function PvpStadiumScreen({
return () => window.clearInterval(timer)
}, [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 nextRound = roundIndex + 1
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
+7 -6
View File
@@ -100,14 +100,15 @@ export function LootRollList({
return (
<div className="run-loot-rolls">
{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>
<span>
{roll.items.length > 0
? roll.items
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
.join(', ')
: 'No components dropped'}
{[
...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
...(roll.petAwarded
? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
: []),
].join(', ') || 'No components dropped'}
</span>
</div>
))}
+13 -4
View File
@@ -84,7 +84,7 @@ export type DualScreenCombatState = {
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
title: string
subtitle: string
summary?: string
@@ -150,6 +150,16 @@ function formatDualTime(seconds: number) {
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 }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -404,8 +414,7 @@ export function DualScreenBottomDisplay() {
}
useGameAction((action) => {
if (!state || state.opponentParty) return
if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return
if (!shouldRelayBottomAction(action, state)) return
sendAction(action)
})
@@ -458,7 +467,7 @@ export function DualScreenBottomDisplay() {
return (
<main
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">
<div>
+151 -10
View File
@@ -17,6 +17,7 @@ import type {
LootRoll,
Item,
EquipmentSlot,
PetAward,
} from './profile'
export type GameMode = 'online' | 'offline'
@@ -64,6 +65,14 @@ export interface GameRepository {
difficultyId: number,
runToken: string,
): Promise<LootRoll>
recordBossKill(
encounterId: number,
options?: { petVariant?: 'normal' | 'purple' },
): Promise<{
profile: CharacterProfile
petAwarded: PetAward | null
}>
recordPvpMatch(won: boolean): Promise<CharacterProfile>
}
type CharacterData = {
@@ -76,7 +85,7 @@ type CharacterData = {
}
type OfflineSave = {
version: 3
version: 4
characterName: string
activeClassId: number
completedDungeonParts: number
@@ -84,6 +93,10 @@ type OfflineSave = {
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
type OnlineCache = {
@@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null
function clone<T>(value: T): T {
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
}
}
return {
version: 3,
version: 4,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
@@ -185,14 +199,33 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
),
characters,
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({
...v2,
version: 3,
version: 4,
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
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) {
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) {
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 save = normalizeOfflineSave(raw)
if (!save) return null
if (raw.version !== 3) {
if (raw.version !== 4) {
localStorage.setItem(key, JSON.stringify(save))
}
return save
@@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null {
save,
dirty: Boolean(raw.dirty),
}
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
writeOnlineCache(cache)
}
return cache
@@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
...dungeon,
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_
}
@@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
inventory: clone(profile.inventory),
}
return {
version: 3,
version: 4,
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
@@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
),
characters,
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]
}
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(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
@@ -885,6 +975,10 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.upgradeItem(itemId),
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 {
@@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints,
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(
profile,
options?.lootSourceEncounterId,
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
petAwarded,
profile: updatedProfile,
}
},
@@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const petAwarded = encounter.isBoss
? recordBossKillInSave(save, encounter)
: null
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
@@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
petAwarded,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
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),
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 {
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
version: 4,
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
}
writeOfflineSave(save)
writeMode('offline-local')
+42 -15
View File
@@ -131,7 +131,7 @@ const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
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 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 GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
@@ -309,12 +309,20 @@ function rememberFocusableControl(element: HTMLElement) {
function focusControl(element: HTMLElement) {
rememberFocusableControl(element)
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
if (active instanceof HTMLElement && candidates.includes(active)) {
rememberFocusableControl(active)
return active
}
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
@@ -346,10 +354,10 @@ export function focusFirstControl() {
return first
}
function moveFocus(action: InputAction) {
function moveFocus(action: InputAction, preferControllerFocus = false) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = currentFocusableControl(candidates)
const current = currentFocusableControl(candidates, preferControllerFocus)
if (!current) {
focusFirstControl()
return
@@ -414,6 +422,18 @@ function hasUiOverlay() {
).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> = {
0: 'A / Cross',
1: 'B / Circle',
@@ -584,10 +604,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
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)
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
dispatchGameAction(action, device)
return
}
if (action === 'toggleTouchLock') {
setPreferences((current) => ({
...current,
@@ -601,22 +628,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
}, 1400)
} else if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) {
const active = currentFocusableControl()
const active = currentFocusableControl(focusableElements(), controllerUiInput)
if (
active instanceof HTMLSelectElement
&& (action === 'navigateUp' || action === 'navigateDown')
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
) {
if (controllerUiInput) focusControl(active)
return
}
moveFocus(action)
moveFocus(action, controllerUiInput)
}
} else if (action === 'confirm') {
const active = currentFocusableControl()
const active = currentFocusableControl(focusableElements(), controllerUiInput)
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLSelectElement) {
if (controllerUiInput) focusControl(active)
const select = active as HTMLSelectElement & { showPicker?: () => void }
if (select.showPicker) select.showPicker()
else active.click()
@@ -625,6 +654,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
&& active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active)
) {
if (controllerUiInput) focusControl(active)
active.click()
} else {
focusFirstControl()
@@ -640,9 +670,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
}
}
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
dispatchGameAction(action, device)
}, [closeKeyboard])
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
@@ -742,6 +770,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
if (document.documentElement.dataset.inputDevice !== 'controller') return
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
@@ -752,7 +781,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
if (!control || !isVisible(control)) return
rememberFocusableControl(control)
if (event.pointerType !== 'touch') rememberFocusableControl(control)
}
document.addEventListener('focusin', onFocusIn)
document.addEventListener('pointerdown', onPointerDown, { capture: true })
@@ -1041,7 +1070,5 @@ export function dispatchExternalGameAction(
action: InputAction,
device: InputDevice,
) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
dispatchGameAction(action, device)
}
+1 -1
View File
@@ -1 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
+7 -1
View File
@@ -6905,5 +6905,11 @@
"full_run": []
}
}
]
],
"hunterStats": {
"bossKills": {},
"bossPets": {},
"pvpMatchesPlayed": 0,
"pvpMatchesWon": 0
}
}
+30
View File
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
quantityAfter: number
}
export type PetAward = {
encounterId: number
petName: string
quantity: number
duplicate: boolean
quantityAfter: number
}
export type LootRoll = {
encounterId: number
encounterName: string
@@ -157,6 +165,7 @@ export type LootRoll = {
awarded: boolean
duplicate: boolean
quantityAfter: number
petAwarded?: PetAward | null
}
export type Dungeon = {
@@ -232,6 +241,12 @@ export type CharacterProfile = {
craftingRecipes: CraftingRecipe[]
gearUpgradePaths: GearUpgradePath[]
dungeons: Dungeon[]
hunterStats: {
bossKills: Record<string, number>
bossPets: Record<string, number>
pvpMatchesPlayed: number
pvpMatchesWon: number
}
}
export type Account = {
@@ -280,6 +295,7 @@ export type DungeonReward = {
glyph: string
}>
bonusItem: BonusItem | null
petAwarded?: PetAward | null
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> {
return activeGameRepository().allocateTalent(talentId)
}