Android build v1.1.12
This commit is contained in:
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
</article>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
<button
|
||||
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">
|
||||
{([
|
||||
{ key: 'display', label: 'Display' },
|
||||
{ key: 'input', label: 'Input' },
|
||||
{ key: 'bindings', label: 'Bindings' },
|
||||
] as const).map((tab) => (
|
||||
{SETTINGS_TAB_ORDER.map((tab) => (
|
||||
<button
|
||||
aria-selected={settingsTab === tab.key}
|
||||
className={settingsTab === tab.key ? 'selected' : ''}
|
||||
key={tab.key}
|
||||
onClick={() => setSettingsTab(tab.key)}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user