diff --git a/IWantToHeal-Thor-v1.1.11.apk b/IWantToHeal-Thor-v1.1.11.apk new file mode 100644 index 0000000..491af07 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.11.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 709ca0d..d3e3dcc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 89 - versionName "1.1.10" + 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. diff --git a/src/App.css b/src/App.css index 331039e..ff70e74 100644 --- a/src/App.css +++ b/src/App.css @@ -70,6 +70,11 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo 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; diff --git a/src/App.tsx b/src/App.tsx index 32638a0..7c14a04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,19 @@ 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 @@ -117,6 +130,7 @@ function App() { const [syncingCloud, setSyncingCloud] = useState(false) const [syncMessage, setSyncMessage] = useState('') const [homeSelectedIndex, setHomeSelectedIndex] = useState(0) + const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0) useEffect(() => { loadAuthSession() @@ -356,29 +370,194 @@ function App() { 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' || screen !== 'menu') return - if (action === 'confirm') { - openHomeMenuIndex(homeActiveIndex) + 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 (!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 (screen === 'dungeons' || screen === 'raids') { + if (action === 'back') { + setScreen('menu') + return } - 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 (action === 'confirm') { + openDungeonEntry(activeDungeonEntry()) + return + } + if (action.startsWith('navigate')) moveDungeonSelection(action) + } }) if (error) { @@ -563,14 +742,7 @@ function App() { data-controller-nav="skip" data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined} key={item.screen} - onClick={() => { - if (item.screen === 'pvp') { - setRoguelikeVariant('pvp') - setScreen('roguelike') - return - } - setScreen(item.screen) - }} + onClick={() => openHomeMenuIndex(homeIndex)} onPointerDown={() => setHomeSelectedIndex(homeIndex)} type="button" > @@ -787,7 +959,7 @@ function App() { )} {(screen === 'dungeons' || screen === 'raids') && ( -
+
@@ -798,7 +970,15 @@ function App() {

Selected Run

{activity.name}

- +

{activity.description}

@@ -820,16 +1000,22 @@ function App() { {activityPageCount > 1 ? (
{activityPageStart}-{activityPageEnd} of {activityOptions.length}
- {pagedActivityOptions.map((candidate) => { + {pagedActivityOptions.map((candidate, index) => { const difficulty = candidate.difficulties.find( (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, ) ?? candidate.difficulties[0] @@ -848,7 +1034,8 @@ function App() { const selected = candidate.id === activity.id return (
- {tierOptions.map((difficulty) => { + {tierOptions.map((difficulty, index) => { const locked = profile.character.level < difficulty.unlockLevel const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel return (