@ -1,16 +1,13 @@
import { animated } from "@react-spring/web" ;
import { animated , useSpring } from "@react-spring/web" ;
import { ReactElement , useMemo } from "react" ;
import {
evaluateResourceBarStyles ,
ResourceBarColors ,
ResourceBarStyles
} from "./resource_bar" ;
import { evaluateResourceBarStyles , ResourceBarColors , ResourceBarStyles } from "./resource_bar" ;
import { isDefined } from "./type_check" ;
import {
SpringyValueInterpolatables ,
useSpringyValue
} from "./SpringyValueHook" ;
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" ,
@ -21,13 +18,129 @@ export enum CharacterHealth {
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" ,
KO = "KO" ,
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 {
@ -35,28 +148,41 @@ export enum SPType {
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 {
name : string
iconUrl : string
readonly name : string
readonly count? : number
readonly iconUrl : string
readonly description? : string
}
export interface Character {
portraitUrl? : string
name? : string
level? : number
hp? : number
maxHp? : number
health? : CharacterHealth
mp? : number
maxMp? : number
ip? : number
maxIp? : number
sp? : number
spType? : SPType
turnsLeft? : number
turnsTotal? : number
canAct? : boolean
statuses? : StatusEffect [ ]
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 > = {
@ -96,28 +222,35 @@ const ipBarStyle: SpringyValueInterpolatables<ResourceBarStyles> = {
} ,
}
export function CharacterStatus ( { character } : { character : Character } ) : ReactElement {
const { name , level , health } = character
export function CharacterStatus ( { character , active } : { character : Character , active : boolean } ) : ReactElement {
const { name , level , health , statuses } = character
const { hp , maxHp } = character
const { interpolate : hpInterpolate } = useSpringyValue ( {
current : hp ,
max : maxHp ,
flash : isDefined ( maxHp ) && isDefined ( hp ) && hp * 2 < maxHp && hp > 0 ,
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 , hpBarStyleInterpolated } = useMemo ( ( ) = > {
if ( isDefined ( hp ) && isDefined ( maxHp ) && maxHp > 0 ) {
const { hpText , hpTextStyleInterpolated , hp BarStyleInterpolated } = useMemo ( ( ) = > {
if ( ( isDefined ( hp ) && isDefined ( maxHp ) ) || isDefined ( health ) ) {
return {
hpText : hpInterpolate ( ( { recentValue } ) = > ` ${ Math . round ( recentValue ) } ` ) ,
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 , maxHp , hpInterpolate ] )
} , [ hp , health , maxHp , hpRecentSpring , hpInterpolate ] )
const { mp , maxMp } = character
const { interpolate : mpInterpolate } = useSpringyValue ( {
const { springs : [ , , { v : mpRecentSpring } ] , interpolate : mpInterpolate } = useSpringyValue ( {
current : mp ,
max : maxMp ,
flash : false ,
@ -125,16 +258,16 @@ export function CharacterStatus({character}: {character: Character}): ReactEleme
const { mpText , mpBarStyleInterpolated } = useMemo ( ( ) = > {
if ( isDefined ( mp ) && isDefined ( maxMp ) && maxMp > 0 ) {
return {
mpText : mpInterpolate ( ( { recentValue } ) = > ` ${ Math . round ( recentValue ) } ` ) ,
mpText : to ( [ mpRecentSpring ] , ( recentValue ) = > ` ${ Math . round ( recentValue ) } ` ) ,
mpBarStyleInterpolated : evaluateResourceBarStyles ( mpBarStyle , mpInterpolate ) ,
}
} else {
return { }
}
} , [ mp , maxMp , mpInterpolate ] )
} , [ mp , maxMp , mpRecentSpring , mp Interpolate ] )
const { ip , maxIp } = character
const { interpolate : ipInterpolate } = useSpringyValue ( {
const { springs : [ , , { v : ipRecentSpring } ] , interpolate : ipInterpolate } = useSpringyValue ( {
current : ip ,
max : maxIp ,
flash : false ,
@ -142,91 +275,221 @@ export function CharacterStatus({character}: {character: Character}): ReactEleme
const { ipText , ipBarStyleInterpolated } = useMemo ( ( ) = > {
if ( isDefined ( ip ) && isDefined ( maxIp ) && maxIp > 0 ) {
return {
ipText : ipInterpolate ( ( { recentValue } ) = > ` ${ Math . round ( recentValue ) } ` ) ,
ipText : to ( [ ipRecentSpring ] , recentValue = > ` ${ Math . round ( recentValue ) } ` ) ,
ipBarStyleInterpolated : evaluateResourceBarStyles ( ipBarStyle , ipInterpolate ) ,
}
} else {
return { }
}
} , [ ip , maxIp , ipInterpolate ] )
} , [ ip , maxIp , ipRecentSpring , ip Interpolate ] )
const { sp , spType } = character
const { interpolate : spInterpolate } = useSpringyValue ( {
const { springs : [ , , { v : spRecentSpring } ] } = useSpringyValue ( {
current : sp ,
flash : isDefined ( spType ) && isDefined ( sp ) && sp > 0 ,
} )
const { spText } = useMemo ( ( ) = > {
if ( isDefined ( sp ) && isDefined ( spType ) ) {
return {
spText : spInterpolate ( ( { recentValue } ) = > recentValue . toFixed ( 0 ) )
spText : to ( [ spRecentSpring ] , ( recentValue ) = > recentValue . toFixed ( 0 ) )
}
} else {
return { }
}
} , [ sp , spType , spInterpolate ] )
} , [ sp , spType , spRecentSpring ] )
const { turnsLeft , turnsTotal , canAct } = character
const { turnsState , turnsText } = useMemo ( ( ) = > {
if ( isDefined ( turnsTotal ) && hp === 0 && isDefined ( maxHp ) && maxHp > 0 ) {
if ( ! isDefined ( turnsTotal ) || ! isDefined ( turnsLeft ) ) {
return {
turnsState : CharacterTurnState.KO ,
turnsState : CharacterTurnState.None
}
} else if ( isDefined ( turnsTotal ) && ( canAct === false || turnsTotal === 0 ) ) {
} else if ( active ) {
return {
turnsState : CharacterTurnState.CantAct ,
turnsState : CharacterTurnState.Active ,
turnsText : "🞂" ,
}
} else if ( isDefined ( turnsTotal ) && turnsLeft === 0 ) {
} else if ( hp === 0 && isDefined ( maxHp ) && maxHp > 0 ) {
return {
turnsState : CharacterTurnState.Done ,
turnsState : CharacterTurnState.Downed ,
turnsText : ( isDefined ( turnsTotal ) && turnsLeft === 0 ) ? "✓" : "" ,
}
} else if ( turnsTotal === 1 && turnsLeft === 1 ) {
} else if ( canAct === false || turnsTotal === 0 ) {
return {
turnsState : CharacterTurnState.Ready ,
turnsState : CharacterTurnState.CantAct ,
}
} else if ( isDefined ( turnsTotal ) && turnsTotal > 1 && isDefined ( turnsLeft ) ) {
} else if ( turnsLeft === 0 ) {
return {
turnsState : CharacterTurnState.Done ,
turnsText : "✓"
}
} else if ( turnsTotal > 1 ) {
return {
turnsState : CharacterTurnState.HighTurns ,
turnsText : ` ${ turnsLeft } `
}
} else {
return {
turnsState : CharacterTurnState.None
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 } %) ` )
}
} , [ hp , maxHp , canAct , turnsLeft , turnsTotal ] )
} , [ 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" >
< div className = { "characterPortrait" } / >
< animated. div className = { "characterPortrait" } style = { characterPortraitStyleInterpolated } / >
{ isDefined ( turnsState ) &&
< div className = { "characterTurns characterTurns" + turnsState } > { turnsText } < / div > }
< 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" } >
{ isDefined ( level ) &&
< div className = "characterLevel" >
< div className = "characterLevel" >
< span className = "characterLevelLabel" > Lv < / span >
< span className = "characterLevelValue" > { level } < / span >
< / div > }
{ isDefined ( name ) &&
< div className = { "characterName characterName" + ( health ? ? "Unknown" ) } > { name } < / div > }
< span className = "characterLevelValue" > { level ? ? "??" } < / span >
< / div >
< div className = { "characterName" } > { name ? ? "???" } < / div >
< / div >
{ isDefined ( hpText ) &&
< div className = { "characterHp" } >
< animated.div className = { "characterHpBar" } style = { hpBarStyleInterpolated } / >
< animated.div className = { "characterHpValue" } > { hpText } < / animated.div >
< 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" } >
< animated.div className = { "characterMpBar" } style = { mpBarStyleInterpolated } / >
< animated.div className = { "characterMpValue" } > { mpText } < / animated.div >
< 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" } >
< animated.div className = { "characterIpBar" } style = { ipBarStyleInterpolated } / >
< animated.div className = { "characterIpValue" } > { ipText } < / animated.div >
< / div > }
< 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 ) &&
< animated.div className = { "characterSp characterSp" + spType } >
< animated.span className = { "characterSpValue characterSpValue" + spType } >
{ spText } < / animated.span >
< / animated.div > }
< 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 >
}