908 lines
32 KiB
TypeScript
908 lines
32 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import {
|
|
ACTION_EQUIP_SLOTS,
|
|
chooseActionTalentSpec,
|
|
getEquippedActionItem,
|
|
getActionTalentPoints,
|
|
respecActionTalents,
|
|
spendActionTalentPoint,
|
|
type ActionCharacter,
|
|
} from '../actionMode'
|
|
import {
|
|
getTalentSpec,
|
|
talentsFor,
|
|
type TalentChoiceOption,
|
|
type TalentEffect,
|
|
type TalentNode,
|
|
} from '../claudeCraftTalents'
|
|
import { canEquipItem } from '../claudeCraftEquipmentRules'
|
|
import { getClaudeCraftItem, getItemStatsText } from '../claudeCraftItems'
|
|
import type { EquipSlot, ItemDef, Stats } from '../claudeCraftTypes'
|
|
|
|
type ItemEntry = {
|
|
count: number
|
|
item: ItemDef
|
|
}
|
|
|
|
type InventoryFilter = 'all' | 'weapon' | 'armor' | 'other'
|
|
|
|
export function ActionCharacterMenu({
|
|
character,
|
|
onOpenInventory,
|
|
onOpenTalents,
|
|
}: {
|
|
character: ActionCharacter
|
|
onOpenInventory?: () => void
|
|
onOpenTalents?: () => void
|
|
}) {
|
|
const [selectedSlot, setSelectedSlot] = useState<EquipSlot>('mainhand')
|
|
const selectedItem = getEquippedActionItem(character, selectedSlot)
|
|
const stats = useMemo(() => getEquipmentStats(character), [character])
|
|
const weapon = getEquippedActionItem(character, 'mainhand')
|
|
|
|
return (
|
|
<section className="action-gear-panel claudecraft-menu">
|
|
<article className="action-gear-summary">
|
|
<div>
|
|
<p className="eyebrow">ClaudeCraft</p>
|
|
<h2>Character</h2>
|
|
</div>
|
|
<div className="claudecraft-character-meta">
|
|
<span>{character.classId}</span>
|
|
<span>Level {character.level}</span>
|
|
<span>{character.copper} Copper</span>
|
|
</div>
|
|
</article>
|
|
|
|
<div className="claudecraft-character-layout">
|
|
<div className="action-slot-grid claudecraft-paperdoll" aria-label="Equipped gear">
|
|
{ACTION_EQUIP_SLOTS.map((slot) => {
|
|
const equipped = getEquippedActionItem(character, slot.slot)
|
|
return (
|
|
<button
|
|
className={selectedSlot === slot.slot ? 'selected' : ''}
|
|
key={slot.slot}
|
|
onClick={() => setSelectedSlot(slot.slot)}
|
|
type="button"
|
|
>
|
|
<span>{slot.glyph}</span>
|
|
<strong>{slot.label}</strong>
|
|
<small>{equipped?.name ?? 'Empty'}</small>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<aside className="action-inventory-panel claudecraft-character-sheet">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Stats</p>
|
|
<h2>{character.name}</h2>
|
|
</div>
|
|
{onOpenInventory && (
|
|
<button className="back-button" onClick={onOpenInventory} type="button">Bags</button>
|
|
)}
|
|
{onOpenTalents && (
|
|
<button className="back-button" onClick={onOpenTalents} type="button">Talents</button>
|
|
)}
|
|
</header>
|
|
|
|
<dl className="action-stat-compare claudecraft-stat-grid">
|
|
<StatLine label="Armor" value={stats.armor} />
|
|
<StatLine label="Stamina" value={stats.sta} />
|
|
<StatLine label="Strength" value={stats.str} />
|
|
<StatLine label="Agility" value={stats.agi} />
|
|
<StatLine label="Intellect" value={stats.int} />
|
|
<StatLine label="Spirit" value={stats.spi} />
|
|
<StatLine label="Weapon" value={weapon?.weapon ? `${weapon.weapon.min}-${weapon.weapon.max}` : 'None'} />
|
|
<StatLine label="Speed" value={weapon?.weapon ? weapon.weapon.speed.toFixed(1) : '-'} />
|
|
</dl>
|
|
|
|
<GearDetail item={selectedItem} slot={selectedSlot} />
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export function ActionTalentMenu({
|
|
character,
|
|
onBack,
|
|
onCharacterChange,
|
|
}: {
|
|
character: ActionCharacter
|
|
onBack?: () => void
|
|
onCharacterChange?: (character: ActionCharacter) => void
|
|
}) {
|
|
const talents = talentsFor(character.classId)
|
|
const points = getActionTalentPoints(character)
|
|
const [selectedChoices, setSelectedChoices] = useState<Record<string, string>>({})
|
|
const [talentTab, setTalentTab] = useState<'class' | string>('class')
|
|
const [selectedTalentId, setSelectedTalentId] = useState<string | null>(null)
|
|
const canEdit = Boolean(onCharacterChange)
|
|
|
|
if (!talents) {
|
|
return (
|
|
<section className="action-gear-panel claudecraft-menu">
|
|
<article className="action-gear-summary action-talent-summary">
|
|
<div className="claudecraft-character-meta">
|
|
<span>No trees</span>
|
|
</div>
|
|
{onBack && (
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
)}
|
|
</article>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const activeSpecId = talentTab === 'class' ? character.talents.spec : talentTab
|
|
const selectedSpec = getTalentSpec(character.classId, activeSpecId)
|
|
const classNodes = talents.nodes.filter((node) => node.tree === 'class')
|
|
const specNodes = talents.nodes.filter((node) => node.tree === 'spec' && node.specId === activeSpecId)
|
|
const visibleNodes = talentTab === 'class' ? classNodes : specNodes
|
|
const selectedTalent = visibleNodes.find((node) => node.id === selectedTalentId) ?? visibleNodes[0] ?? null
|
|
const treeSpent = (tree: 'class' | 'spec', specId = character.talents.spec) => talents.nodes
|
|
.filter((node) => node.tree === tree && (tree === 'class' || node.specId === specId))
|
|
.reduce((sum, node) => sum + (character.talents.ranks[node.id] ?? 0), 0)
|
|
|
|
const spendPoint = (node: TalentNode, choiceId?: string) => {
|
|
if (!canEdit) return
|
|
const result = spendActionTalentPoint(character, node.id, choiceId ?? selectedChoices[node.id])
|
|
if (result.ok) onCharacterChange?.(result.character)
|
|
}
|
|
|
|
return (
|
|
<section className="action-gear-panel claudecraft-menu action-talents-menu">
|
|
<article className="action-gear-summary action-talent-summary">
|
|
<div className="claudecraft-character-meta">
|
|
<span>{character.name}</span>
|
|
<span>Level {character.level}</span>
|
|
<span className="talent-available-pill">Available: {points.available} / {points.total}</span>
|
|
<span>Spent: {points.spent}</span>
|
|
</div>
|
|
<div className="arena-pause-actions action-talent-actions">
|
|
{onBack && (
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
)}
|
|
<button
|
|
className="back-button"
|
|
disabled={!canEdit || points.spent <= 0}
|
|
onClick={() => onCharacterChange?.(respecActionTalents(character))}
|
|
type="button"
|
|
>
|
|
Respec
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<div className="claudecraft-talent-tabs" role="tablist" aria-label="Talent trees">
|
|
<button
|
|
aria-selected={talentTab === 'class'}
|
|
className={talentTab === 'class' ? 'active' : ''}
|
|
onClick={() => {
|
|
setTalentTab('class')
|
|
setSelectedTalentId(null)
|
|
}}
|
|
role="tab"
|
|
type="button"
|
|
>
|
|
<span>Class</span>
|
|
<b>{treeSpent('class')}</b>
|
|
</button>
|
|
{talents.specs.map((spec) => (
|
|
<button
|
|
aria-selected={talentTab === spec.id}
|
|
className={talentTab === spec.id ? 'active' : ''}
|
|
key={spec.id}
|
|
onClick={() => {
|
|
setTalentTab(spec.id)
|
|
setSelectedTalentId(null)
|
|
if (character.talents.spec !== spec.id) {
|
|
onCharacterChange?.(chooseActionTalentSpec(character, spec.id))
|
|
}
|
|
}}
|
|
role="tab"
|
|
type="button"
|
|
>
|
|
<span>{spec.name}</span>
|
|
<b>{treeSpent('spec', spec.id)}</b>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{talentTab !== 'class' && selectedSpec && (
|
|
<div className="claudecraft-talent-mastery">
|
|
<strong>Mastery: {selectedSpec.mastery.name}</strong>
|
|
<span>{selectedSpec.mastery.description}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="claudecraft-talent-body" role="tabpanel">
|
|
<TalentTreeCanvas
|
|
allocation={character.talents}
|
|
canEdit={canEdit}
|
|
classId={character.classId}
|
|
choices={selectedChoices}
|
|
nodes={visibleNodes}
|
|
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
|
|
onSpend={spendPoint}
|
|
points={points}
|
|
selectedId={selectedTalent?.id ?? null}
|
|
selectedSpec={selectedSpec?.id ?? null}
|
|
onSelect={(node) => setSelectedTalentId(node.id)}
|
|
/>
|
|
<TalentDetailPanel
|
|
allocation={character.talents}
|
|
canEdit={canEdit}
|
|
classId={character.classId}
|
|
choices={selectedChoices}
|
|
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
|
|
onSpend={spendPoint}
|
|
node={selectedTalent}
|
|
nodes={visibleNodes}
|
|
points={points}
|
|
selectedSpec={selectedSpec?.id ?? null}
|
|
/>
|
|
</div>
|
|
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export function ActionInventoryMenu({
|
|
character,
|
|
onEquip,
|
|
}: {
|
|
character: ActionCharacter
|
|
onEquip: (itemId: string) => void
|
|
}) {
|
|
const [filter, setFilter] = useState<InventoryFilter>('all')
|
|
const [query, setQuery] = useState('')
|
|
const [tooltipItemId, setTooltipItemId] = useState<string | null>(null)
|
|
const entries = useMemo(() => getInventoryEntries(character), [character])
|
|
const filteredEntries = useMemo(() => {
|
|
const normalizedQuery = query.trim().toLowerCase()
|
|
return entries
|
|
.filter(({ item }) => {
|
|
if (filter === 'weapon' && item.kind !== 'weapon') return false
|
|
if (filter === 'armor' && item.kind !== 'armor') return false
|
|
if (filter === 'other' && (item.kind === 'weapon' || item.kind === 'armor')) return false
|
|
if (!normalizedQuery) return true
|
|
return item.name.toLowerCase().includes(normalizedQuery)
|
|
})
|
|
.sort((a, b) => (getItemPower(b.item) - getItemPower(a.item)) || a.item.name.localeCompare(b.item.name))
|
|
}, [entries, filter, query])
|
|
const tooltipEntry = filteredEntries.find((entry) => entry.item.id === tooltipItemId) ?? null
|
|
const tooltipItem = tooltipEntry?.item ?? null
|
|
const tooltipEquippedItem = tooltipItem?.slot ? getEquippedActionItem(character, tooltipItem.slot) : null
|
|
|
|
return (
|
|
<section className="action-gear-panel claudecraft-menu">
|
|
<article className="action-gear-summary">
|
|
<div>
|
|
<p className="eyebrow">ClaudeCraft</p>
|
|
<h2>Inventory</h2>
|
|
</div>
|
|
<div className="claudecraft-character-meta">
|
|
<span>{entries.length} Slots</span>
|
|
<span>{getInventoryCount(entries)} Items</span>
|
|
<span>{character.copper} Copper</span>
|
|
</div>
|
|
</article>
|
|
|
|
<div className="claudecraft-bag-layout">
|
|
<section className="action-inventory-panel claudecraft-bag-panel">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Bags</p>
|
|
<h2>{getFilterLabel(filter)}</h2>
|
|
</div>
|
|
<input
|
|
aria-label="Search bags"
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search"
|
|
value={query}
|
|
/>
|
|
</header>
|
|
|
|
<div className="claudecraft-bag-filters" aria-label="Bag filters">
|
|
{(['all', 'weapon', 'armor', 'other'] as InventoryFilter[]).map((nextFilter) => (
|
|
<button
|
|
aria-pressed={filter === nextFilter}
|
|
key={nextFilter}
|
|
onClick={() => {
|
|
setFilter(nextFilter)
|
|
setTooltipItemId(null)
|
|
}}
|
|
type="button"
|
|
>
|
|
{getFilterLabel(nextFilter)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="action-inventory-list claudecraft-bag-list" onMouseLeave={() => setTooltipItemId(null)}>
|
|
{filteredEntries.length === 0 ? (
|
|
<p>Bags empty.</p>
|
|
) : (
|
|
filteredEntries.map(({ item, count }) => (
|
|
<button
|
|
key={item.id}
|
|
onBlur={() => setTooltipItemId(null)}
|
|
onClick={() => {
|
|
if (canEquipInventoryItem(character, item)) onEquip(item.id)
|
|
}}
|
|
onFocus={() => setTooltipItemId(item.id)}
|
|
onMouseEnter={() => setTooltipItemId(item.id)}
|
|
type="button"
|
|
>
|
|
<strong>{item.name}</strong>
|
|
<small>{getItemTag(item)}{count > 1 ? ` x${count}` : ''}</small>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
{tooltipItem && (
|
|
<InventoryItemTooltip
|
|
canEquip={canEquipInventoryItem(character, tooltipItem)}
|
|
count={tooltipEntry?.count ?? 1}
|
|
equippedItem={tooltipEquippedItem}
|
|
item={tooltipItem}
|
|
/>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export function ActionMenuOverlay({
|
|
children,
|
|
onBack,
|
|
onClose,
|
|
title,
|
|
}: {
|
|
children: ReactNode
|
|
onBack: () => void
|
|
onClose: () => void
|
|
title: string
|
|
}) {
|
|
return (
|
|
<div className="arena-pause-backdrop claudecraft-menu-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-menu-overlay-title">
|
|
<section className="claudecraft-menu-overlay">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Paused</p>
|
|
<h2 id="action-menu-overlay-title">{title}</h2>
|
|
</div>
|
|
<div className="arena-pause-actions">
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
<button className="primary-button" onClick={onClose} type="button">Resume</button>
|
|
</div>
|
|
</header>
|
|
{children}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TalentTreeCanvas({
|
|
allocation,
|
|
canEdit,
|
|
classId,
|
|
choices,
|
|
nodes,
|
|
onChoice,
|
|
onSelect,
|
|
onSpend,
|
|
points,
|
|
selectedId,
|
|
selectedSpec,
|
|
}: {
|
|
allocation: ActionCharacter['talents']
|
|
canEdit: boolean
|
|
classId: ActionCharacter['classId']
|
|
choices: Record<string, string>
|
|
nodes: TalentNode[]
|
|
onChoice: (nodeId: string, choiceId: string) => void
|
|
onSelect: (node: TalentNode) => void
|
|
onSpend: (node: TalentNode, choiceId?: string) => void
|
|
points: { available: number, spent: number, total: number }
|
|
selectedId: string | null
|
|
selectedSpec: string | null
|
|
}) {
|
|
const cols = Math.max(1, ...nodes.map((node) => node.col + 1))
|
|
const rows = Math.max(1, ...nodes.map((node) => node.row + 1))
|
|
const cellWidth = 86
|
|
const cellHeight = 70
|
|
const nodeSize = 46
|
|
const top = 8
|
|
const width = cols * cellWidth
|
|
const height = rows * cellHeight + top
|
|
const byId = new Map(nodes.map((node) => [node.id, node]))
|
|
const centerX = (node: TalentNode) => node.col * cellWidth + cellWidth / 2
|
|
const centerY = (node: TalentNode) => node.row * cellHeight + top + nodeSize / 2
|
|
|
|
if (nodes.length === 0) {
|
|
return <div className="claudecraft-talent-empty">Choose a specialization.</div>
|
|
}
|
|
|
|
return (
|
|
<div className="claudecraft-talent-tree-scroll">
|
|
<div className="claudecraft-talent-tree" style={{ height, width }}>
|
|
<svg className="claudecraft-talent-arrows" height={height} width={width}>
|
|
{nodes.flatMap((node) => (node.requires ?? []).map((required) => {
|
|
const parent = byId.get(required)
|
|
if (!parent) return null
|
|
const filled = (allocation.ranks[required] ?? 0) > 0
|
|
return (
|
|
<line
|
|
key={`${required}-${node.id}`}
|
|
stroke={filled ? '#f5c843' : '#5a4a22'}
|
|
strokeWidth="2"
|
|
x1={centerX(parent)}
|
|
x2={centerX(node)}
|
|
y1={centerY(parent) + nodeSize / 2}
|
|
y2={centerY(node) - nodeSize / 2}
|
|
/>
|
|
)
|
|
}))}
|
|
</svg>
|
|
{nodes.map((node) => {
|
|
const rank = allocation.ranks[node.id] ?? 0
|
|
const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec)
|
|
const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id
|
|
const selectedChoice = node.choices?.find((choice) => choice.id === choiceId)
|
|
const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0
|
|
const maxed = rank >= node.maxRank
|
|
const shape = node.kind === 'active' ? 'square' : node.kind === 'choice' ? 'octagon' : 'circle'
|
|
const state = locked ? 'locked' : maxed ? 'maxed' : rank > 0 ? 'filled' : canSpend ? 'avail' : 'locked'
|
|
return (
|
|
<button
|
|
aria-label={`${node.name}, rank ${rank}/${node.maxRank}`}
|
|
aria-pressed={rank > 0}
|
|
aria-disabled={!canSpend && rank <= 0}
|
|
className={`claudecraft-talent-node ${shape} ${state} ${selectedId === node.id ? 'selected' : ''}`}
|
|
key={node.id}
|
|
onClick={() => {
|
|
onSelect(node)
|
|
if (node.kind === 'choice') {
|
|
if (choiceId) onChoice(node.id, choiceId)
|
|
if (rank <= 0 && canSpend) onSpend(node, choiceId)
|
|
return
|
|
}
|
|
if (canSpend) onSpend(node)
|
|
}}
|
|
onMouseEnter={() => onSelect(node)}
|
|
style={{
|
|
left: node.col * cellWidth + (cellWidth - nodeSize) / 2,
|
|
top: node.row * cellHeight + top,
|
|
}}
|
|
type="button"
|
|
>
|
|
<span
|
|
className="claudecraft-talent-icon"
|
|
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
|
|
/>
|
|
{(rank > 0 || node.maxRank > 1) && (
|
|
<span className="claudecraft-talent-rank">{rank}/{node.maxRank}</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TalentDetailPanel({
|
|
allocation,
|
|
canEdit,
|
|
classId,
|
|
choices,
|
|
node,
|
|
nodes,
|
|
onChoice,
|
|
onSpend,
|
|
points,
|
|
selectedSpec,
|
|
}: {
|
|
allocation: ActionCharacter['talents']
|
|
canEdit: boolean
|
|
classId: ActionCharacter['classId']
|
|
choices: Record<string, string>
|
|
node: TalentNode | null
|
|
nodes: TalentNode[]
|
|
onChoice: (nodeId: string, choiceId: string) => void
|
|
onSpend: (node: TalentNode, choiceId?: string) => void
|
|
points: { available: number, spent: number, total: number }
|
|
selectedSpec: string | null
|
|
}) {
|
|
if (!node) return <aside className="claudecraft-talent-detail"><span>Select a talent.</span></aside>
|
|
|
|
const rank = allocation.ranks[node.id] ?? 0
|
|
const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec)
|
|
const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id
|
|
const selectedChoice = node.choices?.find((choice) => choice.id === choiceId)
|
|
const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0
|
|
|
|
return (
|
|
<aside className="claudecraft-talent-detail">
|
|
<header>
|
|
<span
|
|
className="claudecraft-talent-detail-icon"
|
|
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
|
|
/>
|
|
<div>
|
|
<strong>{node.name}</strong>
|
|
<small>Rank {rank}/{node.maxRank}</small>
|
|
</div>
|
|
</header>
|
|
<p>{node.description}</p>
|
|
{node.requires?.length ? <small>Requires: {node.requires.join(', ')}</small> : null}
|
|
{node.pointsGate ? <small>{node.pointsGate} points required above.</small> : null}
|
|
{locked && <small className="claudecraft-talent-warning">Locked</small>}
|
|
{node.kind === 'choice' && node.choices && (
|
|
<div className="claudecraft-talent-choice-list">
|
|
{node.choices.map((choice) => {
|
|
const selected = choiceId === choice.id
|
|
return (
|
|
<button
|
|
aria-pressed={selected}
|
|
className={selected ? 'selected' : ''}
|
|
disabled={!canEdit || (rank > 0 && !selected)}
|
|
key={choice.id}
|
|
onClick={() => {
|
|
onChoice(node.id, choice.id)
|
|
if (rank <= 0 && canSpend) onSpend(node, choice.id)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span style={{ backgroundImage: `url(${getTalentIconUrl(classId, choice)})` }} />
|
|
<strong>{choice.name}</strong>
|
|
<small>{choice.description}</small>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
{node.kind !== 'choice' && (
|
|
<button
|
|
className="primary-button"
|
|
disabled={!canSpend}
|
|
onClick={() => onSpend(node)}
|
|
type="button"
|
|
>
|
|
Add Point
|
|
</button>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function getTalentIconUrl(classId: ActionCharacter['classId'], source: TalentNode | TalentChoiceOption) {
|
|
const abilityId = getTalentAbilityIconId(source.effect)
|
|
if (abilityId) return `/ui/skills/${classId}/${abilityId}.png`
|
|
return getTalentCrestIconUrl(source.effect, 'choices' in source ? 'choice' : 'node')
|
|
}
|
|
|
|
function getTalentAbilityIconId(effect?: TalentEffect) {
|
|
return effect?.grant?.ability ?? effect?.ability?.[0]?.ability ?? null
|
|
}
|
|
|
|
function getTalentCrestIconUrl(effect: TalentEffect | undefined, kind: 'choice' | 'node') {
|
|
const stat = effect?.stats ? Object.keys(effect.stats)[0] : ''
|
|
const crest = stat.includes('armor')
|
|
? { bg: '#2d3542', fg: '#d8e2ee', glyph: 'shield' }
|
|
: stat.includes('dodge') || stat === 'agi'
|
|
? { bg: '#163b4f', fg: '#9fe4ff', glyph: 'spark' }
|
|
: stat.includes('hp') || stat === 'sta'
|
|
? { bg: '#4f1418', fg: '#ffd6d6', glyph: 'heart' }
|
|
: stat.includes('ap') || stat === 'str'
|
|
? { bg: '#4d2f0c', fg: '#ffd36e', glyph: 'fist' }
|
|
: effect?.global?.healPct
|
|
? { bg: '#254525', fg: '#bcffb5', glyph: 'cross' }
|
|
: effect?.global?.threatPct
|
|
? { bg: '#313641', fg: '#d9e5ff', glyph: 'shield' }
|
|
: effect?.global
|
|
? { bg: '#4a3510', fg: '#ffe28a', glyph: 'burst' }
|
|
: kind === 'choice'
|
|
? { bg: '#2e1c4e', fg: '#f0d2ff', glyph: 'gem' }
|
|
: { bg: '#252b35', fg: '#dde7ff', glyph: 'rune' }
|
|
return svgDataUrl(renderTalentCrestSvg(crest.bg, crest.fg, crest.glyph))
|
|
}
|
|
|
|
function svgDataUrl(svg: string) {
|
|
return `data:image/svg+xml;base64,${window.btoa(svg)}`
|
|
}
|
|
|
|
function renderTalentCrestSvg(bg: string, fg: string, glyph: string) {
|
|
const common = `fill="${fg}" stroke="#120d04" stroke-width="3" stroke-linejoin="round"`
|
|
const mark = glyph === 'shield'
|
|
? `<path ${common} d="M64 20 L100 34 V62 C100 88 82 106 64 116 C46 106 28 88 28 62 V34 Z"/>`
|
|
: glyph === 'heart'
|
|
? `<path ${common} d="M64 108 C36 82 22 67 22 47 C22 32 32 22 46 22 C54 22 60 26 64 34 C68 26 74 22 82 22 C96 22 106 32 106 47 C106 67 92 82 64 108 Z"/>`
|
|
: glyph === 'cross'
|
|
? `<path ${common} d="M54 22 H74 V52 H104 V72 H74 V106 H54 V72 H24 V52 H54 Z"/>`
|
|
: glyph === 'fist'
|
|
? `<path ${common} d="M32 56 H44 V28 H58 V56 H66 V24 H80 V58 H88 V34 H101 V74 C101 94 86 108 64 108 C42 108 28 94 28 74 Z"/>`
|
|
: glyph === 'spark'
|
|
? `<path ${common} d="M70 12 L46 58 H66 L54 116 L86 50 H66 Z"/>`
|
|
: glyph === 'gem'
|
|
? `<path ${common} d="M64 18 L104 48 L64 112 L24 48 Z M24 48 H104 M46 48 L64 112 L82 48"/>`
|
|
: glyph === 'burst'
|
|
? `<path ${common} d="M64 14 L74 48 L108 38 L84 64 L108 90 L74 80 L64 114 L54 80 L20 90 L44 64 L20 38 L54 48 Z"/>`
|
|
: `<path ${common} d="M32 28 H96 V100 H32 Z M46 44 H82 M46 64 H82 M46 84 H72"/>`
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><defs><radialGradient id="g" cx="35%" cy="25%" r="80%"><stop stop-color="#ffffff" stop-opacity=".25"/><stop offset=".45" stop-color="${bg}"/><stop offset="1" stop-color="#07090d"/></radialGradient></defs><rect width="128" height="128" rx="18" fill="url(#g)"/><rect x="8" y="8" width="112" height="112" rx="16" fill="none" stroke="#f5c843" stroke-opacity=".35" stroke-width="4"/>${mark}</svg>`
|
|
}
|
|
|
|
function isTalentLocked(
|
|
node: TalentNode,
|
|
allocation: ActionCharacter['talents'],
|
|
siblingNodes: TalentNode[],
|
|
points: { available: number },
|
|
selectedSpec: string | null,
|
|
) {
|
|
if (!allocation.spec) return true
|
|
if (node.tree === 'spec' && node.specId !== selectedSpec) return true
|
|
for (const required of node.requires ?? []) {
|
|
if ((allocation.ranks[required] ?? 0) <= 0) return true
|
|
}
|
|
const gate = node.pointsGate ?? 0
|
|
if (gate > 0) {
|
|
const spentAbove = siblingNodes
|
|
.filter((candidate) => candidate.tree === node.tree && candidate.row < node.row)
|
|
.reduce((sum, candidate) => sum + (allocation.ranks[candidate.id] ?? 0), 0)
|
|
if (spentAbove < gate) return true
|
|
}
|
|
return points.available <= 0 && (allocation.ranks[node.id] ?? 0) < node.maxRank
|
|
}
|
|
|
|
function StatLine({ label, value }: { label: string, value: number | string }) {
|
|
return (
|
|
<div>
|
|
<dt>{label}</dt>
|
|
<dd>{value}</dd>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GearDetail({
|
|
equippedItem,
|
|
item,
|
|
onEquip,
|
|
slot,
|
|
}: {
|
|
equippedItem?: ItemDef | null
|
|
item: ItemDef | null
|
|
onEquip?: () => void
|
|
slot?: EquipSlot
|
|
}) {
|
|
if (!item) {
|
|
return (
|
|
<section className="action-gear-detail empty">
|
|
<p>{slot ? `No item equipped in ${slot}.` : 'Select an item.'}</p>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<section className="action-gear-detail">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Selected</p>
|
|
<h2>{item.name}</h2>
|
|
</div>
|
|
<span>{item.quality ?? 'common'}</span>
|
|
</header>
|
|
<dl className="action-stat-compare">
|
|
<div>
|
|
<dt>Stats</dt>
|
|
<dd>{getItemStatsText(item)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Equipped</dt>
|
|
<dd>{equippedItem ? getItemStatsText(equippedItem) : 'Empty'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Slot</dt>
|
|
<dd>{item.slot ?? 'Bag'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Class</dt>
|
|
<dd>{item.requiredClass?.join(', ') ?? 'Any'}</dd>
|
|
</div>
|
|
</dl>
|
|
{onEquip && (
|
|
<button disabled={equippedItem?.id === item.id} onClick={onEquip} type="button">
|
|
{equippedItem?.id === item.id ? 'Equipped' : 'Equip'}
|
|
</button>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function InventoryItemTooltip({
|
|
canEquip,
|
|
count,
|
|
equippedItem,
|
|
item,
|
|
}: {
|
|
canEquip: boolean
|
|
count: number
|
|
equippedItem?: ItemDef | null
|
|
item: ItemDef
|
|
}) {
|
|
const rows = getItemTooltipRows(item, equippedItem)
|
|
return (
|
|
<aside className={`claudecraft-action-tooltip claudecraft-item-tooltip quality-${item.quality ?? 'common'}`} role="tooltip">
|
|
<div className="tooltip-heading">
|
|
<span className="item-tooltip-icon">{getItemIconGlyph(item)}</span>
|
|
<div>
|
|
<strong>{item.name}</strong>
|
|
<span>{formatItemQuality(item)}{count > 1 ? ` x${count}` : ''}</span>
|
|
</div>
|
|
</div>
|
|
<dl>
|
|
<div><dt>Slot</dt><dd>{item.slot ? formatEquipSlot(item.slot) : 'Bag'}</dd></div>
|
|
<div><dt>Type</dt><dd>{getItemKindLabel(item)}</dd></div>
|
|
{item.requiredClass?.length ? <div><dt>Class</dt><dd>{item.requiredClass.join(', ')}</dd></div> : null}
|
|
{item.sellValue > 0 ? <div><dt>Sell</dt><dd>{item.sellValue} copper</dd></div> : null}
|
|
</dl>
|
|
<dl className="item-tooltip-stats">
|
|
{rows.length === 0 ? (
|
|
<div><dt>Stats</dt><dd>No combat stats</dd></div>
|
|
) : rows.map((row) => (
|
|
<div className={row.delta === undefined ? '' : row.delta > 0 ? 'better' : row.delta < 0 ? 'worse' : 'same'} key={row.label}>
|
|
<dt>{row.label}</dt>
|
|
<dd>
|
|
<span>{row.value}</span>
|
|
{row.delta !== undefined && <em>{formatStatDelta(row.delta)}</em>}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{item.slot && (
|
|
<p>
|
|
{equippedItem
|
|
? `Equipped: ${equippedItem.name}`
|
|
: `Equipped: empty ${formatEquipSlot(item.slot)}`}
|
|
</p>
|
|
)}
|
|
{item.slot && <em>{canEquip ? 'Click to equip.' : 'Cannot equip.'}</em>}
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function getInventoryEntries(character: ActionCharacter): ItemEntry[] {
|
|
return character.inventory
|
|
.map((slot) => ({ item: getClaudeCraftItem(slot.itemId), count: slot.count }))
|
|
.filter((entry): entry is ItemEntry => Boolean(entry.item))
|
|
}
|
|
|
|
function getInventoryCount(entries: ItemEntry[]) {
|
|
return entries.reduce((total, entry) => total + entry.count, 0)
|
|
}
|
|
|
|
type ItemTooltipRow = {
|
|
delta?: number
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
const STAT_ORDER: Array<keyof Stats> = ['armor', 'str', 'agi', 'sta', 'int', 'spi']
|
|
const STAT_LABELS: Record<keyof Stats, string> = {
|
|
agi: 'Agility',
|
|
armor: 'Armor',
|
|
int: 'Intellect',
|
|
spi: 'Spirit',
|
|
sta: 'Stamina',
|
|
str: 'Strength',
|
|
}
|
|
|
|
function getItemTooltipRows(item: ItemDef, equippedItem?: ItemDef | null): ItemTooltipRow[] {
|
|
const rows: ItemTooltipRow[] = []
|
|
if (item.weapon) {
|
|
const equippedWeapon = equippedItem?.weapon
|
|
const itemAverage = (item.weapon.min + item.weapon.max) / 2
|
|
const equippedAverage = equippedWeapon ? (equippedWeapon.min + equippedWeapon.max) / 2 : 0
|
|
rows.push({
|
|
delta: equippedItem ? itemAverage - equippedAverage : undefined,
|
|
label: 'Damage',
|
|
value: `${item.weapon.min}-${item.weapon.max}`,
|
|
})
|
|
rows.push({
|
|
delta: equippedWeapon ? equippedWeapon.speed - item.weapon.speed : undefined,
|
|
label: 'Speed',
|
|
value: item.weapon.speed.toFixed(1),
|
|
})
|
|
}
|
|
|
|
for (const key of STAT_ORDER) {
|
|
const value = item.stats?.[key] ?? 0
|
|
if (!value) continue
|
|
const equippedValue = equippedItem?.stats?.[key] ?? 0
|
|
rows.push({
|
|
delta: equippedItem ? value - equippedValue : undefined,
|
|
label: STAT_LABELS[key],
|
|
value: key === 'armor' ? String(value) : `+${value}`,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
function formatStatDelta(delta: number) {
|
|
if (delta === 0) return '+0'
|
|
return delta > 0 ? `+${formatDeltaNumber(delta)}` : formatDeltaNumber(delta)
|
|
}
|
|
|
|
function formatDeltaNumber(value: number) {
|
|
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
}
|
|
|
|
function getItemIconGlyph(item: ItemDef) {
|
|
if (item.kind === 'weapon') return '/'
|
|
if (item.kind === 'armor') return 'H'
|
|
if (item.kind === 'potion' || item.kind === 'elixir') return '+'
|
|
return '*'
|
|
}
|
|
|
|
function formatItemQuality(item: ItemDef) {
|
|
const quality = item.quality ?? 'common'
|
|
return quality[0].toUpperCase() + quality.slice(1)
|
|
}
|
|
|
|
function formatEquipSlot(slot: EquipSlot) {
|
|
return ACTION_EQUIP_SLOTS.find((entry) => entry.slot === slot)?.label ?? slot
|
|
}
|
|
|
|
function getItemKindLabel(item: ItemDef) {
|
|
if (item.kind === 'weapon' && item.weapon?.dagger) return 'Dagger'
|
|
if (item.kind === 'weapon') return 'Weapon'
|
|
if (item.kind === 'armor') return item.armorType ? `${item.armorType} armor` : 'Armor'
|
|
return item.kind
|
|
}
|
|
|
|
function getEquipmentStats(character: ActionCharacter): Stats {
|
|
const stats: Stats = { str: 0, agi: 0, sta: 0, int: 0, spi: 0, armor: 0 }
|
|
for (const slot of ACTION_EQUIP_SLOTS) {
|
|
const item = getEquippedActionItem(character, slot.slot)
|
|
if (!item?.stats) continue
|
|
stats.str += item.stats.str ?? 0
|
|
stats.agi += item.stats.agi ?? 0
|
|
stats.sta += item.stats.sta ?? 0
|
|
stats.int += item.stats.int ?? 0
|
|
stats.spi += item.stats.spi ?? 0
|
|
stats.armor += item.stats.armor ?? 0
|
|
}
|
|
return stats
|
|
}
|
|
|
|
function canEquipInventoryItem(character: ActionCharacter, item: ItemDef) {
|
|
return Boolean(item.slot && (item.kind === 'weapon' || item.kind === 'armor') && canEquipItem(character.classId, item))
|
|
}
|
|
|
|
function getFilterLabel(filter: InventoryFilter) {
|
|
if (filter === 'weapon') return 'Weapons'
|
|
if (filter === 'armor') return 'Armor'
|
|
if (filter === 'other') return 'Other'
|
|
return 'All'
|
|
}
|
|
|
|
function getItemTag(item: ItemDef) {
|
|
const quality = item.quality ?? 'common'
|
|
const slot = item.slot ?? item.kind
|
|
const stats = getItemStatsText(item)
|
|
return stats ? `${quality} - ${slot} - ${stats}` : `${quality} - ${slot}`
|
|
}
|
|
|
|
function getItemPower(item: ItemDef) {
|
|
const stats = item.stats ?? {}
|
|
const statScore = (stats.str ?? 0) + (stats.agi ?? 0) + (stats.sta ?? 0) + (stats.int ?? 0) + (stats.spi ?? 0)
|
|
const armorScore = (stats.armor ?? 0) / 12
|
|
const weaponScore = item.weapon ? item.weapon.min + item.weapon.max : 0
|
|
return statScore + armorScore + weaponScore
|
|
}
|