224 lines
7.2 KiB
TypeScript
224 lines
7.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
allocateTalent,
|
|
resetTalents,
|
|
type CharacterProfile,
|
|
type Talent,
|
|
} from '../profile'
|
|
|
|
type Props = {
|
|
profile: CharacterProfile
|
|
onBack?: () => void
|
|
onUpdated: (profile: CharacterProfile) => void
|
|
embedded?: boolean
|
|
}
|
|
|
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
|
const [talentPage, setTalentPage] = useState(0)
|
|
const [resetting, setResetting] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const scrollRef = useRef<number>(0)
|
|
const gameClass = profile.classes.find(
|
|
(candidate) => candidate.id === profile.character.classId,
|
|
)!
|
|
const classPointsSpent = gameClass.talents.reduce(
|
|
(total, talent) => total + talent.rank,
|
|
0,
|
|
)
|
|
const tiers = Array.from(
|
|
new Set(gameClass.talents.map((talent) => talent.tier)),
|
|
).sort((a, b) => a - b)
|
|
const tierPages = Array.from(
|
|
{ length: Math.ceil(tiers.length / 2) },
|
|
(_, index) => tiers.slice(index * 2, index * 2 + 2),
|
|
)
|
|
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
|
|
|
|
useEffect(() => {
|
|
window.scrollTo(0, scrollRef.current)
|
|
}, [profile])
|
|
|
|
function saveScroll() {
|
|
scrollRef.current = window.scrollY
|
|
}
|
|
|
|
function lowerTierPoints(talent: Talent) {
|
|
return gameClass.talents
|
|
.filter((candidate) => candidate.tier < talent.tier)
|
|
.reduce((total, candidate) => total + candidate.rank, 0)
|
|
}
|
|
|
|
function lockReason(talent: Talent) {
|
|
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
|
|
|
const requiredTierPoints = (talent.tier - 1) * 5
|
|
if (lowerTierPoints(talent) < requiredTierPoints) {
|
|
return `Requires ${requiredTierPoints} earlier-tier points`
|
|
}
|
|
|
|
if (talent.prerequisiteTalentId) {
|
|
const prerequisite = gameClass.talents.find(
|
|
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
|
)
|
|
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
|
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
|
|
}
|
|
}
|
|
|
|
if (profile.character.talentPoints <= 0) return 'No points available'
|
|
|
|
return ''
|
|
}
|
|
|
|
async function purchaseRank(talent: Talent) {
|
|
saveScroll()
|
|
setBusyTalentId(talent.id)
|
|
setMessage('')
|
|
try {
|
|
const updated = await allocateTalent(talent.id)
|
|
onUpdated(updated)
|
|
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
|
} finally {
|
|
setBusyTalentId(null)
|
|
}
|
|
}
|
|
|
|
async function refundTree() {
|
|
saveScroll()
|
|
setResetting(true)
|
|
setMessage('')
|
|
try {
|
|
const updated = await resetTalents()
|
|
onUpdated(updated)
|
|
setMessage('All points in this talent tree were refunded.')
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
|
} finally {
|
|
setResetting(false)
|
|
}
|
|
}
|
|
|
|
const content = (
|
|
<>
|
|
{!embedded && (
|
|
<div className="screen-heading">
|
|
<div>
|
|
<p className="eyebrow">Character Growth</p>
|
|
<h1>Talents</h1>
|
|
</div>
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
</div>
|
|
)}
|
|
<div className="talent-toolbar">
|
|
<div className="talent-class-summary">
|
|
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
|
{gameClass.name[0]}
|
|
</span>
|
|
<div>
|
|
<p className="eyebrow">{gameClass.name} Tree</p>
|
|
<h2>Shape Your Healing Style</h2>
|
|
</div>
|
|
</div>
|
|
<div className="talent-points">
|
|
<strong>{profile.character.talentPoints}</strong>
|
|
<span>Available</span>
|
|
<small>{classPointsSpent} spent in this tree</small>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
|
{tierPages.map((pageTiers, index) => (
|
|
<button
|
|
aria-selected={talentPage === index}
|
|
className={talentPage === index ? 'active' : ''}
|
|
key={pageTiers.join('-')}
|
|
onClick={() => setTalentPage(index)}
|
|
role="tab"
|
|
type="button"
|
|
>
|
|
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="talent-tree">
|
|
{visibleTiers.map((tier) => {
|
|
const requiredPoints = (tier - 1) * 5
|
|
return (
|
|
<section className="talent-tier" key={tier}>
|
|
<div className="tier-label">
|
|
<span>Tier {tier}</span>
|
|
<small>
|
|
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
|
</small>
|
|
</div>
|
|
<div className="tier-talents">
|
|
{gameClass.talents
|
|
.filter((talent) => talent.tier === tier)
|
|
.sort((a, b) => a.branch - b.branch)
|
|
.map((talent) => {
|
|
const reason = lockReason(talent)
|
|
const isBusy = busyTalentId === talent.id
|
|
return (
|
|
<article
|
|
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
|
key={talent.id}
|
|
style={{ gridColumn: talent.branch }}
|
|
>
|
|
<div className="talent-node-header">
|
|
<span>{talent.glyph}</span>
|
|
<div>
|
|
<strong>{talent.name}</strong>
|
|
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
|
</div>
|
|
</div>
|
|
<p>{talent.description}</p>
|
|
<div className="rank-pips">
|
|
{Array.from({ length: talent.maxRank }, (_, index) => (
|
|
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
|
))}
|
|
</div>
|
|
<button
|
|
disabled={Boolean(reason) || isBusy}
|
|
onClick={() => purchaseRank(talent)}
|
|
type="button"
|
|
>
|
|
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
|
</button>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<footer className="talent-footer">
|
|
<span>{message || 'Talent changes are saved immediately.'}</span>
|
|
<button
|
|
className="text-button"
|
|
disabled={classPointsSpent === 0 || resetting}
|
|
onClick={refundTree}
|
|
type="button"
|
|
>
|
|
{resetting ? 'Refunding...' : 'Reset Tree'}
|
|
</button>
|
|
</footer>
|
|
</>
|
|
)
|
|
|
|
if (embedded) {
|
|
return <div className="talent-screen embedded-screen">{content}</div>
|
|
}
|
|
|
|
return (
|
|
<section className="content-screen talent-screen">
|
|
{content}
|
|
</section>
|
|
)
|
|
}
|