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 = { 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 = { 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 = { 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 =
{isDefined(maxHp) && isDefined(hp) ? "Health Points" : health} {isDefined(maxHp) && isDefined(hp) ? `${hp}/${maxHp}` : (healthToBounds(health))}
{
{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."}
}
const mpTooltip =
Mind Points {isDefined(maxMp) && isDefined(mp) && {mp}/{maxMp}}
Mind Points, or MP. MP represent a character's focus and energy. MP are spent to use all manner of abilities.
const ipTooltip =
Inventory Points {isDefined(maxIp) && isDefined(ip) && {ip}/{maxIp}}
Inventory Points, or IP. IP represent a character's stock of prepared items. IP are spent to use items.
return
{isDefined(turnsState) &&
{turnStateToTitle(turnsState)} {isDefined(turnsTotal) && isDefined(turnsLeft) && turnsTotal > 1 && {turnsLeft}/{turnsTotal}}
{
{isDefined(turnsTotal) && isDefined(turnsLeft) && turnStateToDescription(turnsState) .replaceAll("%c%", turnsLeft.toFixed(0)) .replaceAll("%m%", turnsTotal.toFixed(0))}
} } placement={"right"}>
{turnsText}
}
Lv {level ?? "??"}
{name ?? "???"}
{isDefined(hpText) &&
{hpText}
} {isDefined(mpText) &&
{mpText}
} {isDefined(ipText) &&
{ipText}
} {isDefined(spText) &&
{spType} Points {sp}
{isDefined(spType) &&
{spTypeToDescription(spType)}
} } placement={"right"}> {spText}
} {isDefined(statuses) &&
{statuses.map(({name, count, description, iconUrl}) =>
{name} {isDefined(count) && {count}}
{isDefined(description) &&
{isDefined(count) ? description.replaceAll("%c%", count.toFixed(0)) : description}
} } placement={"bottom"}>
{count}
)}
}
}