Compare commits

..
2 Commits
Author SHA1 Message Date
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
7 changed files with 527 additions and 70 deletions
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 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
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
+54 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}