312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
allocateTalent,
|
|
resetTalents,
|
|
type CharacterProfile,
|
|
type Talent,
|
|
} from '../profile'
|
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
|
|
|
type Props = {
|
|
profile: CharacterProfile
|
|
onBack?: () => void
|
|
onUpdated: (profile: CharacterProfile) => void
|
|
embedded?: boolean
|
|
}
|
|
|
|
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
|
const EFFECT_CLASS_ID = 1
|
|
const EFFECTS_PER_PAGE = 8
|
|
const EFFECT_SOURCE_LABELS: Record<string, string> = {
|
|
mend: 'Mend',
|
|
radiance: 'Radiance',
|
|
shield: 'Shield',
|
|
}
|
|
|
|
function effectSource(effectType: string) {
|
|
if (effectType.startsWith('mend_')) return 'mend'
|
|
if (effectType.startsWith('radiance_')) return 'radiance'
|
|
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
|
|
return effectType
|
|
}
|
|
|
|
function effectCapacity(level: number) {
|
|
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
|
|
}
|
|
|
|
function activeEffects(talents: Talent[]) {
|
|
return talents.filter((talent) => talent.rank > 0)
|
|
}
|
|
|
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
|
const [resetting, setResetting] = useState(false)
|
|
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
|
const [effectPage, setEffectPage] = useState(0)
|
|
const [message, setMessage] = useState('')
|
|
const scrollRef = useRef<number>(0)
|
|
const gameClass = profile.classes.find(
|
|
(candidate) => candidate.id === profile.character.classId,
|
|
)!
|
|
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
|
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
|
const selectedEffects = activeEffects(gameClass.talents)
|
|
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
|
?? selectedEffects[0]
|
|
?? gameClass.talents[0]
|
|
?? null
|
|
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
|
|
const visibleTalents = gameClass.talents.slice(
|
|
effectPage * EFFECTS_PER_PAGE,
|
|
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
|
)
|
|
|
|
useEffect(() => {
|
|
window.scrollTo(0, scrollRef.current)
|
|
}, [profile])
|
|
|
|
useEffect(() => {
|
|
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
|
setSelectedTalentId(selectedTalent?.id ?? null)
|
|
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
|
|
|
useEffect(() => {
|
|
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
|
}, [effectPageCount])
|
|
|
|
function saveScroll() {
|
|
scrollRef.current = window.scrollY
|
|
}
|
|
|
|
function lockReason(talent: Talent) {
|
|
if (!isEffectClass) return 'Coming soon'
|
|
if (talent.rank > 0) return ''
|
|
const source = effectSource(talent.effectType)
|
|
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
|
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
|
|
if (capacity <= 0) return 'Unlocks at level 5'
|
|
if (selectedEffects.length >= capacity) {
|
|
return `Active slots full (${capacity}/${capacity})`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
async function toggleEffect(talent: Talent) {
|
|
saveScroll()
|
|
setBusyTalentId(talent.id)
|
|
setMessage('')
|
|
try {
|
|
const updated = await allocateTalent(talent.id)
|
|
onUpdated(updated)
|
|
setSelectedTalentId(talent.id)
|
|
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
|
} finally {
|
|
setBusyTalentId(null)
|
|
}
|
|
}
|
|
|
|
async function clearEffects() {
|
|
saveScroll()
|
|
setResetting(true)
|
|
setMessage('')
|
|
try {
|
|
const updated = await resetTalents()
|
|
onUpdated(updated)
|
|
setMessage('Spell effects cleared.')
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
|
} finally {
|
|
setResetting(false)
|
|
}
|
|
}
|
|
|
|
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
|
if (!isEffectClass) return null
|
|
return {
|
|
mode: 'talents',
|
|
title: 'Spell Effects',
|
|
subtitle: `${selectedEffects.length}/${capacity} active`,
|
|
summary: selectedTalent
|
|
? `${selectedTalent.name}: ${selectedTalent.description}`
|
|
: 'Choose effects to modify your spells.',
|
|
items: gameClass.talents.map((talent) => ({
|
|
glyph: talent.glyph,
|
|
title: talent.name,
|
|
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
|
|
detail: talent.description,
|
|
status: talent.rank > 0 ? 'Selected' : '',
|
|
})),
|
|
}
|
|
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
|
|
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
|
|
|
const content = (
|
|
<>
|
|
{!embedded && (
|
|
<div className="screen-heading">
|
|
<div>
|
|
<p className="eyebrow">Character Growth</p>
|
|
<h1>Spell Effects</h1>
|
|
</div>
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
</div>
|
|
)}
|
|
<div className="talent-toolbar spell-effect-toolbar">
|
|
<div className="talent-class-summary">
|
|
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
|
{gameClass.name[0]}
|
|
</span>
|
|
<div>
|
|
<p className="eyebrow">{gameClass.name} Effects</p>
|
|
<h2>Modify Your Spells</h2>
|
|
</div>
|
|
</div>
|
|
<div className="talent-points">
|
|
<strong>{selectedEffects.length}/{capacity}</strong>
|
|
<span>Active</span>
|
|
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
|
</div>
|
|
</div>
|
|
|
|
{!isEffectClass ? (
|
|
<div className="talent-empty-state">
|
|
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
|
<p>This replacement system starts with the first class.</p>
|
|
</div>
|
|
) : (
|
|
<div className="spell-effect-layout">
|
|
<section className="effect-slots-panel">
|
|
<p className="eyebrow">Active Slots</p>
|
|
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
|
const effect = selectedEffects[index]
|
|
const unlocked = profile.character.level >= level
|
|
return (
|
|
<button
|
|
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
|
disabled={!effect}
|
|
key={level}
|
|
onClick={() => effect && setSelectedTalentId(effect.id)}
|
|
type="button"
|
|
>
|
|
<span>Lv {level}</span>
|
|
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
|
|
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
|
|
</button>
|
|
)
|
|
})}
|
|
</section>
|
|
|
|
<section className="effect-pool-panel">
|
|
<div className="effect-panel-heading">
|
|
<div>
|
|
<p className="eyebrow">Effect Pool</p>
|
|
<h2>Choose and Swap</h2>
|
|
</div>
|
|
<span>{selectedEffects.length}/{capacity} active</span>
|
|
</div>
|
|
<div className="selected-effect-strip">
|
|
<div>
|
|
<p className="eyebrow">Selected Effect</p>
|
|
{selectedTalent ? (
|
|
<>
|
|
<strong>{selectedTalent.name}</strong>
|
|
<small>{selectedTalent.description}</small>
|
|
</>
|
|
) : (
|
|
<small>No effect selected.</small>
|
|
)}
|
|
</div>
|
|
{selectedTalent && (
|
|
<button
|
|
className="primary-button"
|
|
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
|
onClick={() => toggleEffect(selectedTalent)}
|
|
type="button"
|
|
>
|
|
{busyTalentId === selectedTalent.id
|
|
? 'Saving...'
|
|
: selectedTalent.rank > 0
|
|
? 'Remove'
|
|
: 'Activate'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="effect-pool">
|
|
{visibleTalents.map((talent) => {
|
|
const reason = lockReason(talent)
|
|
const active = talent.rank > 0
|
|
const selected = selectedTalent?.id === talent.id
|
|
const isBusy = busyTalentId === talent.id
|
|
return (
|
|
<button
|
|
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
|
disabled={Boolean(reason) || isBusy}
|
|
key={talent.id}
|
|
onClick={() => {
|
|
setSelectedTalentId(talent.id)
|
|
void toggleEffect(talent)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>{talent.glyph}</span>
|
|
<div>
|
|
<strong>{talent.name}</strong>
|
|
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
|
</div>
|
|
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{effectPageCount > 1 && (
|
|
<div className="effect-pager">
|
|
<button
|
|
disabled={effectPage === 0}
|
|
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
|
type="button"
|
|
>
|
|
Prev
|
|
</button>
|
|
<span>{effectPage + 1}/{effectPageCount}</span>
|
|
<button
|
|
disabled={effectPage >= effectPageCount - 1}
|
|
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
|
type="button"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<footer className="talent-footer">
|
|
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
|
<button
|
|
className="text-button"
|
|
disabled={selectedEffects.length === 0 || resetting}
|
|
onClick={clearEffects}
|
|
type="button"
|
|
>
|
|
{resetting ? 'Clearing...' : 'Clear Effects'}
|
|
</button>
|
|
</footer>
|
|
</>
|
|
)
|
|
|
|
if (embedded) {
|
|
return <div className="talent-screen embedded-screen">{content}</div>
|
|
}
|
|
|
|
return (
|
|
<section className="content-screen talent-screen">
|
|
{content}
|
|
</section>
|
|
)
|
|
}
|