275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
saveProfile,
|
|
type CharacterProfile,
|
|
type GameClass,
|
|
} from '../profile'
|
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
|
import { EquipmentScreen } from './EquipmentScreen'
|
|
import { TalentScreen } from './TalentScreen'
|
|
|
|
type Props = {
|
|
profile: CharacterProfile
|
|
onBack: () => void
|
|
onSaved: (profile: CharacterProfile) => void
|
|
}
|
|
|
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('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 [message, setMessage] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const scrollRef = useRef<number>(0)
|
|
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
|
const abilityMap = useMemo(
|
|
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
|
[gameClass],
|
|
)
|
|
|
|
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)
|
|
setMessage('')
|
|
}
|
|
|
|
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),
|
|
)
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
</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
|
|
aria-selected={activeTab === tab.key}
|
|
className={activeTab === tab.key ? 'active' : ''}
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(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' : ''}
|
|
key={candidate.id}
|
|
onClick={() => chooseClass(candidate)}
|
|
style={{ '--class-color': candidate.themeColor } as React.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' : ''}
|
|
key={index}
|
|
onClick={() => setSelectedSlot(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>
|
|
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
|
|
</div>
|
|
|
|
<div className="ability-library">
|
|
{gameClass.spells.map((ability) => {
|
|
const locked = ability.unlockLevel > profile.character.level
|
|
const equipped = slots.includes(ability.id)
|
|
return (
|
|
<button
|
|
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
|
|
disabled={locked}
|
|
key={ability.id}
|
|
onClick={() => equipAbility(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"
|
|
disabled={saving}
|
|
onClick={persistChanges}
|
|
type="button"
|
|
>
|
|
{saving ? 'Saving...' : 'Save Character'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|