Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00ad8655b |
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 88
|
versionCode 89
|
||||||
versionName "1.1.9"
|
versionName "1.1.10"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+139
-1
@@ -1,15 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +26,24 @@ GITEA_REPO = "i-want-to-heal"
|
|||||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||||
BRANCH = "main"
|
BRANCH = "main"
|
||||||
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
||||||
|
PLAYER_DATA_TABLES = [
|
||||||
|
"encounter_loot_roll_items",
|
||||||
|
"encounter_loot_rolls",
|
||||||
|
"dungeon_runs",
|
||||||
|
"character_pvp_stats",
|
||||||
|
"character_boss_stats",
|
||||||
|
"character_inventory",
|
||||||
|
"character_talents",
|
||||||
|
"character_ability_slots",
|
||||||
|
"action_encounter_loot_rolls",
|
||||||
|
"action_dungeon_runs",
|
||||||
|
"action_character_inventory",
|
||||||
|
"action_gear_items",
|
||||||
|
"action_characters",
|
||||||
|
"sessions",
|
||||||
|
"characters",
|
||||||
|
"accounts",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||||
@@ -30,6 +51,29 @@ def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> Non
|
|||||||
subprocess.run(args, cwd=cwd, env=env, check=True)
|
subprocess.run(args, cwd=cwd, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-player-data",
|
||||||
|
action="store_true",
|
||||||
|
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-db",
|
||||||
|
type=Path,
|
||||||
|
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes-reset",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.yes_reset and not args.reset_player_data:
|
||||||
|
parser.error("--yes-reset requires --reset-player-data")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def prompt_version() -> str:
|
def prompt_version() -> str:
|
||||||
current = GRADLE_FILE.read_text()
|
current = GRADLE_FILE.read_text()
|
||||||
match = re.search(r'versionName\s+"([^"]+)"', current)
|
match = re.search(r'versionName\s+"([^"]+)"', current)
|
||||||
@@ -151,13 +195,107 @@ def update_truenas() -> None:
|
|||||||
print("Then restart app in TrueNAS UI.")
|
print("Then restart app in TrueNAS UI.")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def default_reset_database_path() -> Path:
|
||||||
|
truenas_database = TRUENAS_PATH / "data" / "game.db"
|
||||||
|
if truenas_database.exists():
|
||||||
|
return truenas_database
|
||||||
|
return REPO_ROOT / "data" / "game.db"
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(database: sqlite3.Connection, table: str) -> bool:
|
||||||
|
return database.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
|
(table,),
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def count_rows(database: sqlite3.Connection, table: str) -> int:
|
||||||
|
if not table_exists(database, table):
|
||||||
|
return 0
|
||||||
|
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def backup_database(database_path: Path) -> Path:
|
||||||
|
backup_dir = database_path.parent / "backups"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
|
||||||
|
|
||||||
|
source = sqlite3.connect(database_path)
|
||||||
|
try:
|
||||||
|
destination = sqlite3.connect(backup_path)
|
||||||
|
try:
|
||||||
|
source.backup(destination)
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
||||||
|
database_path = database_path.expanduser().resolve()
|
||||||
|
if not database_path.exists():
|
||||||
|
raise SystemExit(f"Database not found: {database_path}")
|
||||||
|
|
||||||
|
if not assume_yes:
|
||||||
|
print(f"Reset target: {database_path}")
|
||||||
|
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
|
||||||
|
if confirmation != "RESET":
|
||||||
|
raise SystemExit("Player data reset cancelled.")
|
||||||
|
|
||||||
|
backup_path = backup_database(database_path)
|
||||||
|
database = sqlite3.connect(database_path)
|
||||||
|
try:
|
||||||
|
before = {
|
||||||
|
"accounts": count_rows(database, "accounts"),
|
||||||
|
"characters": count_rows(database, "characters"),
|
||||||
|
"action_characters": count_rows(database, "action_characters"),
|
||||||
|
"dungeon_runs": count_rows(database, "dungeon_runs"),
|
||||||
|
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
|
||||||
|
}
|
||||||
|
database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
database.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
for table in PLAYER_DATA_TABLES:
|
||||||
|
if table_exists(database, table):
|
||||||
|
database.execute(f'DELETE FROM "{table}"')
|
||||||
|
database.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
database.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
after_accounts = count_rows(database, "accounts")
|
||||||
|
after_characters = count_rows(database, "characters")
|
||||||
|
if after_accounts or after_characters:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Database backup: {backup_path}")
|
||||||
|
print(
|
||||||
|
"Player data reset: "
|
||||||
|
f"{before['accounts']} accounts, "
|
||||||
|
f"{before['characters']} characters, "
|
||||||
|
f"{before['action_characters']} action characters, "
|
||||||
|
f"{before['dungeon_runs']} dungeon runs, "
|
||||||
|
f"{before['encounter_loot_rolls']} loot rolls removed."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
os.chdir(REPO_ROOT)
|
os.chdir(REPO_ROOT)
|
||||||
version = prompt_version()
|
version = prompt_version()
|
||||||
apk = build_apk(version)
|
apk = build_apk(version)
|
||||||
commit_and_push(version)
|
commit_and_push(version)
|
||||||
create_gitea_release(version, apk)
|
create_gitea_release(version, apk)
|
||||||
update_truenas()
|
update_truenas()
|
||||||
|
if args.reset_player_data:
|
||||||
|
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
|
||||||
print("Done.")
|
print("Done.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+49
-15
@@ -55,6 +55,21 @@ textarea:focus-visible,
|
|||||||
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-input-device='controller'] button:focus,
|
||||||
|
html[data-input-device='controller'] input:focus,
|
||||||
|
html[data-input-device='controller'] select:focus,
|
||||||
|
html[data-input-device='controller'] textarea:focus,
|
||||||
|
html[data-input-device='controller'] [tabindex]:focus {
|
||||||
|
outline: 3px solid #fff4a8 !important;
|
||||||
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-input-device='controller'] [data-game-nav-active='true'] button:focus:not(.game-selected),
|
||||||
|
html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:focus:not(.game-selected) {
|
||||||
|
outline: 2px solid #42414c !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-heading {
|
.settings-heading {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
border-bottom: 2px solid #34343d;
|
border-bottom: 2px solid #34343d;
|
||||||
@@ -1849,10 +1864,13 @@ h2 {
|
|||||||
|
|
||||||
.main-menu-grid {
|
.main-menu-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 15px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
height: min(100%, 388px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 930px;
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roguelike-mode-grid {
|
.roguelike-mode-grid {
|
||||||
@@ -1901,24 +1919,33 @@ h2 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
min-height: 105px;
|
min-height: 0;
|
||||||
outline: 2px solid #42414c;
|
outline: 2px solid #42414c;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:first-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card:hover {
|
.menu-card:hover {
|
||||||
outline-color: var(--gold);
|
outline-color: var(--gold);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-card.game-selected {
|
||||||
|
outline: 3px solid #fff4a8;
|
||||||
|
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card.game-selected > span {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #13141a;
|
||||||
|
}
|
||||||
|
|
||||||
.cloud-sync-card {
|
.cloud-sync-card {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1931,6 +1958,13 @@ h2 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-sync-card .text-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cloud-sync-card .text-button:disabled {
|
.cloud-sync-card .text-button:disabled {
|
||||||
@@ -1948,10 +1982,10 @@ h2 {
|
|||||||
border: 2px solid var(--gold);
|
border: 2px solid var(--gold);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 58px;
|
flex: 0 0 48px;
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 19px;
|
font-size: 16px;
|
||||||
height: 58px;
|
height: 48px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1962,13 +1996,13 @@ h2 {
|
|||||||
|
|
||||||
.menu-card strong {
|
.menu-card strong {
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
margin-bottom: 9px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card small {
|
.menu-card small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-27
@@ -19,7 +19,7 @@ import {
|
|||||||
syncCloudSave,
|
syncCloudSave,
|
||||||
type GameMode,
|
type GameMode,
|
||||||
} from './gameRepository'
|
} from './gameRepository'
|
||||||
import { focusFirstControl } from './input.tsx'
|
import { focusFirstControl, useGameAction } from './input.tsx'
|
||||||
import { barFillStyle } from './components/barStyles'
|
import { barFillStyle } from './components/barStyles'
|
||||||
|
|
||||||
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
||||||
@@ -62,6 +62,7 @@ const MENU_ITEMS: Array<{
|
|||||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||||
const SHOW_LEADERBOARDS = false
|
const SHOW_LEADERBOARDS = false
|
||||||
const ACTIVITY_PAGE_SIZE = 4
|
const ACTIVITY_PAGE_SIZE = 4
|
||||||
|
const HOME_MENU_COLUMNS = 2
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -115,6 +116,7 @@ function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||||
const [syncMessage, setSyncMessage] = useState('')
|
const [syncMessage, setSyncMessage] = useState('')
|
||||||
|
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAuthSession()
|
loadAuthSession()
|
||||||
@@ -336,6 +338,49 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloudSync = getCloudSyncStatus()
|
||||||
|
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
||||||
|
const homeMenuOffset = canShowCloudSync ? 1 : 0
|
||||||
|
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||||
|
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||||
|
|
||||||
|
function openHomeMenuIndex(index: number) {
|
||||||
|
if (canShowCloudSync && index === 0) {
|
||||||
|
if (!syncingCloud && cloudSync.dirty) void syncSaveNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||||
|
if (!item) return
|
||||||
|
if (item.screen === 'pvp') {
|
||||||
|
setRoguelikeVariant('pvp')
|
||||||
|
setScreen('roguelike')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setScreen(item.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
useGameAction((action, device) => {
|
||||||
|
if (device !== 'controller' || screen !== 'menu') return
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openHomeMenuIndex(homeActiveIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!action.startsWith('navigate')) return
|
||||||
|
setHomeSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, homeMenuEntryCount - 1)
|
||||||
|
const column = bounded % HOME_MENU_COLUMNS
|
||||||
|
if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded
|
||||||
|
if (action === 'navigateRight') {
|
||||||
|
const next = bounded + 1
|
||||||
|
return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded
|
||||||
|
}
|
||||||
|
if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded
|
||||||
|
const next = bounded + HOME_MENU_COLUMNS
|
||||||
|
if (next < homeMenuEntryCount) return next
|
||||||
|
return column > 0 ? homeMenuEntryCount - 1 : bounded
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
@@ -455,9 +500,6 @@ function App() {
|
|||||||
const activity = selectedActivityOption ?? dungeon
|
const activity = selectedActivityOption ?? dungeon
|
||||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const cloudSync = getCloudSyncStatus()
|
|
||||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||||
{screen !== 'hunter-profile' && (
|
{screen !== 'hunter-profile' && (
|
||||||
@@ -484,10 +526,14 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="menu-screen">
|
<section className="menu-screen" data-game-nav-active="true">
|
||||||
<div className="main-menu-grid">
|
<div className="main-menu-grid">
|
||||||
{canShowCloudSync && (
|
{canShowCloudSync && (
|
||||||
<div className="menu-card cloud-sync-card">
|
<div
|
||||||
|
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
||||||
|
onPointerDown={() => setHomeSelectedIndex(0)}
|
||||||
|
>
|
||||||
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Cloud Save</strong>
|
<strong>Cloud Save</strong>
|
||||||
@@ -500,6 +546,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
|
data-controller-nav="skip"
|
||||||
disabled={syncingCloud || !cloudSync.dirty}
|
disabled={syncingCloud || !cloudSync.dirty}
|
||||||
onClick={syncSaveNow}
|
onClick={syncSaveNow}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -508,27 +555,33 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{MENU_ITEMS.map((item) => (
|
{MENU_ITEMS.map((item, index) => {
|
||||||
<button
|
const homeIndex = index + homeMenuOffset
|
||||||
className="menu-card"
|
return (
|
||||||
key={item.screen}
|
<button
|
||||||
onClick={() => {
|
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
|
||||||
if (item.screen === 'pvp') {
|
data-controller-nav="skip"
|
||||||
setRoguelikeVariant('pvp')
|
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
|
||||||
setScreen('roguelike')
|
key={item.screen}
|
||||||
return
|
onClick={() => {
|
||||||
}
|
if (item.screen === 'pvp') {
|
||||||
setScreen(item.screen)
|
setRoguelikeVariant('pvp')
|
||||||
}}
|
setScreen('roguelike')
|
||||||
type="button"
|
return
|
||||||
>
|
}
|
||||||
<span>{item.glyph}</span>
|
setScreen(item.screen)
|
||||||
<div>
|
}}
|
||||||
<strong>{item.label}</strong>
|
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
|
||||||
<small>{item.description}</small>
|
type="button"
|
||||||
</div>
|
>
|
||||||
</button>
|
<span>{item.glyph}</span>
|
||||||
))}
|
<div>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<small>{item.description}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+40
-14
@@ -312,10 +312,17 @@ function focusControl(element: HTMLElement) {
|
|||||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFocusableControl(candidates = focusableElements()) {
|
function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) {
|
||||||
|
if (
|
||||||
|
preferControllerFocus
|
||||||
|
&& lastControllerFocus
|
||||||
|
&& candidates.includes(lastControllerFocus)
|
||||||
|
&& isVisible(lastControllerFocus)
|
||||||
|
) {
|
||||||
|
return lastControllerFocus
|
||||||
|
}
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
if (active instanceof HTMLElement && candidates.includes(active)) {
|
if (active instanceof HTMLElement && candidates.includes(active)) {
|
||||||
rememberFocusableControl(active)
|
|
||||||
return active
|
return active
|
||||||
}
|
}
|
||||||
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
||||||
@@ -347,10 +354,10 @@ export function focusFirstControl() {
|
|||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveFocus(action: InputAction) {
|
function moveFocus(action: InputAction, preferControllerFocus = false) {
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
const current = currentFocusableControl(candidates)
|
const current = currentFocusableControl(candidates, preferControllerFocus)
|
||||||
if (!current) {
|
if (!current) {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
return
|
return
|
||||||
@@ -415,6 +422,18 @@ function hasUiOverlay() {
|
|||||||
).some(isVisible)
|
).some(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDedicatedGameNavigation() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
|
||||||
|
).some(isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||||
|
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||||
|
detail: { action, device },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const BUTTON_LABELS: Record<number, string> = {
|
const BUTTON_LABELS: Record<number, string> = {
|
||||||
0: 'A / Cross',
|
0: 'A / Cross',
|
||||||
1: 'B / Circle',
|
1: 'B / Circle',
|
||||||
@@ -585,10 +604,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
const uiOverlay = hasUiOverlay()
|
const uiOverlay = hasUiOverlay()
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
|
const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
|
||||||
|
const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
|
||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
|
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||||
|
dispatchGameAction(action, device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'toggleTouchLock') {
|
if (action === 'toggleTouchLock') {
|
||||||
setPreferences((current) => ({
|
setPreferences((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -602,22 +628,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}, 1400)
|
}, 1400)
|
||||||
} else if (action.startsWith('navigate')) {
|
} else if (action.startsWith('navigate')) {
|
||||||
if (uiOverlay || !combatActive) {
|
if (uiOverlay || !combatActive) {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
if (
|
if (
|
||||||
active instanceof HTMLSelectElement
|
active instanceof HTMLSelectElement
|
||||||
&& (action === 'navigateUp' || action === 'navigateDown')
|
&& (action === 'navigateUp' || action === 'navigateDown')
|
||||||
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
||||||
) {
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
moveFocus(action)
|
moveFocus(action, controllerUiInput)
|
||||||
}
|
}
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||||
if (isTextInput(active)) {
|
if (isTextInput(active)) {
|
||||||
setKeyboardInput(active)
|
setKeyboardInput(active)
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
} else if (active instanceof HTMLSelectElement) {
|
} else if (active instanceof HTMLSelectElement) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
||||||
if (select.showPicker) select.showPicker()
|
if (select.showPicker) select.showPicker()
|
||||||
else active.click()
|
else active.click()
|
||||||
@@ -626,6 +654,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
&& active.matches('button:not(:disabled), [role="button"]')
|
&& active.matches('button:not(:disabled), [role="button"]')
|
||||||
&& isVisible(active)
|
&& isVisible(active)
|
||||||
) {
|
) {
|
||||||
|
if (controllerUiInput) focusControl(active)
|
||||||
active.click()
|
active.click()
|
||||||
} else {
|
} else {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
@@ -641,9 +670,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
dispatchGameAction(action, device)
|
||||||
detail: { action, device },
|
|
||||||
}))
|
|
||||||
}, [closeKeyboard])
|
}, [closeKeyboard])
|
||||||
|
|
||||||
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
||||||
@@ -743,6 +770,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const target = event.target
|
const target = event.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
||||||
|
if (document.documentElement.dataset.inputDevice !== 'controller') return
|
||||||
rememberFocusableControl(target)
|
rememberFocusableControl(target)
|
||||||
}
|
}
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
@@ -753,7 +781,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!(target instanceof Element)) return
|
if (!(target instanceof Element)) return
|
||||||
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
if (!control || !isVisible(control)) return
|
if (!control || !isVisible(control)) return
|
||||||
rememberFocusableControl(control)
|
if (event.pointerType !== 'touch') rememberFocusableControl(control)
|
||||||
}
|
}
|
||||||
document.addEventListener('focusin', onFocusIn)
|
document.addEventListener('focusin', onFocusIn)
|
||||||
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||||
@@ -1042,7 +1070,5 @@ export function dispatchExternalGameAction(
|
|||||||
action: InputAction,
|
action: InputAction,
|
||||||
device: InputDevice,
|
device: InputDevice,
|
||||||
) {
|
) {
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
dispatchGameAction(action, device)
|
||||||
detail: { action, device },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user