155 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
})
|