Tracker made in React for keeping track of HP and MP and so on.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
fabula-ultima-react/src/CharacterStatus.tsx

495 lines
20 KiB

import {animated, useSpring} from "@react-spring/web";
import {ReactElement, useMemo} from "react";
import {evaluateResourceBarStyles, ResourceBarColors, ResourceBarStyles} from "./resource_bar";
import {isDefined} from "./type_check";
import {SpringyValueInterpolatables, useSpringyValue} from "./SpringyValueHook";
import "./CharacterStatus.css";
import DefaultPortrait from "./default-portrait.svg";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Tooltip from "react-bootstrap/Tooltip";
import {to} from "@react-spring/web";
export enum CharacterHealth {
Full = "Full",
Healthy = "Healthy",
Wounded = "Wounded",
Crisis = "Crisis",
Peril = "Peril",
KO = "KO",
}
export function healthToColor(health: CharacterHealth | undefined): string {
switch (health) {
case CharacterHealth.Full:
return "#cfc"
case CharacterHealth.Healthy:
return "#efe"
case CharacterHealth.Crisis:
return "#ffa"
case CharacterHealth.Peril:
return "#faa"
case CharacterHealth.KO:
return "#f66"
case CharacterHealth.Wounded:
default:
return "#fff"
}
}
export function healthToFraction(health: CharacterHealth | undefined): number {
switch (health) {
case CharacterHealth.Full:
return 1
case CharacterHealth.Healthy:
return 0.95
case CharacterHealth.Crisis:
return 0.40
case CharacterHealth.Peril:
return 0.05
case CharacterHealth.KO:
return 0
case CharacterHealth.Wounded:
case undefined:
default:
return 0.75
}
}
export function hpToHealth(hp: number | undefined, maxHp: number | undefined): CharacterHealth | undefined {
if (!(isDefined(hp) && isDefined(maxHp)) || maxHp <= 0) {
return undefined
}
if (Math.round(hp) >= maxHp) {
return CharacterHealth.Full
} else if (Math.round(hp) * 10 >= maxHp * 9) {
return CharacterHealth.Healthy
} else if (Math.round(hp) * 2 > maxHp) {
return CharacterHealth.Wounded
} else if (Math.round(hp) * 10 > maxHp) {
return CharacterHealth.Crisis
} else if (Math.round(hp) >= 1) {
return CharacterHealth.Peril
} else {
return CharacterHealth.KO
}
}
export function healthToBounds(health: CharacterHealth | undefined): string {
switch (health) {
case CharacterHealth.Full:
return "100%"
case CharacterHealth.Healthy:
return "90-99%"
case CharacterHealth.Wounded:
return "51-99%"
case CharacterHealth.Crisis:
return "11-50%"
case CharacterHealth.Peril:
return "1-10%"
case CharacterHealth.KO:
return "0%"
default:
return "???"
}
}
export enum CharacterTurnState {
None = "None",
Ready = "Ready",
HighTurns = "HighTurns",
Done = "Done",
CantAct = "CantAct",
Downed = "Downed",
Active = "Active",
}
export function turnStateToTitle(state: CharacterTurnState): string {
switch (state) {
case CharacterTurnState.Active:
return "Active"
case CharacterTurnState.Ready:
return "Ready"
case CharacterTurnState.HighTurns:
return "Multiple Turns"
case CharacterTurnState.Done:
return "Done"
case CharacterTurnState.CantAct:
return "Can't Act"
case CharacterTurnState.Downed:
return "Downed"
case CharacterTurnState.None:
default:
return "None"
}
}
export function turnStateToDescription(state: CharacterTurnState): string {
switch (state) {
case CharacterTurnState.Active:
return "Currently taking a turn."
case CharacterTurnState.Ready:
return "Has not acted yet this round."
case CharacterTurnState.HighTurns:
return "Has %c% turns left out of %m% turns. Must still alternate with opponents."
case CharacterTurnState.Done:
return "Has finished acting this round."
case CharacterTurnState.CantAct:
return "Is currently unable to act."
case CharacterTurnState.Downed:
return "Has 0 HP. Is currently down and out of the action and unable to act."
case CharacterTurnState.None:
default:
return "Cannot take turns."
}
}
export enum SPType {
UltimaPoints = "Ultima",
FabulaPoints = "Fabula",
}
function spTypeToDescription(sp: SPType): string {
switch (sp) {
case SPType.UltimaPoints:
return ("The number of Ultima Points. Ultima Points can be used to make a getaway, "
+ "recover MP and clear status effects, or perform special villainy moves.")
case SPType.FabulaPoints:
return ("The number of Fabula Points. Fabula Points can be used to buy rerolls by "
+ "invoking your Traits, boost your rolls by invoking your Bonds, or add elements to the story.")
}
}
export interface StatusEffect {
readonly name: string
readonly count?: number
readonly iconUrl: string
readonly description?: string
}
export interface Character {
readonly portraitUrl?: string
readonly name?: string
readonly level?: number
readonly hp?: number
readonly maxHp?: number
readonly health?: CharacterHealth
readonly mp?: number
readonly maxMp?: number
readonly ip?: number
readonly maxIp?: number
readonly sp?: number
readonly spType?: SPType
readonly turnsLeft?: number
readonly turnsTotal?: number
readonly canAct?: boolean
readonly statuses?: readonly StatusEffect[]
}
const hpBarStyle: SpringyValueInterpolatables<ResourceBarStyles> = {
foreground: 'linear-gradient(to bottom, rgb(255, 255, 255, 0.1) 0%, rgb(0, 0, 0, 0) 30% 50%, rgb(0, 0, 0, 0.1) 80%, rgb(0, 0, 0, 0.2) 95%, rgb(0, 0, 0, 0.3) 100%)',
barDirection: "to right",
barColors: ({flashValue}: {flashValue: number}): ResourceBarColors => {
return {
emptiedColor: `rgb(${Math.min(1, Math.max(flashValue, 0)) * 55 + 55}, 55, 55)`,
toEmptyColor: 'rgb(200, 0, 0)',
toFillColor: 'rgb(150, 250, 250)',
filledColor: 'rgb(0, 200, 0)',
}
},
}
const mpBarStyle: SpringyValueInterpolatables<ResourceBarStyles> = {
foreground: 'linear-gradient(to bottom, rgb(255, 255, 255, 0.1) 0%, rgb(0, 0, 0, 0) 30% 50%, rgb(0, 0, 0, 0.1) 80%, rgb(0, 0, 0, 0.2) 95%, rgb(0, 0, 0, 0.3) 100%)',
barColors: ({flashValue}: {flashValue: number}): ResourceBarColors => {
return {
emptiedColor: `rgb(${Math.min(1, Math.max(flashValue, 0)) * 55 + 55}, 55, 55)`,
toEmptyColor: 'rgb(250, 250, 60)',
toFillColor: 'rgb(150, 250, 250)',
filledColor: 'rgb(0, 150, 255)',
}
},
}
const ipBarStyle: SpringyValueInterpolatables<ResourceBarStyles> = {
foreground: 'linear-gradient(to bottom, rgb(255, 255, 255, 0.1) 0%, rgb(0, 0, 0, 0) 30% 50%, rgb(0, 0, 0, 0.1) 80%, rgb(0, 0, 0, 0.2) 95%, rgb(0, 0, 0, 0.3) 100%)',
barColors: ({flashValue}: {flashValue: number}): ResourceBarColors => {
return {
emptiedColor: `rgb(${Math.min(1, Math.max(flashValue, 0)) * 55 + 55}, 55, 55)`,
toEmptyColor: 'rgb(160, 55, 195)',
toFillColor: 'rgb(250, 250, 60)',
filledColor: 'rgb(255, 150, 55)',
}
},
}
export function CharacterStatus({character, active}: {character: Character, active: boolean}): ReactElement {
const {name, level, health, statuses} = character
const {hp, maxHp} = character
const effectiveMaxHp = maxHp ?? 100
const effectiveHp = hp ?? (healthToFraction(health) * effectiveMaxHp)
const {springs: [, , {v: hpRecentSpring}], flashSpring: {v: hpFlashSpring}, interpolate: hpInterpolate} = useSpringyValue({
current: effectiveHp,
max: effectiveMaxHp,
flash: effectiveHp * 2 <= effectiveMaxHp && effectiveHp > 0,
})
const {hpText, hpTextStyleInterpolated, hpBarStyleInterpolated} = useMemo(() => {
if ((isDefined(hp) && isDefined(maxHp)) || isDefined(health)) {
return {
hpText: isDefined(hp)
? to([hpRecentSpring], recentValue => `${Math.round(recentValue)}`)
: to([hpRecentSpring], recentValue => hpToHealth(recentValue, maxHp) ?? "???"),
hpBarStyleInterpolated: evaluateResourceBarStyles(hpBarStyle, hpInterpolate),
hpTextStyleInterpolated: {
color: to([hpRecentSpring], recentValue => healthToColor(hpToHealth(recentValue, maxHp)))
}
}
} else {
return {}
}
}, [hp, health, maxHp, hpRecentSpring, hpInterpolate])
const {mp, maxMp} = character
const {springs: [, , {v: mpRecentSpring}], interpolate: mpInterpolate} = useSpringyValue({
current: mp,
max: maxMp,
flash: false,
})
const {mpText, mpBarStyleInterpolated} = useMemo(() => {
if (isDefined(mp) && isDefined(maxMp) && maxMp > 0) {
return {
mpText: to([mpRecentSpring], (recentValue) => `${Math.round(recentValue)}`),
mpBarStyleInterpolated: evaluateResourceBarStyles(mpBarStyle, mpInterpolate),
}
} else {
return {}
}
}, [mp, maxMp, mpRecentSpring, mpInterpolate])
const {ip, maxIp} = character
const {springs: [, , {v: ipRecentSpring}], interpolate: ipInterpolate} = useSpringyValue({
current: ip,
max: maxIp,
flash: false,
})
const {ipText, ipBarStyleInterpolated} = useMemo(() => {
if (isDefined(ip) && isDefined(maxIp) && maxIp > 0) {
return {
ipText: to([ipRecentSpring], recentValue => `${Math.round(recentValue)}`),
ipBarStyleInterpolated: evaluateResourceBarStyles(ipBarStyle, ipInterpolate),
}
} else {
return {}
}
}, [ip, maxIp, ipRecentSpring, ipInterpolate])
const {sp, spType} = character
const {springs: [, , {v: spRecentSpring}]} = useSpringyValue({
current: sp,
flash: isDefined(spType) && isDefined(sp) && sp > 0,
})
const {spText} = useMemo(() => {
if (isDefined(sp) && isDefined(spType)) {
return {
spText: to([spRecentSpring], (recentValue) => recentValue.toFixed(0))
}
} else {
return {}
}
}, [sp, spType, spRecentSpring])
const {turnsLeft, turnsTotal, canAct} = character
const {turnsState, turnsText} = useMemo(() => {
if (!isDefined(turnsTotal) || !isDefined(turnsLeft)) {
return {
turnsState: CharacterTurnState.None
}
} else if (active) {
return {
turnsState: CharacterTurnState.Active,
turnsText: "🞂",
}
} else if (hp === 0 && isDefined(maxHp) && maxHp > 0) {
return {
turnsState: CharacterTurnState.Downed,
turnsText: (isDefined(turnsTotal) && turnsLeft === 0) ? "✓" : "",
}
} else if (canAct === false || turnsTotal === 0) {
return {
turnsState: CharacterTurnState.CantAct,
}
} else if (turnsLeft === 0) {
return {
turnsState: CharacterTurnState.Done,
turnsText: "✓"
}
} else if (turnsTotal > 1) {
return {
turnsState: CharacterTurnState.HighTurns,
turnsText: `${turnsLeft}`
}
} else {
return {
turnsState: CharacterTurnState.Ready,
}
}
}, [active, hp, maxHp, canAct, turnsLeft, turnsTotal])
const {portraitUrl} = character
const effectivePortraitUrl = portraitUrl ?? DefaultPortrait
const portraitFilterInterpolated = useMemo(() => {
return to([hpRecentSpring], recentValue => {
const filter = {
color: 100,
brightness: 100,
}
if (isDefined(effectiveMaxHp) && Math.round(recentValue) < 1 && effectiveMaxHp > 0) {
filter.color *= 0.50
filter.brightness *= 0.25
}
if (canAct === false || turnsTotal === 0) {
filter.color *= 0.50
filter.brightness *= 0.50
}
if (isDefined(turnsTotal) && turnsLeft === 0) {
filter.color *= 0.75
filter.brightness *= 0.75
}
return filter
})
}, [hpRecentSpring, effectiveMaxHp, turnsTotal, turnsLeft, canAct])
const {brightness: brightnessSpring, grayscale: grayscaleSpring} = useSpring({
grayscale: to([portraitFilterInterpolated], ({color}) => 100 - color),
brightness: to([portraitFilterInterpolated], ({brightness}) => brightness),
})
const characterPortraitStyleInterpolated = useMemo(() => {
return {
backgroundImage: to([hpFlashSpring], (flashValue: number) => {
return `radial-gradient(closest-side, rgb(75% 0% 0% / ${Math.round(100 * flashValue)}%), transparent), url("${effectivePortraitUrl}")`
}),
filter: to([brightnessSpring, grayscaleSpring],
(brightness: number, grayscale: number) =>
`grayscale(${grayscale}%) brightness(${brightness}%)`)
}
}, [brightnessSpring, grayscaleSpring, hpFlashSpring, effectivePortraitUrl])
const hpTooltip = <Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>{isDefined(maxHp) && isDefined(hp) ? "Health Points" : health}</span>
<span className={"characterHelpValue"}>{isDefined(maxHp) && isDefined(hp) ? `${hp}/${maxHp}` : (healthToBounds(health))}</span></div>
{<div className={"characterHelpDescription"}>
{isDefined(hp)
? "Health Points, or HP. HP represent a character's determination and will. "
+ "HP are lost when taking damage. When a character's HP reach 0, they are defeated."
: "The enemy's condition, giving a rough estimate of its current HP."}
</div>}
</Tooltip>
const mpTooltip = <Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>Mind Points</span>
{isDefined(maxMp) && isDefined(mp) && <span className={"characterHelpValue"}>{mp}/{maxMp}</span>}
</div>
<div className={"characterHelpDescription"}>
Mind Points, or MP. MP represent a character's focus and energy. MP are spent to use all manner of abilities.
</div>
</Tooltip>
const ipTooltip = <Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>Inventory Points</span>
{isDefined(maxIp) && isDefined(ip) && <span className={"characterHelpValue"}>{ip}/{maxIp}</span>}
</div>
<div className={"characterHelpDescription"}>
Inventory Points, or IP. IP represent a character's stock of prepared items. IP are spent to use items.
</div>
</Tooltip>
return <div className="characterStatus">
<animated.div className={"characterPortrait"} style={characterPortraitStyleInterpolated} />
{isDefined(turnsState) &&
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={
<Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>{turnStateToTitle(turnsState)}</span>
{isDefined(turnsTotal) && isDefined(turnsLeft) && turnsTotal > 1 && <span className={"characterHelpValue"}>{turnsLeft}/{turnsTotal}</span>}</div>
{<div className={"characterHelpDescription"}>
{isDefined(turnsTotal) && isDefined(turnsLeft) &&
turnStateToDescription(turnsState)
.replaceAll("%c%", turnsLeft.toFixed(0))
.replaceAll("%m%", turnsTotal.toFixed(0))}
</div>}
</Tooltip>
} placement={"right"}>
<div className={"characterTurns characterTurns" + turnsState}>{turnsText}</div>
</OverlayTrigger>}
<div className={"characterHeader"}>
<div className="characterLevel">
<span className="characterLevelLabel">Lv</span>
<span className="characterLevelValue">{level ?? "??"}</span>
</div>
<div className={"characterName"}>{name ?? "???"}</div>
</div>
{isDefined(hpText) &&
<div className={"characterHp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={hpTooltip} placement={"top"}>
<animated.div className={"characterHpBar"} style={hpBarStyleInterpolated} />
</OverlayTrigger>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={hpTooltip} placement={"top"}>
<animated.div
className={isDefined(hp) ? "characterHpValue" : "characterHealthText"}
style={hpTextStyleInterpolated}>{hpText}</animated.div>
</OverlayTrigger>
</div>}
{isDefined(mpText) &&
<div className={"characterMp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={mpTooltip} placement={"top"}>
<animated.div className={"characterMpBar"} style={mpBarStyleInterpolated} />
</OverlayTrigger>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={mpTooltip} placement={"top"}>
<animated.div className={"characterMpValue"}>{mpText}</animated.div>
</OverlayTrigger>
</div>}
{isDefined(ipText) &&
<div className={"characterIp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={ipTooltip} placement={"top"}>
<animated.div className={"characterIpBar"} style={ipBarStyleInterpolated} />
</OverlayTrigger>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={ipTooltip} placement={"top"}>
<animated.div className={"characterIpValue"}>{ipText}</animated.div>
</OverlayTrigger>
</div>
}
{isDefined(spText) &&
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={
<Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>{spType} Points</span>
<span className={"characterHelpValue"}>{sp}</span></div>
{isDefined(spType) && <div className={"characterHelpDescription"}>
{spTypeToDescription(spType)}
</div>}
</Tooltip>
} placement={"right"}>
<animated.div className={"characterSp characterSp" + spType}>
<animated.span className={"characterSpValue characterSpValue" + spType}>
{spText}</animated.span>
</animated.div>
</OverlayTrigger>}
{isDefined(statuses) &&
<div className={"characterStatuses"}>
{statuses.map(({name, count, description, iconUrl}) =>
<OverlayTrigger key={iconUrl} delay={{show: 300, hide: 0}} overlay={
<Tooltip>
<div className={"characterStatusHeader"}>
<span className={"characterStatusName"}>{name}</span>
{isDefined(count) && <span className={"characterStatusCount"}>{count}</span>}</div>
{isDefined(description) && <div className={"characterStatusDescription"}>
{isDefined(count) ? description.replaceAll("%c%", count.toFixed(0)) : description}
</div>}
</Tooltip>
} placement={"bottom"}>
<div className={"characterStatusIcon"} style={{backgroundImage: `url("${iconUrl}")`}}><span className={"characterStatusIconCountBadge"}>{count}</span></div>
</OverlayTrigger>
)}
</div>}
</div>
}