Files
i-want-to-heal/src/components/CustomizeScreen.tsx
T
2026-07-03 00:28:38 -04:00

518 lines
20 KiB
TypeScript

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'
type Props = {
profile: CharacterProfile
onBack: () => void
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<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)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 6)
.map((ability) => ability.id)
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.')
return
}
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? abilityId : spellId),
)
setMessage('')
}
function clearSlot() {
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? null : spellId),
)
}
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 {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() {
saveScroll()
setSaving(true)
setMessage('')
try {
const updated = await saveProfile(classId, slots)
onSaved(updated)
setMessage('Character saved.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to save character.')
} finally {
setSaving(false)
}
}
return (
<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 ${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 ${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' : ''} ${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={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</div>
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'talents' && (
<TalentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'class' && (
<div className="customize-layout">
<aside className="class-picker">
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
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)}
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
ref={navRef(`class:${candidate.id}`)}
style={{ '--class-color': candidate.themeColor } as CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
<div>
<strong>{candidate.name}</strong>
<small>{candidate.resourceName}</small>
</div>
</button>
))}
</aside>
<div className="loadout-editor">
<div className="class-detail">
<div
className="class-portrait"
style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}
>
{gameClass.name[0]}
</div>
<div>
<p className="eyebrow">Level {profile.character.level} Healer</p>
<h2>{gameClass.name}</h2>
<p>{gameClass.description}</p>
</div>
</div>
<div className="loadout-heading">
<div>
<p className="eyebrow">Active Loadout</p>
<h2>Ability Bar</h2>
</div>
<span>Select a slot, then choose an ability.</span>
</div>
<div className="ability-slots">
{slots.map((abilityId, index) => {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
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>
<span>{ability?.glyph ?? '-'}</span>
<strong>{ability?.name ?? 'Empty Slot'}</strong>
</button>
)
})}
</div>
<div className="ability-library-heading">
<div>
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<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">
{visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
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>
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
</div>
<i>{locked ? `Level ${ability.unlockLevel}` : equipped ? 'Equipped' : `${ability.cost} ${gameClass.resourceName}`}</i>
</button>
)
})}
</div>
<div className="save-row">
<span>{message}</span>
<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'}
</button>
</div>
</div>
</div>
)}
</section>
)
}