508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
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'
|
|
import { useDualScreen } from '../dualScreen'
|
|
import {
|
|
getNativeDisplays,
|
|
hasNativeDualScreenBridge,
|
|
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<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,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
combatTouchLocked,
|
|
beginCapture,
|
|
cancelCapture,
|
|
resetBindings,
|
|
setControllerIconStyle,
|
|
setDirectPartyTargeting,
|
|
setCombatTouchLocked,
|
|
} = useInput()
|
|
const {
|
|
enabled: dualScreenEnabled,
|
|
connected: topDisplayConnected,
|
|
setEnabled: setDualScreenEnabled,
|
|
openTopDisplay,
|
|
} = useDualScreen()
|
|
const nativeDualScreen = hasNativeDualScreenBridge()
|
|
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
|
|
directPartyTargeting
|
|
? action !== 'previousTarget' && action !== 'nextTarget'
|
|
: !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
|
|
try {
|
|
const result = await getNativeDisplays()
|
|
setAndroidDisplays(result.displays)
|
|
} catch {
|
|
setAndroidDisplays([])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!nativeDualScreen) return
|
|
getNativeDisplays()
|
|
.then((result) => setAndroidDisplays(result.displays))
|
|
.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
|
|
? nativeDualScreen
|
|
? 'Android placed the game on the larger display and controls on the smaller display.'
|
|
: 'Companion display opened. Move it to the Thor screen you want and select Fullscreen.'
|
|
: 'No usable second display was found. Check the Thor display mode and try again.')
|
|
await refreshNativeDisplays()
|
|
}
|
|
|
|
return (
|
|
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
|
|
<div className="settings-nav">
|
|
<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">
|
|
{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"
|
|
>
|
|
{SETTINGS_TAB_LABELS[tab]}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{settingsTab === 'display' && (
|
|
<section className="dual-screen-settings settings-tab-panel">
|
|
<div>
|
|
<p className="eyebrow">Display</p>
|
|
<h2>AYN Thor Dual-Screen Mode</h2>
|
|
<p>
|
|
The upper display shows enemy and party health. The lower display
|
|
keeps targeting, resources, skills, and cooldowns.
|
|
</p>
|
|
</div>
|
|
<div className="dual-screen-actions">
|
|
<button
|
|
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
|
|
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>
|
|
<small>
|
|
{displayMessage || (
|
|
topDisplayConnected
|
|
? 'The companion display is connected and receiving live combat data.'
|
|
: 'Open the companion display before starting combat.'
|
|
)}
|
|
</small>
|
|
{nativeDualScreen && androidDisplays.length > 0 && (
|
|
<div className="android-display-list">
|
|
{androidDisplays.map((display) => (
|
|
<span key={display.id}>
|
|
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
|
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
|
|
{display.isPresentation ? ' - Presentation' : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{settingsTab === 'input' && (
|
|
<section className="controller-preferences settings-tab-panel">
|
|
<div>
|
|
<p className="eyebrow">Targeting</p>
|
|
<h3>Direct Party Keybinds</h3>
|
|
<p>
|
|
Assign party slots directly. In raids, use the group-switch binding
|
|
to alternate between members 1-6, 7-12, and 13-18.
|
|
</p>
|
|
</div>
|
|
<button
|
|
aria-pressed={directPartyTargeting}
|
|
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' : ''} ${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'}
|
|
</button>
|
|
<div className="controller-icon-options">
|
|
<span>Controller Icons</span>
|
|
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
|
<button
|
|
aria-pressed={controllerIconStyle === style}
|
|
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} />
|
|
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{settingsTab === 'bindings' && (
|
|
<section className="settings-bindings-panel settings-tab-panel">
|
|
<div className="settings-heading">
|
|
<div>
|
|
<p className="eyebrow">Input</p>
|
|
<h2>Keybindings</h2>
|
|
</div>
|
|
<p>Select an action, then press the new key or controller control.</p>
|
|
</div>
|
|
|
|
<div className="binding-tabs">
|
|
<button
|
|
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' : ''} ${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
|
|
</button>
|
|
</div>
|
|
|
|
<div className="binding-list">
|
|
{visibleActions.map((action) => (
|
|
<button
|
|
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>
|
|
<kbd>
|
|
{capture?.device === device && capture.action === action
|
|
? 'Press a control...'
|
|
: (
|
|
<ControllerBindingLabel
|
|
binding={bindings[device][action]}
|
|
iconStyle={controllerIconStyle}
|
|
/>
|
|
)}
|
|
</kbd>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<footer className="settings-footer">
|
|
<span>Bindings are saved automatically on this device.</span>
|
|
<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>
|
|
</section>
|
|
)}
|
|
|
|
{capture && (
|
|
<div className="binding-capture" role="dialog" aria-modal="true">
|
|
<div>
|
|
<p className="eyebrow">Remapping</p>
|
|
<h2>{ACTION_LABELS[capture.action]}</h2>
|
|
<p>
|
|
Press any {capture.device === 'pc' ? 'keyboard key' : 'controller button or move a stick'}.
|
|
</p>
|
|
<button onClick={cancelCapture} type="button">Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|