Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e2c61d0a | ||
|
|
f00ad8655b |
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 88
|
||||
versionName "1.1.9"
|
||||
versionCode 90
|
||||
versionName "1.1.11"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+139
-1
@@ -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
|
||||
|
||||
|
||||
+54
-15
@@ -55,6 +55,26 @@ 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;
|
||||
}
|
||||
|
||||
.game-selected {
|
||||
outline: 3px solid #fff4a8 !important;
|
||||
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65) !important;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
align-items: end;
|
||||
border-bottom: 2px solid #34343d;
|
||||
@@ -1849,10 +1869,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 +1924,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 +1963,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 +1987,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 +2001,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;
|
||||
}
|
||||
|
||||
|
||||
+292
-38
@@ -19,7 +19,7 @@ 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 })))
|
||||
@@ -62,6 +62,20 @@ 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
|
||||
const DUNGEON_NAV_COLUMNS = 2
|
||||
|
||||
type DungeonNavEntry =
|
||||
| { kind: 'spacer'; disabled: true }
|
||||
| { kind: 'back'; disabled?: boolean }
|
||||
| { kind: 'pagePrev'; disabled?: boolean }
|
||||
| { kind: 'pageNext'; disabled?: boolean }
|
||||
| { kind: 'activity'; index: number; disabled?: boolean }
|
||||
| { kind: 'tier'; index: number; disabled?: boolean }
|
||||
| { kind: 'start'; disabled?: boolean }
|
||||
| { kind: 'marathon'; disabled?: boolean }
|
||||
| { kind: 'loot'; disabled?: boolean }
|
||||
| { kind: 'lootSort'; disabled?: boolean }
|
||||
|
||||
function activityInitials(name: string) {
|
||||
return name
|
||||
@@ -115,6 +129,8 @@ function App() {
|
||||
const [error, setError] = useState('')
|
||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState('')
|
||||
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthSession()
|
||||
@@ -336,6 +352,214 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
||||
const homeMenuOffset = canShowCloudSync ? 1 : 0
|
||||
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||
|
||||
function openHomeMenuIndex(index: number) {
|
||||
if (canShowCloudSync && index === 0) {
|
||||
if (!syncingCloud && cloudSync.dirty) void syncSaveNow()
|
||||
return
|
||||
}
|
||||
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||
if (!item) return
|
||||
if (item.screen === 'pvp') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
if (item.screen === 'dungeons' || item.screen === 'raids') {
|
||||
const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions
|
||||
setActivityPage(0)
|
||||
setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount(
|
||||
Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)),
|
||||
))
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}
|
||||
|
||||
function dungeonEntries() {
|
||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
||||
const entries: DungeonNavEntry[] = [
|
||||
{ kind: 'back' },
|
||||
]
|
||||
if (activityPageCount > 1) {
|
||||
entries.push(
|
||||
{ kind: 'pagePrev', disabled: currentActivityPage === 0 },
|
||||
{ kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 },
|
||||
)
|
||||
}
|
||||
if (entries.length % DUNGEON_NAV_COLUMNS !== 0) {
|
||||
entries.push({ kind: 'spacer', disabled: true })
|
||||
}
|
||||
pagedActivityOptions.forEach((candidate, index) => {
|
||||
const candidateDifficulty = difficulty
|
||||
? candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
: candidate.difficulties[0]
|
||||
entries.push({
|
||||
kind: 'activity',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
||||
})
|
||||
})
|
||||
tierOptions.forEach((difficultyOption, index) => {
|
||||
entries.push({
|
||||
kind: 'tier',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
||||
})
|
||||
})
|
||||
entries.push(
|
||||
{ kind: 'start', disabled: locked },
|
||||
{ kind: 'marathon', disabled: locked },
|
||||
{ kind: 'loot' },
|
||||
)
|
||||
if (showLoot) entries.push({ kind: 'lootSort' })
|
||||
return entries
|
||||
}
|
||||
|
||||
function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) {
|
||||
return Math.max(0, entries.findIndex((entry) => !entry.disabled))
|
||||
}
|
||||
|
||||
function firstActivityDungeonEntryIndexForPageCount(pageCount: number) {
|
||||
const entryCountBeforeActivities = pageCount > 1 ? 3 : 1
|
||||
return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0
|
||||
? entryCountBeforeActivities
|
||||
: entryCountBeforeActivities + 1
|
||||
}
|
||||
|
||||
function activeDungeonEntry(entries = dungeonEntries()) {
|
||||
if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) {
|
||||
return entries[dungeonSelectedIndex]
|
||||
}
|
||||
const firstEnabled = firstEnabledDungeonEntry(entries)
|
||||
return entries[firstEnabled] ?? entries[0]
|
||||
}
|
||||
|
||||
function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) {
|
||||
const active = activeDungeonEntry()
|
||||
return active?.kind === entry && ('index' in active ? active.index === index : index === undefined)
|
||||
}
|
||||
|
||||
function moveDungeonSelection(action: string) {
|
||||
const entries = dungeonEntries()
|
||||
if (entries.length === 0) return
|
||||
setDungeonSelectedIndex((current) => {
|
||||
const bounded = entries[current] && !entries[current].disabled
|
||||
? current
|
||||
: firstEnabledDungeonEntry(entries)
|
||||
const column = bounded % DUNGEON_NAV_COLUMNS
|
||||
const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1
|
||||
const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1
|
||||
if (action === 'navigateLeft' && column === 0) return bounded
|
||||
if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded
|
||||
for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) {
|
||||
if (!entries[next].disabled) return next
|
||||
}
|
||||
return bounded
|
||||
})
|
||||
}
|
||||
|
||||
function selectActivityByPageIndex(index: number) {
|
||||
const candidate = pagedActivityOptions[index]
|
||||
if (!candidate) return
|
||||
const difficulty = selectedDifficultyOption
|
||||
? candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
: candidate.difficulties[0]
|
||||
if (profile && profile.character.level < difficulty.unlockLevel) return
|
||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
}
|
||||
|
||||
function selectTierByIndex(index: number) {
|
||||
const difficulty = tierOptions[index]
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
setActivityPage(0)
|
||||
const nextActivity = activity.difficulties.some(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
? activity
|
||||
: activityOptions.find((option) =>
|
||||
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||
)
|
||||
if (!nextActivity) return
|
||||
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||
else setSelectedDungeonId(nextActivity.id)
|
||||
const nextDifficulty = nextActivity.difficulties.find(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||
}
|
||||
|
||||
function startSelectedRun(marathon: boolean) {
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
setSelectedMarathonMode(marathon)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
setScreen('combat')
|
||||
}
|
||||
|
||||
function openDungeonEntry(entry: DungeonNavEntry | undefined) {
|
||||
if (!entry || entry.disabled) return
|
||||
if (entry.kind === 'back') setScreen('menu')
|
||||
else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1))
|
||||
else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))
|
||||
else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index)
|
||||
else if (entry.kind === 'tier') selectTierByIndex(entry.index)
|
||||
else if (entry.kind === 'start') startSelectedRun(false)
|
||||
else if (entry.kind === 'marathon') startSelectedRun(true)
|
||||
else if (entry.kind === 'loot') setShowLoot((current) => !current)
|
||||
else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence')
|
||||
}
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device !== 'controller') return
|
||||
if (screen === 'menu') {
|
||||
if (action === 'confirm') {
|
||||
openHomeMenuIndex(homeActiveIndex)
|
||||
return
|
||||
}
|
||||
if (!action.startsWith('navigate')) return
|
||||
setHomeSelectedIndex((current) => {
|
||||
const bounded = Math.min(current, homeMenuEntryCount - 1)
|
||||
const column = bounded % HOME_MENU_COLUMNS
|
||||
if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded
|
||||
if (action === 'navigateRight') {
|
||||
const next = bounded + 1
|
||||
return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded
|
||||
}
|
||||
if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded
|
||||
const next = bounded + HOME_MENU_COLUMNS
|
||||
if (next < homeMenuEntryCount) return next
|
||||
return column > 0 ? homeMenuEntryCount - 1 : bounded
|
||||
})
|
||||
return
|
||||
}
|
||||
if (screen === 'dungeons' || screen === 'raids') {
|
||||
if (action === 'back') {
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openDungeonEntry(activeDungeonEntry())
|
||||
return
|
||||
}
|
||||
if (action.startsWith('navigate')) moveDungeonSelection(action)
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
@@ -455,9 +679,6 @@ 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' : ''}`}>
|
||||
{screen !== 'hunter-profile' && (
|
||||
@@ -484,10 +705,14 @@ function App() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
@@ -500,6 +725,7 @@ function App() {
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
data-controller-nav="skip"
|
||||
disabled={syncingCloud || !cloudSync.dirty}
|
||||
onClick={syncSaveNow}
|
||||
type="button"
|
||||
@@ -508,27 +734,26 @@ 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={() => openHomeMenuIndex(homeIndex)}
|
||||
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -734,7 +959,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{(screen === 'dungeons' || screen === 'raids') && (
|
||||
<section className="content-screen dungeon-run-screen">
|
||||
<section className="content-screen dungeon-run-screen" data-game-nav-active="true">
|
||||
<div className="dungeon-run-board">
|
||||
<div className="dungeon-run-main">
|
||||
<article className="run-summary-card dungeon-focus-card">
|
||||
@@ -745,7 +970,15 @@ function App() {
|
||||
<p className="eyebrow">Selected Run</p>
|
||||
<div className="run-title-row">
|
||||
<h2>{activity.name}</h2>
|
||||
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
|
||||
<button
|
||||
className={`back-button inline-back-button ${dungeonEntrySelected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={() => setScreen('menu')}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'back'))}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<p>{activity.description}</p>
|
||||
<div className="tag-row">
|
||||
@@ -767,16 +1000,22 @@ function App() {
|
||||
{activityPageCount > 1 ? (
|
||||
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||
<button
|
||||
className={dungeonEntrySelected('pagePrev') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
disabled={currentActivityPage === 0}
|
||||
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pagePrev'))}
|
||||
type="button"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
|
||||
<button
|
||||
className={dungeonEntrySelected('pageNext') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
disabled={currentActivityPage >= activityPageCount - 1}
|
||||
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pageNext'))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
@@ -787,7 +1026,7 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
<div className="activity-card-grid dungeon-choice-grid">
|
||||
{pagedActivityOptions.map((candidate) => {
|
||||
{pagedActivityOptions.map((candidate, index) => {
|
||||
const difficulty = candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
@@ -795,7 +1034,8 @@ function App() {
|
||||
const selected = candidate.id === activity.id
|
||||
return (
|
||||
<button
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={candidate.id}
|
||||
onClick={() => {
|
||||
@@ -803,6 +1043,7 @@ function App() {
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'activity' && 'index' in entry && entry.index === index))}
|
||||
type="button"
|
||||
>
|
||||
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
@@ -830,12 +1071,13 @@ function App() {
|
||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||
</div>
|
||||
<div className="tier-grid">
|
||||
{tierOptions.map((difficulty) => {
|
||||
{tierOptions.map((difficulty, index) => {
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||
return (
|
||||
<button
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={difficulty.id}
|
||||
onClick={() => {
|
||||
@@ -856,6 +1098,7 @@ function App() {
|
||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||
}
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'tier' && 'index' in entry && entry.index === index))}
|
||||
type="button"
|
||||
>
|
||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||
@@ -880,7 +1123,8 @@ function App() {
|
||||
</div>
|
||||
<div className="part-picker">
|
||||
<button
|
||||
className="primary-button selected-part"
|
||||
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(false)
|
||||
@@ -888,12 +1132,14 @@ function App() {
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'start'))}
|
||||
type="button"
|
||||
>
|
||||
Start Hunt
|
||||
</button>
|
||||
<button
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''}`}
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(true)
|
||||
@@ -901,6 +1147,7 @@ function App() {
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
|
||||
type="button"
|
||||
>
|
||||
Marathon
|
||||
@@ -930,8 +1177,10 @@ function App() {
|
||||
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
className={`text-button ${dungeonEntrySelected('loot') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={() => setShowLoot((current) => !current)}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'loot'))}
|
||||
type="button"
|
||||
>
|
||||
{showLoot ? 'Hide Loot' : 'View Loot'}
|
||||
@@ -940,9 +1189,14 @@ function App() {
|
||||
{showLoot && (
|
||||
<>
|
||||
<div className="loot-toolbar">
|
||||
<label>
|
||||
<label className={dungeonEntrySelected('lootSort') ? 'game-selected' : ''}>
|
||||
<span>Sort</span>
|
||||
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
|
||||
<select
|
||||
data-controller-nav="skip"
|
||||
onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'lootSort'))}
|
||||
value={lootSort}
|
||||
>
|
||||
<option value="sequence">Encounter order</option>
|
||||
<option value="boss">Boss name</option>
|
||||
</select>
|
||||
|
||||
+40
-14
@@ -312,10 +312,17 @@ function focusControl(element: HTMLElement) {
|
||||
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)) {
|
||||
@@ -347,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
|
||||
@@ -415,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',
|
||||
@@ -585,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,
|
||||
@@ -602,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()
|
||||
@@ -626,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()
|
||||
@@ -641,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) => {
|
||||
@@ -743,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) => {
|
||||
@@ -753,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 })
|
||||
@@ -1042,7 +1070,5 @@ export function dispatchExternalGameAction(
|
||||
action: InputAction,
|
||||
device: InputDevice,
|
||||
) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
dispatchGameAction(action, device)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user