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.
441 lines
21 KiB
441 lines
21 KiB
import {animated, useSpring} from "@react-spring/web";
|
|
import {ReactElement, useCallback, useMemo, useState} from "react";
|
|
import {evaluateResourceBarStyles, ResourceBarColors, ResourceBarStyles} from "./ResourceBarGradient";
|
|
import {isDefined} from "../types/type_check";
|
|
import {SpringyValueInterpolatables, useSpringyValue} from "./SpringyValueHook";
|
|
import "./CharacterStatus.css";
|
|
import DefaultPortrait from "./default-portrait.svg";
|
|
import DefaultStatus from "./default-status.svg";
|
|
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
|
|
import Tooltip from "react-bootstrap/Tooltip";
|
|
import {altTo as to} from "./FixedInterpolation";
|
|
import {
|
|
Character,
|
|
CharacterHealth,
|
|
CharacterTurnState,
|
|
healthToBounds,
|
|
healthToFraction,
|
|
hpToHealth, spTypeToDescription, turnStateToDescription,
|
|
turnStateToTitle
|
|
} from "../model/Character";
|
|
import {StatusEffect} from "../model/GameState";
|
|
import React from "react";
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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, minions, statuses, activeId, collapsed, click}: {character: Character, minions: readonly Character[], statuses: readonly StatusEffect[], activeId: string, collapsed?: boolean, click?: () => void}): ReactElement {
|
|
const [showMinions, setShowMinions] = useState<boolean>(false)
|
|
|
|
const toggleMinions = useCallback(() => {
|
|
setShowMinions(!showMinions)
|
|
}, [showMinions, setShowMinions])
|
|
|
|
const {id, name, altName, leader, level, health, koText} = 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,
|
|
starting: 0,
|
|
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)}`)
|
|
: "",
|
|
hpBarStyleInterpolated: evaluateResourceBarStyles(hpBarStyle, hpInterpolate),
|
|
hpTextStyleInterpolated: {
|
|
color: to([hpRecentSpring], recentValue => healthToColor(hpToHealth({hp: recentValue, maxHp})))
|
|
}
|
|
}
|
|
} else {
|
|
return {}
|
|
}
|
|
}, [hp, health, maxHp, hpRecentSpring, hpInterpolate])
|
|
|
|
const {mp, maxMp} = character
|
|
const {springs: [, , {v: mpRecentSpring}], interpolate: mpInterpolate} = useSpringyValue({
|
|
current: mp,
|
|
max: maxMp,
|
|
starting: 0,
|
|
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,
|
|
starting: 0,
|
|
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, spBank, spType} = character
|
|
const {springs: [, , {v: spRecentSpring}]} = useSpringyValue({
|
|
current: sp,
|
|
starting: 0,
|
|
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 {bp, maxBp} = character
|
|
const {springs: [, , {v: bpRecentSpring}]} = useSpringyValue({
|
|
current: bp,
|
|
starting: 0,
|
|
flash: isDefined(bp) && bp > 0,
|
|
})
|
|
const {bpText} = useMemo(() => {
|
|
if (isDefined(bp) && isDefined(maxBp)) {
|
|
return {
|
|
bpText: to([bpRecentSpring], (recentValue) => recentValue.toFixed(0))
|
|
}
|
|
} else {
|
|
return {}
|
|
}
|
|
}, [bp, maxBp, bpRecentSpring])
|
|
|
|
const {zp, maxZp} = character
|
|
const {springs: [, , {v: zpRecentSpring}]} = useSpringyValue({
|
|
current: zp,
|
|
max: maxZp,
|
|
starting: 0,
|
|
flash: isDefined(maxZp) && isDefined(zp) && zp >= maxZp,
|
|
})
|
|
const {zpStyle} = useMemo(() => {
|
|
if (isDefined(zp) && isDefined(maxZp)) {
|
|
return {
|
|
zpStyle: {
|
|
bottom: 0,
|
|
top: "auto",
|
|
height: to([zpRecentSpring],(recentValue) => {
|
|
return ((225/104) + (600 * ((recentValue / maxZp) ** 2) / 13) + (50 * (recentValue / maxZp))).toFixed(3) + "%"
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
return {}
|
|
}
|
|
}, [zp, maxZp, zpRecentSpring])
|
|
const {turnsLeft, turnsTotal, canAct} = character
|
|
const {turnsState, turnsText} = useMemo(() => {
|
|
if (!isDefined(turnsTotal) || !isDefined(turnsLeft)) {
|
|
return {
|
|
turnsState: CharacterTurnState.None
|
|
}
|
|
} else if (activeId === id) {
|
|
return {
|
|
turnsState: CharacterTurnState.Active,
|
|
turnsText: "🞂",
|
|
}
|
|
} else if (effectiveHp === 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,
|
|
}
|
|
}
|
|
}, [activeId, id, effectiveHp, canAct, turnsLeft, turnsTotal])
|
|
|
|
const {portraitUrl} = character
|
|
const effectivePortraitUrl = portraitUrl ?? DefaultPortrait
|
|
const portraitFilterInterpolated = useMemo(() => {
|
|
return to([hpRecentSpring], recentValue => {
|
|
const filter = {
|
|
color: 100,
|
|
brightness: 100,
|
|
showKOBar: false,
|
|
}
|
|
if (isDefined(effectiveMaxHp) && Math.round(recentValue) < 1 && effectiveMaxHp > 0) {
|
|
filter.color *= 0.35
|
|
filter.brightness *= 0.40
|
|
filter.showKOBar = true
|
|
} else if (canAct === false || turnsTotal === 0) {
|
|
filter.color *= 1
|
|
filter.brightness *= 0.40
|
|
}
|
|
return filter
|
|
})
|
|
}, [hpRecentSpring, effectiveMaxHp, turnsTotal, canAct])
|
|
const {brightness: brightnessSpring, grayscale: grayscaleSpring} = useSpring({
|
|
grayscale: to([portraitFilterInterpolated], ({color}) => 100 - color),
|
|
brightness: to([portraitFilterInterpolated], ({brightness}) => brightness),
|
|
})
|
|
const {opacity: koOpacitySpring} = useSpring({
|
|
opacity: to([portraitFilterInterpolated], ({showKOBar}) => showKOBar ? 100 : 0)
|
|
})
|
|
const characterKOBarStyleInterpolated = useMemo(() => {
|
|
return {
|
|
opacity: to([koOpacitySpring], (opacity: number) => `${opacity}%`)
|
|
}
|
|
}, [koOpacitySpring])
|
|
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 characterStatuses = character.statuses ?? []
|
|
const effectiveStatuses = characterStatuses.map((statusInstance) => ({
|
|
...statuses.find(s => s.id === statusInstance.id) ?? {id: statusInstance.id, name: statusInstance.id, description: "Unrecognized status effect.", iconUrl: undefined}, count: statusInstance.count}))
|
|
|
|
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 <React.Fragment>
|
|
<div className={"characterStatus" + (minions.length > 0 && !showMinions ? " leader" : leader ? " minion" : "") + (collapsed ? " collapsed" : "")} onClick={() => click ? click() : null}>
|
|
{minions.length > 0 && !showMinions && <CharacterStatus character={minions[0]} minions={[]} statuses={statuses} activeId={activeId} collapsed={true} click={toggleMinions} />}
|
|
<animated.div className={"characterPortrait"} style={characterPortraitStyleInterpolated} />
|
|
{isDefined(maxZp) && isDefined(zp) && <OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
|
|
<Tooltip>
|
|
<div className={"characterHelpHeader"}>
|
|
<span className={"characterHelpName"}>Zero Charge</span>
|
|
<span className={"characterHelpValue"}>{(100 * zp / maxZp).toFixed(0)}% - {zp}/{maxZp}</span></div>
|
|
{<div className={"characterHelpDescription"}>
|
|
The amount of energy stored up towards unleashing the might of a Zero Power. When the gauge is full, let loose!
|
|
</div>}
|
|
</Tooltip>
|
|
} placement={"right"}>
|
|
<div className={"characterZeroGauge"}>
|
|
<div className={"characterZeroBarBack"} />
|
|
<animated.div className={"characterZeroBar"} style={zpStyle} />
|
|
<div className={"characterZeroBarPulse" + (zp >= maxZp ? " active" : "")} />
|
|
</div>
|
|
</OverlayTrigger>}
|
|
{isDefined(effectiveMaxHp) && effectiveHp < 1 && effectiveMaxHp > 0 && <animated.div className={"characterKOBar"} style={characterKOBarStyleInterpolated}>{koText ?? "KO"}</animated.div>}
|
|
{isDefined(turnsState) &&
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} 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 ?? altName ?? "???"}</div>
|
|
</div>
|
|
{isDefined(hpText) &&
|
|
<div className={"characterHp"}>
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={hpTooltip} placement={"top"}>
|
|
<animated.div className={"characterHpBar"} style={hpBarStyleInterpolated} />
|
|
</OverlayTrigger>
|
|
{isDefined(hp) && <animated.div
|
|
className={"characterHpValue"}
|
|
style={hpTextStyleInterpolated}>{hpText}</animated.div>}
|
|
</div>}
|
|
{isDefined(mpText) &&
|
|
<div className={"characterMp"}>
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={mpTooltip} placement={"top"}>
|
|
<animated.div className={"characterMpBar"} style={mpBarStyleInterpolated} />
|
|
</OverlayTrigger>
|
|
<animated.div className={"characterMpValue"}>{mpText}</animated.div>
|
|
</div>}
|
|
{isDefined(ipText) &&
|
|
<div className={"characterIp"}>
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={ipTooltip} placement={"top"}>
|
|
<animated.div className={"characterIpBar"} style={ipBarStyleInterpolated} />
|
|
</OverlayTrigger>
|
|
<animated.div className={"characterIpValue"}>{ipText}</animated.div>
|
|
</div>
|
|
}
|
|
{isDefined(spText) &&
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
|
|
<Tooltip>
|
|
<div className={"characterHelpHeader"}>
|
|
<span className={"characterHelpName"}>{spType} Points</span>
|
|
<span className={"characterHelpValue"}>{sp}{typeof spBank === "number" ? ` / ${spBank} banked`: null}</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(bpText) &&
|
|
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
|
|
<Tooltip>
|
|
<div className={"characterHelpHeader"}>
|
|
<span className={"characterHelpName"}>Blood Points</span>
|
|
<span className={"characterHelpValue"}>{bp}/{maxBp}</span></div>
|
|
{isDefined(spType) && <div className={"characterHelpDescription"}>
|
|
The current number of blood points, used for Vampire abilities.
|
|
</div>}
|
|
</Tooltip>
|
|
} placement={"right"}>
|
|
<animated.div className={"characterBp"}>
|
|
<animated.span className={"characterBpValue"}>
|
|
{bpText}</animated.span>
|
|
</animated.div>
|
|
</OverlayTrigger>}
|
|
{isDefined(effectiveStatuses) && effectiveStatuses.length > 0 &&
|
|
<div className={"characterStatuses"}>
|
|
{effectiveStatuses.map(({id, name, count, description, iconUrl}) =>
|
|
<OverlayTrigger key={id} delay={{show: 300, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
|
|
<Tooltip>
|
|
<div className={"characterStatusHeader"}>
|
|
<span className={"characterStatusName"}>{name}</span>
|
|
{isDefined(count) && count > 0 && <span className={"characterStatusCount"}>{count}</span>}</div>
|
|
{isDefined(description) && <div className={"characterStatusDescription"}>
|
|
{count > 0 ? description.replaceAll("%c%", count.toFixed(0)) : description}
|
|
</div>}
|
|
</Tooltip>
|
|
} placement={"bottom"}>
|
|
<div className={"characterStatusIcon"} style={{backgroundImage: `url("${iconUrl ?? DefaultStatus}")`}}>{count > 0 && <span className={"characterStatusIconCountBadge"}>{count}</span>}</div>
|
|
</OverlayTrigger>
|
|
)}
|
|
</div>}
|
|
</div>
|
|
{minions.length > 0 && showMinions && minions.map((minion) =>
|
|
<CharacterStatus character={minion} minions={[]} statuses={statuses} key={minion.id}
|
|
activeId={activeId} collapsed={false} click={toggleMinions} />
|
|
)}
|
|
</React.Fragment>
|
|
}
|
|
|