Files
i-want-to-heal/src/components/PartyFrames.tsx
T
2026-06-30 13:30:33 -04:00

155 lines
5.6 KiB
TypeScript

import { memo, type ElementType, type ReactNode } from 'react'
import type { ControllerIconStyle } from '../input'
import type { PartyMember } from '../game'
import {
effectiveMaxHealth,
formatEffectTime,
memberHotEffects,
} from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = {
id: number
memberId: string
value: number
}
type MemberEffectMode = 'full' | 'compact' | 'timed-basic'
const MemberEffects = memo(function MemberEffects({
member,
mode,
}: {
member: PartyMember
mode: MemberEffectMode
}) {
if (mode === 'compact') {
return (
<div className="member-effects">
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
)
}
if (mode === 'timed-basic') {
return (
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
}
return (
<div className="member-effects">
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
})
export const PartyMemberFrame = memo(function PartyMemberFrame({
as,
member,
selected = false,
baseClassName = 'party-member',
selectedClassName = 'selected',
deadClassName = 'dead',
effectMode = 'full',
floatingTexts = [],
showTargetMarker = false,
showHeaderHealth = true,
showHealthText = false,
targetBinding,
controllerIconStyle,
onSelect,
children,
}: {
as?: ElementType
member: PartyMember
selected?: boolean
baseClassName?: string
selectedClassName?: string
deadClassName?: string
effectMode?: MemberEffectMode
floatingTexts?: FloatingCombatText[]
showTargetMarker?: boolean
showHeaderHealth?: boolean
showHealthText?: boolean
targetBinding?: string | null
controllerIconStyle?: ControllerIconStyle
onSelect?: (id: string) => void
children?: ReactNode
}) {
const Component = as ?? (onSelect ? 'button' : 'div')
const maxHealth = effectiveMaxHealth(member)
const className = [
baseClassName,
selected ? selectedClassName : '',
member.health <= 0 ? deadClassName : '',
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox',
} : null
return (
<Component
className={className}
data-party-member-id={member.id}
onClick={onSelect ? () => onSelect(member.id) : undefined}
aria-pressed={onSelect ? selected : undefined}
type={Component === 'button' ? 'button' : undefined}
>
{showTargetMarker && (
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
)}
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div>
<div className="bar member-health">
<span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
{action && (
<div className="member-target-key">
<ControllerBindingLabel
binding={action.binding}
iconStyle={action.iconStyle}
/>
</div>
)}
<MemberEffects member={member} mode={effectMode} />
{children}
</Component>
)
})