Android build v1.1.12

This commit is contained in:
Warren H
2026-07-03 00:28:38 -04:00
parent c6e2c61d0a
commit b7cb572631
33 changed files with 1772 additions and 305 deletions
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 90
versionName "1.1.11"
versionCode 91
versionName "1.1.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+443 -26
View File
@@ -1042,6 +1042,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
.dual-controls-resource,
.dual-controls-targets,
.dual-controls-spells,
.setup-bottom-summary,
.setup-bottom-stats,
.dual-bottom-waiting > section {
background: var(--panel);
border: 3px solid #0c0d11;
@@ -1166,8 +1168,69 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
.dual-controls-progress {
color: var(--muted);
display: grid;
gap: 4px;
font-family: var(--pixel-font);
font-size: 7px;
text-align: right;
}
.setup-bottom-display {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.setup-bottom-summary {
align-items: start;
display: grid;
gap: 12px;
grid-template-columns: 82px minmax(0, 1fr);
min-height: 0;
padding: 12px;
}
.setup-bottom-summary .dungeon-art {
height: 78px;
width: 78px;
}
.setup-bottom-summary h2 {
font-size: 14px;
}
.setup-bottom-summary p:not(.eyebrow),
.setup-bottom-summary small {
color: var(--muted);
display: block;
font-size: 16px;
line-height: 1.15;
margin-top: 6px;
}
.setup-bottom-stats {
display: grid;
gap: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
padding: 10px;
}
.setup-bottom-stats > div {
background: #111217;
border: 2px solid #090a0d;
padding: 8px;
text-align: center;
}
.setup-bottom-stats dt {
color: var(--muted);
font-size: 13px;
}
.setup-bottom-stats dd {
color: var(--ink);
font-family: var(--pixel-font);
font-size: 9px;
margin: 4px 0 0;
}
.dual-controls-resource {
@@ -1256,7 +1319,9 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
.dual-controls-header,
.dual-controls-resource,
.dual-controls-targets,
.dual-controls-spells {
.dual-controls-spells,
.setup-bottom-summary,
.setup-bottom-stats {
border-width: 2px;
width: 100%;
}
@@ -1284,6 +1349,47 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
font-size: 6px;
}
.setup-bottom-summary {
gap: 8px;
grid-template-columns: 58px minmax(0, 1fr);
padding: 8px;
}
.setup-bottom-summary .dungeon-art {
font-size: 13px;
height: 54px;
width: 54px;
}
.setup-bottom-summary h2 {
font-size: 11px;
line-height: 1.2;
}
.setup-bottom-summary p:not(.eyebrow),
.setup-bottom-summary small {
font-size: 13px;
line-height: 1.1;
margin-top: 4px;
}
.setup-bottom-stats {
gap: 5px;
padding: 5px;
}
.setup-bottom-stats > div {
padding: 5px 4px;
}
.setup-bottom-stats dt {
font-size: 10px;
}
.setup-bottom-stats dd {
font-size: 7px;
}
.dual-controls-resource {
gap: 8px;
grid-template-columns: minmax(110px, 0.8fr) minmax(150px, 1.2fr);
@@ -1813,13 +1919,19 @@ h2 {
}
.dungeon-run-main {
gap: 14px;
gap: 10px;
}
.dungeon-setup-rail {
gap: 10px;
}
.dungeon-heading-actions {
align-items: center;
display: flex;
gap: 10px;
}
.message-panel {
align-items: center;
display: flex;
@@ -1891,11 +2003,31 @@ h2 {
margin-bottom: 16px;
}
.roguelike-screen {
gap: 12px;
}
.roguelike-screen .screen-heading {
flex: 0 0 auto;
}
.roguelike-screen > .roguelike-variant-row,
.roguelike-screen > .roguelike-option-panel,
.roguelike-screen > .pvp-queue-panel {
flex: 0 0 auto;
margin-bottom: 0;
margin-top: 0;
}
.roguelike-option-panel {
align-items: center;
background: #111319;
border: 2px solid #090a0d;
display: flex;
gap: 16px;
justify-content: space-between;
outline: 2px solid #41404a;
padding: 14px;
}
.roguelike-timing-row {
@@ -1917,6 +2049,14 @@ h2 {
white-space: nowrap;
}
.roguelike-screen .pvp-queue-panel {
min-height: 104px;
}
.roguelike-screen .text-button {
min-height: 38px;
}
.menu-card {
align-items: center;
background: var(--panel-light);
@@ -2077,6 +2217,17 @@ h2 {
outline-color: var(--gold);
}
.settings-back-button.game-selected,
.settings-tabs button.game-selected,
.dual-screen-actions button.game-selected,
.controller-preferences button.game-selected,
.binding-tabs button.game-selected,
.binding-list button.game-selected,
.settings-footer .text-button.game-selected,
.binding-capture button.game-selected {
color: var(--ink);
}
.settings-screen,
.equipment-screen,
.talent-screen,
@@ -2701,7 +2852,7 @@ h2 {
}
.dungeon-run-board {
grid-template-columns: minmax(0, 1fr) minmax(214px, 0.45fr);
grid-template-columns: minmax(0, 1fr) 292px;
}
.dungeon-run-screen .run-setup-panel,
@@ -2766,20 +2917,20 @@ h2 {
}
.dungeon-choice-grid {
grid-auto-rows: minmax(52px, max-content);
grid-auto-rows: minmax(72px, max-content);
}
.dungeon-choice-grid .activity-card {
gap: 4px 6px;
grid-template-columns: 34px minmax(0, 1fr);
min-height: 52px;
padding: 5px;
gap: 5px 7px;
grid-template-columns: 40px minmax(0, 1fr);
min-height: 72px;
padding: 6px;
}
.dungeon-choice-grid .activity-card .dungeon-art {
font-size: 10px;
height: 32px;
width: 32px;
height: 38px;
width: 38px;
}
.dungeon-choice-grid .activity-card small,
@@ -2790,7 +2941,7 @@ h2 {
}
.tier-setup-panel .tier-grid button {
min-height: 32px;
min-height: 34px;
padding: 4px 5px;
}
@@ -2800,7 +2951,7 @@ h2 {
.part-setup-panel .primary-button {
font-size: 7px;
min-height: 32px;
min-height: 36px;
padding: 4px 5px;
}
@@ -2835,11 +2986,46 @@ h2 {
font-size: 12px;
padding: 5px 7px;
}
.dungeon-heading-actions {
gap: 6px;
}
.roguelike-screen {
gap: 7px;
padding: 8px;
}
.roguelike-screen .screen-heading {
padding-bottom: 6px;
}
.roguelike-screen .roguelike-option-panel {
padding: 8px;
}
.roguelike-screen .pvp-queue-panel {
min-height: 76px;
}
.roguelike-screen .menu-card > span {
flex-basis: 38px;
height: 38px;
}
.roguelike-screen .menu-card small {
font-size: 13px;
}
.roguelike-screen .text-button {
min-height: 30px;
padding: 5px 8px;
}
}
@media (max-width: 700px) and (max-height: 620px) {
.dungeon-run-board {
grid-template-columns: minmax(0, 1fr) 214px;
grid-template-columns: minmax(0, 1fr) 292px;
overflow: hidden;
}
@@ -5170,6 +5356,15 @@ h2 {
outline-color: var(--gold);
}
.customize-tabs button.game-selected,
.class-picker > button.game-selected,
.ability-slots button.game-selected,
.ability-library-actions button.game-selected,
.ability-library > button.game-selected,
.save-row .primary-button.game-selected {
color: var(--ink);
}
.customize-tab-back {
display: none;
}
@@ -5265,6 +5460,24 @@ h2 {
margin-top: 20px;
}
.ability-library-actions {
align-items: center;
display: flex;
gap: 8px;
}
.ability-library-pager {
align-items: center;
display: flex;
gap: 6px;
}
.ability-library-pager > span {
color: var(--muted);
font-family: var(--pixel-font);
font-size: 8px;
}
.loadout-heading > span,
.save-row > span {
color: var(--muted);
@@ -6322,6 +6535,15 @@ h2 {
padding: 8px;
}
.pvp-match-queue-panel {
gap: 14px;
}
.pvp-match-queue-panel .secondary-result-button {
margin: 0 auto;
max-width: 220px;
}
.pvp-screen-tools {
align-items: center;
display: flex;
@@ -6596,10 +6818,77 @@ h2 {
box-shadow: inset 0 0 0 2px #6e5727;
}
.pvp-upgrade-dialog .upgrade-choice-grid button.game-selected,
.pvp-upgrade-dialog .secondary-result-button.game-selected,
.pause-screen button.game-selected,
.pvp-match-queue-panel button.game-selected {
box-shadow: 0 0 0 3px rgba(245, 196, 83, 0.45), inset 0 0 0 2px var(--gold);
}
.pvp-upgrade-dialog > .secondary-result-button {
display: block;
margin-left: auto;
margin-top: 10px;
width: fit-content;
}
.pvp-upgrade-dialog button:focus {
outline-color: var(--gold);
}
@media (max-height: 620px) {
.pvp-upgrade-dialog {
max-height: calc(100dvh - 32px);
overflow: hidden;
padding: 10px !important;
}
.pvp-upgrade-header {
margin-bottom: 8px;
}
.pvp-upgrade-header h2 {
font-size: 14px;
}
.pvp-upgrade-header > strong {
font-size: 12px;
}
.pvp-upgrade-dialog .pvp-choice-columns {
gap: 8px;
}
.pvp-choice-columns > div > strong {
margin-bottom: 6px;
}
.pvp-choice-columns .upgrade-choice-grid {
gap: 7px;
}
.pvp-choice-columns .upgrade-choice-grid button {
min-height: 54px;
padding: 6px;
}
.pvp-upgrade-dialog .upgrade-choice-grid strong {
font-size: 8px;
}
.pvp-upgrade-dialog .upgrade-choice-grid small {
font-size: 10px;
line-height: 1.1;
}
.pvp-upgrade-dialog .secondary-result-button {
margin-left: auto;
margin-top: 8px;
min-height: 34px;
padding: 6px 10px;
}
}
.stadium-screen {
min-height: 100dvh;
}
@@ -6775,6 +7064,13 @@ h2 {
margin-top: 12px;
}
.result-screen .pvp-upgrade-dialog > .secondary-result-button {
display: block;
margin-left: auto;
margin-top: 10px;
width: fit-content;
}
.pause-screen button:focus-visible {
outline: 3px solid #fff4a8 !important;
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
@@ -7118,6 +7414,7 @@ h2 {
}
.settings-screen {
gap: 8px;
padding: 10px;
}
@@ -7145,21 +7442,26 @@ h2 {
scrollbar-width: thin;
}
.settings-screen .settings-tab-panel {
overflow: hidden;
}
.dual-screen-settings,
.controller-preferences {
gap: 10px;
margin: 8px 0 0;
padding: 10px;
gap: 16px;
margin: 10px 0 0;
padding: 16px;
}
.dual-screen-settings.settings-tab-panel,
.controller-preferences.settings-tab-panel {
flex: 0 0 auto;
flex: 1 1 auto;
}
.dual-screen-settings {
align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(214px, auto);
align-content: start;
grid-template-columns: minmax(0, 1fr) minmax(238px, auto);
}
.dual-screen-settings h2,
@@ -7181,20 +7483,20 @@ h2 {
.dual-screen-actions {
align-content: start;
align-self: start;
gap: 7px;
min-width: 214px;
gap: 10px;
min-width: 238px;
}
.dual-screen-actions button,
.controller-preferences button {
min-height: 32px;
padding: 5px 8px;
min-height: 46px;
padding: 8px 12px;
}
.controller-preferences {
align-items: stretch;
align-content: start;
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(158px, auto));
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(176px, auto));
}
.controller-preferences h3 {
@@ -7206,9 +7508,9 @@ h2 {
}
.controller-icon-options {
gap: 6px;
grid-template-columns: minmax(112px, 1fr) repeat(3, minmax(104px, auto));
padding: 6px;
gap: 8px;
grid-template-columns: minmax(130px, 1fr) repeat(3, minmax(120px, auto));
padding: 10px;
}
.controller-icon-options > span {
@@ -7218,6 +7520,7 @@ h2 {
.controller-icon-options button {
font-size: 6px;
gap: 5px;
min-height: 38px;
}
.controller-style-preview .controller-face-icon {
@@ -7253,6 +7556,7 @@ h2 {
.binding-list {
gap: 4px;
padding-right: 4px;
overflow-y: auto;
}
.binding-list button {
@@ -9402,6 +9706,25 @@ h2 {
margin-top: 6px;
}
.workshop-shell .ability-library-heading {
gap: 8px;
}
.workshop-shell .ability-library-actions,
.workshop-shell .ability-library-pager {
gap: 5px;
}
.workshop-shell .ability-library-actions .text-button {
font-size: 7px;
min-height: 28px;
padding: 4px 7px;
}
.workshop-shell .ability-library-pager > span {
font-size: 7px;
}
.workshop-shell .ability-slots {
gap: 5px;
margin-top: 5px;
@@ -9713,6 +10036,15 @@ h2 {
gap: 14px;
}
.hunter-profile-screen .back-button.game-selected,
.hunter-profile-screen .equipment-tab.game-selected,
.hunter-stat-tile.game-selected,
.collection-boss-button.game-selected,
.collection-drop-row.game-selected {
color: var(--ink);
outline-color: #fff4a8;
}
.hunter-profile-heading {
align-items: center;
}
@@ -9755,6 +10087,7 @@ h2 {
.collection-log-layout {
display: grid;
flex: 1;
grid-template-columns: 250px minmax(0, 1fr);
gap: 14px;
min-height: 0;
@@ -9772,6 +10105,8 @@ h2 {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
min-height: 0;
overflow: auto;
padding: 2px;
}
.collection-boss-list {
@@ -9833,6 +10168,7 @@ h2 {
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(229, 185, 95, 0.2);
border-radius: 8px;
@@ -9910,6 +10246,10 @@ h2 {
opacity: 0.46;
}
.collection-drop-row.missing.game-selected {
opacity: 1;
}
.collection-drop-row.missing > span {
background: rgba(238, 232, 210, 0.08);
color: rgba(238, 232, 210, 0.54);
@@ -10005,3 +10345,80 @@ h2 {
max-height: 190px;
}
}
@media (max-width: 1000px) and (max-height: 620px) {
.hunter-profile-screen {
gap: 10px;
margin-top: 0;
padding: 12px;
}
.hunter-profile-heading {
padding-bottom: 10px;
}
.hunter-profile-tabs {
gap: 6px;
margin-top: 0;
}
.hunter-profile-screen .equipment-tab,
.hunter-profile-screen .back-button {
min-height: 34px;
padding: 6px 10px;
}
.hunter-stat-grid {
gap: 8px;
}
.hunter-stat-tile {
min-height: 92px;
padding: 10px;
}
.hunter-stat-tile strong {
font-size: 1.05rem;
}
.collection-log-layout {
gap: 10px;
grid-template-columns: 198px minmax(0, 1fr);
}
.collection-boss-list {
gap: 6px;
max-height: none;
}
.collection-boss-button {
grid-template-columns: 32px minmax(0, 1fr);
min-height: 46px;
padding: 6px;
}
.collection-boss-button > span {
width: 32px;
height: 32px;
font-size: 0.68rem;
}
.collection-boss-detail {
padding: 8px;
}
.collection-drop-list {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.collection-drop-row {
grid-template-rows: 20px minmax(24px, 1fr) auto auto;
min-height: 72px;
padding: 5px;
}
.collection-drop-row > span {
width: 20px;
height: 20px;
}
}
+277 -165
View File
@@ -20,6 +20,11 @@ import {
type GameMode,
} from './gameRepository'
import { focusFirstControl, useGameAction } from './input.tsx'
import {
useDualScreen,
useDualScreenSetupPublisher,
type DualScreenSetupState,
} from './dualScreen'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
@@ -77,6 +82,23 @@ type DungeonNavEntry =
| { kind: 'loot'; disabled?: boolean }
| { kind: 'lootSort'; disabled?: boolean }
type RoguelikeNavEntry =
| { kind: 'back' }
| { kind: 'variantPve' }
| { kind: 'variantPvp' }
| { kind: 'pveDungeon' }
| { kind: 'pveRaid' }
| { kind: 'pveStart' }
| { kind: 'pvpDungeon' }
| { kind: 'pvpRaid' }
| { kind: 'pvpStadium' }
| { kind: 'pvpStart' }
type RoguelikeNavPosition = {
row: number
column: number
}
function activityInitials(name: string) {
return name
.split(/\s+/)
@@ -97,11 +119,10 @@ function ScreenLoading() {
)
}
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeVariant = 'pve' | 'pvp'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
function App() {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null)
const [profile, setProfile] = useState<CharacterProfile | null>(null)
@@ -116,8 +137,6 @@ function App() {
const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
@@ -131,6 +150,7 @@ function App() {
const [syncMessage, setSyncMessage] = useState('')
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1)
useEffect(() => {
loadAuthSession()
@@ -367,9 +387,13 @@ function App() {
if (!item) return
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setRoguelikeSelectedIndex(2)
setScreen('roguelike')
return
}
if (item.screen === 'roguelike') {
setRoguelikeSelectedIndex(1)
}
if (item.screen === 'dungeons' || item.screen === 'raids') {
const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions
setActivityPage(0)
@@ -407,6 +431,10 @@ function App() {
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
})
})
entries.push(
{ kind: 'start', disabled: locked },
{ kind: 'marathon', disabled: locked },
)
tierOptions.forEach((difficultyOption, index) => {
entries.push({
kind: 'tier',
@@ -414,11 +442,7 @@ function App() {
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
})
})
entries.push(
{ kind: 'start', disabled: locked },
{ kind: 'marathon', disabled: locked },
{ kind: 'loot' },
)
entries.push({ kind: 'loot' })
if (showLoot) entries.push({ kind: 'lootSort' })
return entries
}
@@ -524,6 +548,109 @@ function App() {
else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence')
}
function startPveRoguelike() {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedMarathonMode(false)
setScreen('combat')
}
function roguelikeEntries() {
const entries: RoguelikeNavEntry[] = [
{ kind: 'back' },
{ kind: 'variantPve' },
{ kind: 'variantPvp' },
]
if (roguelikeVariant === 'pve') {
entries.push(
{ kind: 'pveDungeon' },
{ kind: 'pveRaid' },
{ kind: 'pveStart' },
)
} else {
entries.push(
{ kind: 'pvpDungeon' },
{ kind: 'pvpRaid' },
{ kind: 'pvpStadium' },
{ kind: 'pvpStart' },
)
}
return entries
}
function activeRoguelikeEntry(entries = roguelikeEntries()) {
return entries[Math.min(roguelikeSelectedIndex, entries.length - 1)] ?? entries[0]
}
function roguelikeEntrySelected(kind: RoguelikeNavEntry['kind']) {
return activeRoguelikeEntry()?.kind === kind
}
function roguelikeEntryPosition(entry: RoguelikeNavEntry): RoguelikeNavPosition {
if (entry.kind === 'back') return { row: 0, column: 0 }
if (entry.kind === 'variantPve') return { row: 1, column: 0 }
if (entry.kind === 'variantPvp') return { row: 1, column: 1 }
if (entry.kind === 'pveDungeon' || entry.kind === 'pvpDungeon') return { row: 2, column: 0 }
if (entry.kind === 'pveRaid' || entry.kind === 'pvpRaid') return { row: 2, column: 1 }
if (entry.kind === 'pvpStart') return { row: 3, column: 1 }
return { row: 3, column: 0 }
}
function moveRoguelikeSelection(action: string) {
const entries = roguelikeEntries()
setRoguelikeSelectedIndex((current) => {
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
if (!active) return bounded
const activePosition = roguelikeEntryPosition(active)
const candidates = entries
.map((entry, index) => ({ entry, index, position: roguelikeEntryPosition(entry) }))
.filter(({ index, position }) => {
if (index === bounded) return false
if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column
if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column
if (action === 'navigateUp') return position.row < activePosition.row
return position.row > activePosition.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.position.row - activePosition.row)
+ Math.abs(a.position.column - activePosition.column)
const bPrimary = Math.abs(b.position.row - activePosition.row)
+ Math.abs(b.position.column - activePosition.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.position.column - activePosition.column)
: Math.abs(a.position.row - activePosition.row)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.position.column - activePosition.column)
: Math.abs(b.position.row - activePosition.row)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openRoguelikeEntry(entry: RoguelikeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') setScreen('menu')
else if (entry.kind === 'variantPve') setRoguelikeVariant('pve')
else if (entry.kind === 'variantPvp') setRoguelikeVariant('pvp')
else if (entry.kind === 'pveDungeon') setRoguelikeKind('dungeon')
else if (entry.kind === 'pveRaid') setRoguelikeKind('raid')
else if (entry.kind === 'pveStart') startPveRoguelike()
else if (entry.kind === 'pvpDungeon') setPvpContentType('dungeon')
else if (entry.kind === 'pvpRaid') setPvpContentType('raid')
else if (entry.kind === 'pvpStadium') setPvpContentType('stadium')
else if (entry.kind === 'pvpStart') setScreen('pvp')
}
useGameAction((action, device) => {
if (device !== 'controller') return
if (screen === 'menu') {
@@ -558,8 +685,48 @@ function App() {
}
if (action.startsWith('navigate')) moveDungeonSelection(action)
}
if (screen === 'roguelike') {
if (action === 'back') {
setScreen('menu')
return
}
if (action === 'confirm') {
openRoguelikeEntry(activeRoguelikeEntry())
return
}
if (action.startsWith('navigate')) moveRoguelikeSelection(action)
}
})
const setupDualScreenState = useMemo<DualScreenSetupState | null>(() => {
if (
!(screen === 'dungeons' || screen === 'raids')
|| !profile
|| !selectedActivityOption
|| !selectedDifficultyOption
) return null
const locked = profile.character.level < selectedDifficultyOption.unlockLevel
return {
contentType: selectedActivityOption.contentType,
title: selectedActivityOption.name,
subtitle: `${selectedActivityOption.locationName} | Level ${selectedActivityOption.recommendedLevel} | ${selectedActivityOption.partySize} Players`,
description: selectedActivityOption.description,
initials: activityInitials(selectedActivityOption.name),
difficultyName: selectedDifficultyOption.name,
itemLevel: selectedDifficultyOption.droppedItemLevel,
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined,
stats: {
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
xp: `${selectedDifficultyOption.experienceMultiplier.toFixed(1)}x`,
loot: `iLvl ${selectedDifficultyOption.droppedItemLevel}`,
},
}
}, [profile, screen, selectedActivityOption, selectedDifficultyOption])
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
if (error) {
return (
<main className="game-shell">
@@ -604,8 +771,8 @@ function App() {
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? 'encounter' : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? 'ability' : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={1}
onExit={() => {
@@ -659,19 +826,6 @@ function App() {
? 100
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
const dungeon = selectedDungeonOption ?? profile.dungeons[0]!
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedMarathonMode(false)
setScreen('combat')
}
const activityPageStart = activityOptions.length === 0
? 0
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
@@ -759,23 +913,30 @@ function App() {
)}
{screen === 'roguelike' && (
<section className="content-screen">
<section className="content-screen roguelike-screen" data-game-nav-active="true">
<ScreenHeading
backClassName={roguelikeEntrySelected('back') ? 'game-selected' : ''}
backControllerSkip
eyebrow="Endless Draft"
title="Roguelike"
onBack={() => setScreen('menu')}
onBackPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'back'))}
/>
<div className="roguelike-variant-row">
<button
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''}`}
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''} ${roguelikeEntrySelected('variantPve') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeVariant('pve')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'variantPve'))}
type="button"
>
PvE
</button>
<button
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''}`}
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''} ${roguelikeEntrySelected('variantPvp') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeVariant('pvp')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'variantPvp'))}
type="button"
>
PvP
@@ -790,65 +951,25 @@ function App() {
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''} ${roguelikeEntrySelected('pveDungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeKind('dungeon')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveDungeon'))}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''} ${roguelikeEntrySelected('pveRaid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setRoguelikeKind('raid')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveRaid'))}
type="button"
>
Raid
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
@@ -860,8 +981,10 @@ function App() {
</small>
</div>
<button
className="text-button"
className={`text-button ${roguelikeEntrySelected('pveStart') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={startPveRoguelike}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pveStart'))}
type="button"
>
Start Run
@@ -878,22 +1001,28 @@ function App() {
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''}`}
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''} ${roguelikeEntrySelected('pvpDungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('dungeon')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpDungeon'))}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''}`}
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''} ${roguelikeEntrySelected('pvpRaid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('raid')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpRaid'))}
type="button"
>
Raid
</button>
<button
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''} ${roguelikeEntrySelected('pvpStadium') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setPvpContentType('stadium')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpStadium'))}
type="button"
>
Stadium
@@ -913,8 +1042,10 @@ function App() {
</small>
</div>
<button
className="text-button"
className={`text-button ${roguelikeEntrySelected('pvpStart') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => setScreen('pvp')}
onPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'pvpStart'))}
type="button"
>
Start Match
@@ -962,14 +1093,13 @@ function App() {
<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">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
<div className="run-summary-copy">
<p className="eyebrow">Selected Run</p>
<div className="run-title-row">
<h2>{activity.name}</h2>
<div className="dungeon-heading-actions">
<button
className={`back-button inline-back-button ${dungeonEntrySelected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
@@ -979,24 +1109,6 @@ function App() {
>
Back
</button>
</div>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
</article>
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
{activityPageCount > 1 ? (
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<button
@@ -1025,6 +1137,7 @@ function App() {
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div>
</div>
<div className="activity-card-grid dungeon-choice-grid">
{pagedActivityOptions.map((candidate, index) => {
const difficulty = candidate.difficulties.find(
@@ -1062,6 +1175,52 @@ function App() {
</div>
<aside className="dungeon-setup-rail">
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Pick a hunt or marathon.'}
</small>
</div>
<div className="part-picker">
<button
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
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' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
type="button"
>
Marathon
</button>
</div>
</section>
<section className="run-setup-panel tier-setup-panel">
<div className="run-setup-heading">
<div>
@@ -1109,67 +1268,6 @@ function App() {
</div>
</section>
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Marathon keeps health and mana between boss kills.'}
</small>
</div>
<div className="part-picker">
<button
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
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' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
type="button"
>
Marathon
</button>
</div>
</section>
<div className="difficulty-section compact-difficulty-section">
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading">
<div>
@@ -1367,13 +1465,19 @@ function App() {
}
function ScreenHeading({
backClassName = '',
backControllerSkip = false,
eyebrow,
title,
onBack,
onBackPointerDown,
}: {
backClassName?: string
backControllerSkip?: boolean
eyebrow: string
title: string
onBack: () => void
onBackPointerDown?: () => void
}) {
return (
<div className="screen-heading">
@@ -1381,7 +1485,15 @@ function ScreenHeading({
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${backClassName}`}
data-controller-nav={backControllerSkip ? 'skip' : undefined}
onClick={onBack}
onPointerDown={onBackPointerDown}
type="button"
>
Back
</button>
</div>
)
}
+263 -20
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,20 +15,121 @@ type Props = {
onSaved: (profile: CharacterProfile) => void
}
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
type CustomizeNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
| { kind: 'class'; key: string; row: number; column: number; classId: number }
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
| { kind: 'clear'; key: string; row: number; column: number }
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
| { kind: 'save'; key: string; row: number; column: number }
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
]
const CLASS_PICKER_COLUMN = 0
const CLASS_CONTENT_COLUMN = 2
const ABILITY_LIBRARY_COLUMNS = 5
const ABILITY_LIBRARY_PAGE_SIZE = 10
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
const [abilityPage, setAbilityPage] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
const visibleAbilities = useMemo(
() => gameClass.spells.slice(
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
),
[currentAbilityPage, gameClass.spells],
)
const classNavActive = activeTab === 'class'
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
const entries: CustomizeNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...CUSTOMIZE_TABS.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab.key}`,
row: 0,
column: index + 1,
tab: tab.key,
})),
]
if (activeTab !== 'class') return entries
entries.push(
...profile.classes.map((candidate, index) => ({
kind: 'class' as const,
key: `class:${candidate.id}`,
row: index + 1,
column: CLASS_PICKER_COLUMN,
classId: candidate.id,
})),
...slots.map((_, index) => ({
kind: 'slot' as const,
key: `slot:${index}`,
row: 1,
column: CLASS_CONTENT_COLUMN + index,
slotIndex: index,
})),
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
...(abilityPageCount > 1 && currentAbilityPage > 0
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
: []),
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
: []),
...visibleAbilities
.filter((ability) => ability.unlockLevel <= profile.character.level)
.map((ability, index) => ({
kind: 'ability' as const,
key: `ability:${ability.id}`,
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
abilityId: ability.id,
})),
)
if (!saving) {
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
}
return entries
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
?? navEntries[0]
function selected(entryKey: string) {
return classNavActive && activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
useEffect(() => {
window.scrollTo(0, scrollRef.current)
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setAbilityPage(0)
setMessage('')
}
function selectTab(tab: CustomizeTab) {
setActiveTab(tab)
setSelectedNavKey(`tab:${tab}`)
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== activeIndex)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < activeEntry.column
if (action === 'navigateRight') return entry.column > activeEntry.column
if (action === 'navigateUp') return entry.row < activeEntry.row
return entry.row > activeEntry.row
})
if (candidates.length === 0) return
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - activeEntry.row)
: Math.abs(a.entry.column - activeEntry.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - activeEntry.row)
: Math.abs(b.entry.column - activeEntry.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
}
function openEntry(entry: CustomizeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'class') {
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
if (nextClass) chooseClass(nextClass)
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
else if (entry.kind === 'clear') clearSlot()
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
else if (entry.kind === 'save') void persistChanges()
}
useEffect(() => {
if (!classNavActive) return
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry, classNavActive])
useGameAction((action, device) => {
if (device !== 'controller' || !classNavActive) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
}
return (
<section className="content-screen customize-screen">
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
type="button"
>
Back
</button>
{CUSTOMIZE_TABS.map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
onClick={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab"
type="button"
>
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
ref={navRef(`class:${candidate.id}`)}
style={{ '--class-color': candidate.themeColor } as CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
key={index}
onClick={() => setSelectedSlot(index)}
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
ref={navRef(`slot:${index}`)}
type="button"
>
<kbd>{index + 1}</kbd>
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
<div className="ability-library-actions">
{abilityPageCount > 1 && (
<div className="ability-library-pager">
<button
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
disabled={currentAbilityPage === 0}
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
ref={navRef('ability-page-prev')}
type="button"
>
Prev
</button>
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
<button
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
disabled={currentAbilityPage >= abilityPageCount - 1}
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
onPointerDown={() => setSelectedNavKey('ability-page-next')}
ref={navRef('ability-page-next')}
type="button"
>
Next
</button>
</div>
)}
<button
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('clear-slot') ? 'true' : undefined}
onClick={clearSlot}
onPointerDown={() => setSelectedNavKey('clear-slot')}
ref={navRef('clear-slot')}
type="button"
>
Clear Selected Slot
</button>
</div>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
{visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
ref={navRef(`ability:${ability.id}`)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('save') ? 'true' : undefined}
disabled={saving}
onClick={persistChanges}
onPointerDown={() => setSelectedNavKey('save')}
ref={navRef('save')}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
+201 -33
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
type HunterProfileScreenProps = {
profile: CharacterProfile
@@ -24,6 +25,17 @@ type CollectionItem = {
source: string
}
type HunterProfileTab = 'stats' | 'collection'
type HunterProfileNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
| { kind: 'stat'; key: string; row: number; column: number; index: number }
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
const HUNTER_PROFILE_DROP_COLUMNS = 6
function bossInitials(name: string) {
return name
.split(/\s+/)
@@ -45,7 +57,9 @@ function purplePetKey(encounterId: number) {
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
@@ -60,8 +74,8 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
@@ -117,7 +131,71 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const statTiles = useMemo(() => [
{
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
},
{
key: 'stat:pvp-matches',
label: 'PvP Matches',
value: matchesPlayed,
},
{
key: 'stat:pvp-wins',
label: 'PvP Wins',
value: matchesWon,
},
{
key: 'stat:pvp-win-rate',
label: 'PvP Win Rate',
value: `${winRate}%`,
},
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
const entries: HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
kind: 'stat' as const,
key: tile.key,
row: 1,
column: index,
index,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
kind: 'boss' as const,
key: `boss:${boss.encounter.id}`,
row: index + 1,
column: 0,
bossId: boss.encounter.id,
})),
...collectionItems.map((item, index) => ({
kind: 'item' as const,
key: `item:${item.key}`,
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
@@ -143,13 +221,94 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: number) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<section className="content-screen hunter-profile-screen">
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
@@ -157,8 +316,11 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
@@ -166,32 +328,32 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
@@ -201,9 +363,12 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
key={boss.encounter.id}
onClick={() => setSelectedBossId(boss.encounter.id)}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
@@ -218,10 +383,13 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
+183 -9
View File
@@ -152,6 +152,14 @@ type LivePvpMatch = {
opponentClassName: string
}
type PvpOverlayNavEntry =
| { kind: 'queueBack'; row: number; column: number }
| { kind: 'pauseResume'; row: number; column: number }
| { kind: 'pauseLeave'; row: number; column: number }
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
@@ -356,6 +364,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -695,6 +704,7 @@ export function PvPRoguelikeScreen({
setStartStage(setup.startStage)
setStage(setup.startStage)
setStatus('queueing')
setOverlaySelectedIndex(0)
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id)
@@ -1025,6 +1035,7 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedDebuff(null)
setOverlaySelectedIndex(0)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
@@ -1312,13 +1323,140 @@ export function PvPRoguelikeScreen({
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
if (paused) {
return [
{ kind: 'pauseResume', row: 0, column: 0 },
{ kind: 'pauseLeave', row: 1, column: 0 },
]
}
if (status !== 'upgrade-choice') return []
const entries: PvpOverlayNavEntry[] = [
...playerBuffChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
...playerDebuffChoices.map((_, index) => ({
kind: 'upgradeDebuff' as const,
index,
row: index,
column: 1,
})),
]
entries.push({
kind: 'upgradeContinue',
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
column: 1,
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
})
return entries
}
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
const active = entries[bounded]
if (active && !pvpOverlayEntryDisabled(active)) return active
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
}
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
const active = activePvpOverlayEntry()
if (!active || active.kind !== kind) return false
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
return true
}
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
const entries = pvpOverlayEntries()
const nextIndex = entries.findIndex((entry) => {
if (entry.kind !== kind) return false
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
return true
})
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
}
function movePvpOverlaySelection(action: string) {
const entries = pvpOverlayEntries()
const enabledEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
if (enabledEntries.length === 0) return
setOverlaySelectedIndex((current) => {
const candidate = entries[Math.min(current, entries.length - 1)]
const active = candidate && !pvpOverlayEntryDisabled(candidate)
? candidate
: enabledEntries[0]?.entry
if (!active) return current
const candidates = enabledEntries.filter(({ entry }) => {
if (entry === active) return false
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || a.index - b.index
})
return candidates[0]?.index ?? current
})
}
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (paused) {
if (action === 'back' || action === 'pause') {
setPaused(false)
return
}
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value)
if (status === 'playing') {
setOverlaySelectedIndex(0)
setPaused((value) => !value)
}
return
}
if (paused || status !== 'playing') return
@@ -1434,9 +1572,18 @@ export function PvPRoguelikeScreen({
>
<section className="content-screen pvp-match-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-panel pvp-match-queue-panel" data-game-nav-active="true">
<div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('queueBack')}
type="button"
>
Back
</button>
</div>
)}
@@ -1552,7 +1699,7 @@ export function PvPRoguelikeScreen({
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="result-screen" data-game-nav-active="true">
<div className="pvp-upgrade-dialog">
<div className="pvp-upgrade-header">
<div>
@@ -1567,9 +1714,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedBuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1583,9 +1732,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerDebuffChoices.map((choice) => (
<button
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedDebuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1596,7 +1747,14 @@ export function PvPRoguelikeScreen({
</div>
</div>
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
onClick={() => confirmUpgradeChoices()}
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
type="button"
>
{liveUpgradePending ? 'Waiting' : 'Continue'}
</button>
</div>
@@ -1604,12 +1762,28 @@ export function PvPRoguelikeScreen({
)}
{paused && (
<div className="pause-screen">
<div className="pause-screen" data-game-nav-active="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
<button
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
data-controller-nav="skip"
onClick={() => setPaused(false)}
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
</button>
</div>
</div>
)}
+270 -42
View File
@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ACTION_LABELS,
INPUT_ACTIONS,
useInput,
useGameAction,
type InputAction,
type InputDevice,
} from '../input'
import {
ControllerBindingLabel,
ControllerStylePreview,
} from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen'
import {
getNativeDisplays,
@@ -22,11 +18,50 @@ import {
type AndroidDisplay,
} from '../nativeDualScreen'
type SettingsTab = 'display' | 'input' | 'bindings'
type SettingsNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab }
| { kind: 'dualToggle'; key: string; row: number; column: number }
| { kind: 'openCompanion'; key: string; row: number; column: number }
| { kind: 'directTargeting'; key: string; row: number; column: number }
| { kind: 'touchLock'; key: string; row: number; column: number }
| { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS }
| { kind: 'device'; key: string; row: number; column: number; device: InputDevice }
| { kind: 'binding'; key: string; row: number; column: number; action: InputAction }
| { kind: 'resetBindings'; key: string; row: number; column: number }
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const
const SETTINGS_TAB_LABELS: Record<SettingsTab, string> = {
display: 'Display',
input: 'Input',
bindings: 'Bindings',
}
const SETTINGS_BINDING_COLUMNS = 2
const DIRECT_TARGET_ACTIONS = new Set<InputAction>([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [settingsTab, setSettingsTab] = useState<SettingsTab>('display')
const [selectedIndex, setSelectedIndex] = useState(1)
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const {
bindings,
capture,
@@ -47,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
openTopDisplay,
} = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget'
: !directTargetActions.has(action)
))
: !DIRECT_TARGET_ACTIONS.has(action)
)), [directPartyTargeting])
const navEntries = useMemo<SettingsNavEntry[]>(() => {
const entries: SettingsNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...SETTINGS_TAB_ORDER.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab}`,
row: 0,
column: index + 1,
tab,
})),
]
if (settingsTab === 'display') {
entries.push(
{ kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 },
{ kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 },
)
} else if (settingsTab === 'input') {
entries.push(
{ kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 },
{ kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 },
{ kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' },
{ kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' },
{ kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' },
)
} else {
entries.push(
{ kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' },
{ kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' },
...visibleActions.map((action, index) => ({
kind: 'binding' as const,
key: `bindings:action:${action}`,
row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2,
column: index % SETTINGS_BINDING_COLUMNS,
action,
})),
{
kind: 'resetBindings',
key: 'bindings:reset',
row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3,
column: 1,
},
)
}
return entries
}, [settingsTab, visibleActions])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: SettingsTab) {
setSettingsTab(tab)
const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab)
setSelectedIndex(tabIndex + 1)
}
function firstContentKey(tab: SettingsTab) {
if (tab === 'display') return 'display:dual-toggle'
if (tab === 'input') return 'input:direct-targeting'
return 'bindings:device:controller'
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
if (action === 'navigateDown' && active.row === 0) {
const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab))
if (nextIndex >= 0) return nextIndex
}
if (action === 'navigateUp' && active.row === 1) {
const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`)
if (tabIndex >= 0) return tabIndex
}
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: SettingsNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'dualToggle') {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
} else if (entry.kind === 'openCompanion') {
void launchTopDisplay()
} else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting)
else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked)
else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style)
else if (entry.kind === 'device') setDevice(entry.device)
else if (entry.kind === 'binding') beginCapture(device, entry.action)
else if (entry.kind === 'resetBindings') resetBindings(device)
}
async function refreshNativeDisplays() {
if (!nativeDualScreen) return
@@ -79,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
.catch(() => setAndroidDisplays([]))
}, [nativeDualScreen])
useEffect(() => {
if (!activeEntry) return
navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller' || capture) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
async function launchTopDisplay() {
const opened = await openTopDisplay()
setDisplayMessage(opened
@@ -90,24 +264,34 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
}
return (
<section className="content-screen settings-screen">
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
<div className="settings-nav">
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{SETTINGS_TAB_ORDER.map((tab) => (
<button
aria-selected={settingsTab === tab}
className={`${settingsTab === tab ? 'selected' : ''} ${selected(`tab:${tab}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`tab:${tab}`) ? 'true' : undefined}
key={tab}
onClick={() => selectTab(tab)}
onPointerDown={() => selectKey(`tab:${tab}`)}
ref={navRef(`tab:${tab}`)}
role="tab"
type="button"
>
{tab.label}
{SETTINGS_TAB_LABELS[tab]}
</button>
))}
</nav>
@@ -125,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
className={`${dualScreenEnabled ? 'selected' : ''} ${selected('display:dual-toggle') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('display:dual-toggle') ? 'true' : undefined}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
onPointerDown={() => selectKey('display:dual-toggle')}
ref={navRef('display:dual-toggle')}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
<button
className={selected('display:open-companion') ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selected('display:open-companion') ? 'true' : undefined}
onClick={launchTopDisplay}
onPointerDown={() => selectKey('display:open-companion')}
ref={navRef('display:open-companion')}
type="button"
>
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
@@ -171,16 +367,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
className={`${directPartyTargeting ? 'selected' : ''} ${selected('input:direct-targeting') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:direct-targeting') ? 'true' : undefined}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
onPointerDown={() => selectKey('input:direct-targeting')}
ref={navRef('input:direct-targeting')}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<button
aria-pressed={combatTouchLocked}
className={combatTouchLocked ? 'selected' : ''}
className={`${combatTouchLocked ? 'selected' : ''} ${selected('input:touch-lock') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:touch-lock') ? 'true' : undefined}
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
onPointerDown={() => selectKey('input:touch-lock')}
ref={navRef('input:touch-lock')}
type="button"
>
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
@@ -190,9 +394,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
className={`${controllerIconStyle === style ? 'selected' : ''} ${selected(`input:icons:${style}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`input:icons:${style}`) ? 'true' : undefined}
key={style}
onClick={() => setControllerIconStyle(style)}
onPointerDown={() => selectKey(`input:icons:${style}`)}
ref={navRef(`input:icons:${style}`)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
@@ -215,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
className={`${device === 'controller' ? 'selected' : ''} ${selected('bindings:device:controller') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:controller') ? 'true' : undefined}
onClick={() => setDevice('controller')}
onPointerDown={() => selectKey('bindings:device:controller')}
ref={navRef('bindings:device:controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
className={`${device === 'pc' ? 'selected' : ''} ${selected('bindings:device:pc') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:pc') ? 'true' : undefined}
onClick={() => setDevice('pc')}
onPointerDown={() => selectKey('bindings:device:pc')}
ref={navRef('bindings:device:pc')}
type="button"
>
PC
@@ -233,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
className={`${capture?.device === device && capture.action === action ? 'listening' : ''} ${selected(`bindings:action:${action}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`bindings:action:${action}`) ? 'true' : undefined}
key={action}
onClick={() => beginCapture(device, action)}
onPointerDown={() => selectKey(`bindings:action:${action}`)}
ref={navRef(`bindings:action:${action}`)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
@@ -255,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
<button
className={`text-button ${selected('bindings:reset') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:reset') ? 'true' : undefined}
onClick={() => resetBindings(device)}
onPointerDown={() => selectKey('bindings:reset')}
ref={navRef('bindings:reset')}
type="button"
>
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
+123 -1
View File
@@ -97,14 +97,34 @@ export type DualScreenWorkshopState = {
}>
}
export type DualScreenSetupState = {
contentType: 'dungeon' | 'raid'
title: string
subtitle: string
description: string
initials: string
difficultyName: string
itemLevel: number
experience: number
lockedReason?: string
stats: {
health: string
damage: string
xp: string
loot: string
}
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'setup-state'; state: DualScreenSetupState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
| { type: 'setup-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -160,6 +180,14 @@ function shouldRelayBottomAction(
return action === 'pause' || action === 'back'
}
function shouldRelaySetupAction(
action: InputAction,
state: DualScreenSetupState | null,
) {
if (!state) return false
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -361,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
@@ -377,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
}, [enabled, state])
}
export function useDualScreenSetupPublisher(
state: DualScreenSetupState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'setup-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
const channel = createChannel()
if (!enabled || !channel) return
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
useEffect(() => {
const channel = createChannel()
@@ -389,13 +464,21 @@ export function DualScreenBottomDisplay() {
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
setSetupState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
setSetupState(null)
}
if (event.data.type === 'setup-state') {
setSetupState(event.data.state)
setState(null)
setWorkshopState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
if (event.data.type === 'setup-ended') setSetupState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -414,7 +497,10 @@ export function DualScreenBottomDisplay() {
}
useGameAction((action) => {
if (!shouldRelayBottomAction(action, state)) return
if (
!shouldRelayBottomAction(action, state)
&& !shouldRelaySetupAction(action, setupState)
) return
sendAction(action)
})
@@ -452,6 +538,42 @@ export function DualScreenBottomDisplay() {
)
}
if (!state && setupState) {
return (
<main className="dual-bottom-display setup-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{setupState.contentType}</p>
<h1>{setupState.title}</h1>
<small>{setupState.subtitle}</small>
</div>
<div className="dual-controls-progress">
<span>{setupState.difficultyName}</span>
<span>iLvl {setupState.itemLevel}</span>
<span>{setupState.experience} XP</span>
</div>
</header>
<section className="setup-bottom-summary">
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
{setupState.initials}
</span>
<div>
<p className="eyebrow">Selected Run</p>
<h2>{setupState.title}</h2>
<p>{setupState.description}</p>
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
</div>
</section>
<dl className="setup-bottom-stats">
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
</dl>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
+3
View File
@@ -349,6 +349,7 @@ function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
}
export function focusFirstControl() {
if (hasDedicatedGameNavigation()) return null
const first = focusableElements()[0]
if (first) focusControl(first)
return first
@@ -611,6 +612,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
}
@@ -810,6 +812,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
if (hasDedicatedGameNavigation()) return
const candidates = focusableElements()
const activeControl = currentFocusableControl(candidates)
if (