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('class') const { enabled: dualScreenEnabled } = useDualScreen() const [classId, setClassId] = useState(profile.character.classId) const [slots, setSlots] = useState>(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(0) const navRefs = useRef>({}) 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(() => { 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(() => { 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 (

Character Workshop

Customize Character

{CUSTOMIZE_TABS.map((tab) => ( ))}
{activeTab === 'equipment' && ( )} {activeTab === 'crafting' && ( )} {activeTab === 'talents' && ( )} {activeTab === 'class' && (
{gameClass.name[0]}

Level {profile.character.level} Healer

{gameClass.name}

{gameClass.description}

Active Loadout

Ability Bar

Select a slot, then choose an ability.
{slots.map((abilityId, index) => { const ability = abilityId ? abilityMap.get(abilityId) : undefined return ( ) })}

Class Abilities

Ability Library

{abilityPageCount > 1 && (
{currentAbilityPage + 1}/{abilityPageCount}
)}
{visibleAbilities.map((ability) => { const locked = ability.unlockLevel > profile.character.level const equipped = slots.includes(ability.id) return ( ) })}
{message}
)}
) }