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.
 
 
 

591 lines
18 KiB

import {isDefined} from "../types/type_check";
import {
createResourceManipulator,
MeteredResource,
Resource,
ResourceManipulator,
UnmeteredResource
} from "./Resources";
export enum CharacterHealth {
Full = "Full",
Healthy = "Healthy",
Wounded = "Wounded",
Crisis = "Crisis",
Peril = "Peril",
KO = "KO",
}
export interface CharacterHealthLevel {
readonly minPercentage: number|null
readonly minInclusive: boolean|null
readonly maxPercentage: number|null
readonly maxInclusive: boolean|null
readonly health: CharacterHealth
}
export const CharacterHealthLevels= {
[CharacterHealth.Full]: {
maxPercentage: null,
maxInclusive: null,
minPercentage: 100,
minInclusive: true,
health: CharacterHealth.Full,
},
[CharacterHealth.Healthy]: {
maxPercentage: 100,
maxInclusive: false,
minPercentage: 90,
minInclusive: true,
health: CharacterHealth.Healthy,
},
[CharacterHealth.Wounded]: {
maxPercentage: 90,
maxInclusive: false,
minPercentage: 50,
minInclusive: false,
health: CharacterHealth.Wounded,
},
[CharacterHealth.Crisis]: {
maxPercentage: 50,
maxInclusive: true,
minPercentage: 10,
minInclusive: false,
health: CharacterHealth.Crisis,
},
[CharacterHealth.Peril]: {
maxPercentage: 10,
maxInclusive: true,
minPercentage: 0,
minInclusive: false,
health: CharacterHealth.Peril,
},
[CharacterHealth.KO]: {
maxPercentage: 0,
maxInclusive: true,
minPercentage: null,
minInclusive: null,
health: CharacterHealth.KO,
},
} as const satisfies {readonly [value in CharacterHealth]: CharacterHealthLevel & {health: value}}
export const CharacterHealthLevelList: readonly CharacterHealthLevel[] = Object.values(CharacterHealthLevels)
export function healthToFraction(health: CharacterHealth | undefined): number {
const {minPercentage, maxPercentage} = CharacterHealthLevels[health ?? CharacterHealth.Wounded]
return ((minPercentage ?? maxPercentage ?? 0) + (maxPercentage ?? minPercentage ?? 0)) / 200
}
export function healthToBounds(health: CharacterHealth | undefined): string {
if (!health) {
return "???"
}
const {minPercentage = null, maxPercentage = null} = CharacterHealthLevels[health]
return `${minPercentage ?? ""}${minPercentage !== null && maxPercentage !== null ? "-" : ""}${maxPercentage ?? ""}%`
}
export function hpToHealth({hp, maxHp}: {hp?: number, maxHp?: number}): CharacterHealth | undefined {
if (!isDefined(hp) || !isDefined(maxHp) || maxHp <= 0) {
return
}
const compareHp = 100 * hp
for (const level of CharacterHealthLevelList) {
if (level.minPercentage === null || level.minInclusive === null) {
return level.health
}
const compareLevel = maxHp * level.minPercentage ?? 0
if (level.minInclusive ? compareHp >= compareLevel : compareHp > compareLevel) {
return level.health
}
}
// Returns undefined by falling off here.
// However, won't reach this point; will always return at the last level in the loop
}
export enum CharacterTurnState {
None = "None",
Ready = "Ready",
HighTurns = "HighTurns",
Done = "Done",
CantAct = "CantAct",
Downed = "Downed",
Active = "Active",
}
export interface CharacterTurnStateData {
readonly title: string
readonly description: string
}
export const CharacterTurnStates = {
[CharacterTurnState.None]: {
title: "None",
description: "Does not take turns.",
},
[CharacterTurnState.Ready]: {
title: "Ready",
description: "Has not acted yet this round.",
},
[CharacterTurnState.HighTurns]: {
title: "Multiple Turns",
description: "Has %c% turns left out of %m% turns. Must still alternate with opponents.",
},
[CharacterTurnState.Done]: {
title: "Done",
description: "Has finished acting this round.",
},
[CharacterTurnState.CantAct]: {
title: "Can't Act",
description: "Is currently unable to act.",
},
[CharacterTurnState.Downed]: {
title: "Downed",
description: "Has 0 HP. Is currently down and out of the action and unable to act.",
},
[CharacterTurnState.Active]: {
title: "Active",
description: "Currently taking a turn."
}
} as const satisfies {readonly [value in CharacterTurnState]: CharacterTurnStateData}
export function turnStateToTitle(state: CharacterTurnState): string {
return CharacterTurnStates[state].title
}
export function turnStateToDescription(state: CharacterTurnState): string {
return CharacterTurnStates[state].description
}
export function isZpReady({zp, maxZp}: {zp?: number, maxZp?: number}): boolean|undefined {
if (typeof zp === "undefined" || typeof maxZp === "undefined") {
return
}
return zp === maxZp
}
export enum SPType {
UltimaPoints = "Ultima",
FabulaPoints = "Fabula",
}
export interface SPTypeData {
readonly description: string
}
export const SPTypes = {
[SPType.UltimaPoints]: {
description: "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.",
},
[SPType.FabulaPoints]: {
description: "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."
}
} as const satisfies {readonly [value in SPType]: SPTypeData}
export function spTypeToDescription(sp: SPType): string {
return SPTypes[sp].description
}
export interface StatusEffectInstance {
readonly id: string
readonly count: number
}
export enum CharacterSide {
Ally = "ally",
Enemy = "enemy"
}
export interface Character {
readonly id: string
readonly side?: CharacterSide
readonly minion?: boolean
readonly portraitUrl?: string
readonly name?: string
readonly altName?: string
readonly description?: string
readonly level?: number
readonly xp?: number
readonly maxXp?: number
readonly hp?: number
readonly maxHp?: number
readonly health?: CharacterHealth
readonly koText?: string
readonly mp?: number
readonly maxMp?: number
readonly ip?: number
readonly maxIp?: number
readonly sp?: number
readonly spBank?: number
readonly spType?: SPType
readonly zp?: number
readonly maxZp?: number
readonly bp?: number
readonly maxBp?: number
readonly turnsLeft?: number
readonly turnsTotal?: number
readonly canAct?: boolean
readonly statuses?: readonly StatusEffectInstance[]
readonly order?: number
readonly zenit?: number
readonly materials?: number
readonly privacy?: CharacterPrivacy
}
interface CharacterPrivacySetting {
readonly showCharacter: boolean
readonly showHp: boolean
readonly showHealth: boolean
readonly showMp: boolean
readonly showIp: boolean
readonly showSp: boolean
readonly showZp: boolean
readonly showName: boolean
readonly showPortrait: boolean
readonly showTurns: boolean
readonly showStatuses: boolean
readonly showLevel: boolean
}
export enum CharacterPrivacy {
Friend = "friend",
FullyScannedEnemy = "fully scanned enemy",
ScannedEnemy = "scanned enemy",
LightlyScannedEnemy = "lightly scanned enemy",
UnscannedEnemy = "unscanned enemy",
SecretiveEnemy = "secretive",
Hidden = "hidden",
}
export const CharacterPrivacySettings = {
[CharacterPrivacy.Friend]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: true,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.FullyScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.ScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.LightlyScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.UnscannedEnemy]: {
showCharacter: true,
showHp: false,
showHealth: true,
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: false,
},
[CharacterPrivacy.SecretiveEnemy]: {
showCharacter: true,
showHp: false,
showHealth: true,
showMp: false,
showIp: false,
showSp: false,
showZp: false,
showName: false,
showPortrait: false,
showTurns: true,
showStatuses: true,
showLevel: false,
},
[CharacterPrivacy.Hidden]: {
showCharacter: false,
showHp: false,
showHealth: false,
showMp: false,
showIp: false,
showSp: false,
showZp: false,
showName: false,
showPortrait: false,
showTurns: false,
showStatuses: false,
showLevel: false,
}
} as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySetting}
const CharacterResourceValue = {
[MeteredResource.Blood]: "bp",
[MeteredResource.Experience]: "xp",
[MeteredResource.Health]: "hp",
[MeteredResource.Items]: "ip",
[MeteredResource.Magic]: "mp",
[MeteredResource.Segments]: undefined,
[MeteredResource.Turns]: "turnsLeft",
[MeteredResource.Zero]: "zp",
[UnmeteredResource.Order]: "order",
[UnmeteredResource.Level]: "level",
[UnmeteredResource.Zenit]: "zenit",
[UnmeteredResource.Fabula]: "sp",
[UnmeteredResource.Ultima]: "sp",
[UnmeteredResource.Special]: "sp",
[UnmeteredResource.Materials]: "materials",
} as const satisfies {[key in Resource]: keyof Character|undefined}
const CharacterResourceMax = {
[MeteredResource.Blood]: "maxBp",
[MeteredResource.Experience]: "maxXp",
[MeteredResource.Health]: "maxHp",
[MeteredResource.Items]: "maxIp",
[MeteredResource.Magic]: "maxMp",
[MeteredResource.Segments]: undefined,
[MeteredResource.Turns]: "turnsTotal",
[MeteredResource.Zero]: "maxZp",
[UnmeteredResource.Materials]: undefined,
[UnmeteredResource.Fabula]: undefined,
[UnmeteredResource.Ultima]: undefined,
[UnmeteredResource.Special]: undefined,
[UnmeteredResource.Zenit]: undefined,
[UnmeteredResource.Order]: undefined,
[UnmeteredResource.Level]: undefined,
} as const satisfies {[key in MeteredResource]: keyof Character|undefined} & {[key in UnmeteredResource]: undefined}
const baseCharacterResource =
createResourceManipulator<
Exclude<typeof CharacterResourceMax[Resource] | typeof CharacterResourceValue[Resource], undefined>,
Character, Resource>(CharacterResourceValue, CharacterResourceMax)
export const CharacterResources: ResourceManipulator<Character, Resource> = {
...baseCharacterResource,
getValue(object: Character, resource: Resource): number | undefined {
if ((resource === UnmeteredResource.Fabula && object.spType !== SPType.FabulaPoints)
|| (resource === UnmeteredResource.Ultima && object.spType !== SPType.UltimaPoints)) {
return undefined
}
return baseCharacterResource.getValue(object, resource)
},
applyDelta(object: Character, resource: Resource, delta: number): Character {
if ((resource === UnmeteredResource.Fabula && object.spType !== SPType.FabulaPoints)
|| (resource === UnmeteredResource.Ultima && object.spType !== SPType.UltimaPoints)) {
return object
}
if ((resource === MeteredResource.Experience)) {
const oldExp = this.getValue(object, resource)
const maxExp = this.getMax(object, resource)
if (typeof maxExp === "undefined" || typeof oldExp === "undefined" || maxExp < 1) {
return object
}
let levelDiff = 0
let newExp = oldExp + delta
while (newExp > maxExp) {
levelDiff += 1
newExp -= maxExp
}
while (newExp < 0) {
levelDiff -= 1
newExp += maxExp
}
return this.setValue(this.applyDelta(object, UnmeteredResource.Level, levelDiff), MeteredResource.Experience, newExp)
}
const updated = baseCharacterResource.applyDelta(object, resource, delta)
if (resource === MeteredResource.Health) {
return {...updated, health: hpToHealth(updated)}
} else {
return updated
}
},
setValue(object: Character, resource: Resource, value: number): Character {
const updated = baseCharacterResource.setValue(object, resource, value)
switch (resource) {
case MeteredResource.Health:
return {...updated, health: hpToHealth(updated)}
case UnmeteredResource.Fabula:
return {...updated, spType: SPType.FabulaPoints}
case UnmeteredResource.Ultima:
return {...updated, spType: SPType.UltimaPoints}
default:
return updated
}
},
setMetered(object: Character, resource: Resource, value: number, max: number): Character {
if (resource === MeteredResource.Experience && value === max) {
return baseCharacterResource.applyDelta(
baseCharacterResource.setMetered(object, resource, 0, max),
UnmeteredResource.Level, 1)
} else {
const result = baseCharacterResource.setMetered(object, resource, value, max)
if (resource === MeteredResource.Health) {
return {...result, health: hpToHealth(result)}
} else {
return result
}
}
},
setMax(object: Character, resource: Resource, max: number): Character {
const result = baseCharacterResource.setMax(object, resource, max)
if (resource === MeteredResource.Health) {
return {...result, health: hpToHealth(result)}
} else {
return result
}
},
}
export function applyCharacterPrivacy(character: Character): Character|null {
const privacySettings = CharacterPrivacySettings[character.privacy ?? CharacterPrivacy.Hidden]
if (!privacySettings.showCharacter) {
return null
}
const out: {-readonly [Field in keyof Character]: Character[Field]} = Object.assign({}, character)
delete out.privacy
if (!privacySettings.showHp) {
delete out.hp
delete out.maxHp
if (!privacySettings.showHealth) {
delete out.health
}
}
if (!privacySettings.showMp) {
delete out.mp
delete out.maxMp
}
if (!privacySettings.showIp) {
delete out.ip
delete out.maxIp
}
if (!privacySettings.showSp) {
delete out.sp
delete out.spBank
delete out.spType
}
if (!privacySettings.showZp) {
delete out.zp
delete out.maxZp
delete out.bp
delete out.maxBp
}
if (!privacySettings.showName) {
if (isDefined(out.altName)) {
out.name = out.altName
delete out.altName
} else {
delete out.name
}
}
if (!privacySettings.showPortrait) {
delete out.portraitUrl
}
if (!privacySettings.showTurns) {
delete out.turnsLeft
delete out.turnsTotal
delete out.canAct
}
if (!privacySettings.showStatuses) {
delete out.statuses
}
if (!privacySettings.showLevel) {
delete out.level
delete out.xp
delete out.maxXp
}
return out
}
export const CharacterStatuses = {
addStatus(c: Character, id: string, stacks: number): Character {
if (!c.statuses || c.statuses.some((s) => s.id === id)) {
return c
} else {
return this.setStatus(c, id, stacks)
}
},
setStatus(c: Character, id: string, stacks: number): Character {
if (!c.statuses) {
return c
}
if (c.statuses.some((s) => s.id === id)) {
return {
...c,
statuses: c.statuses.map((s) => s.id === id ? {...s, count: Math.max(0, stacks)} : s),
}
} else {
return {
...c,
statuses: [...c.statuses, {id: id, count: Math.max(0, stacks)}]
}
}
},
applyStatusDelta(c: Character, id: string, delta: number): Character {
if (!c.statuses) {
return c
}
const status = c.statuses.find((s) => s.id === id)
if (!status) {
if (delta > 0) {
return this.addStatus(c, id, delta)
} else {
return c
}
}
if (status.count + delta > 0) {
return this.setStatus(c, id, status.count + delta)
} else {
return this.removeStatus(c, id)
}
},
removeStatus(c: Character, id: string): Character {
if (!c.statuses || !c.statuses.some((s) => s.id === id)) {
return c
} else {
return {
...c,
statuses: c.statuses.filter((s) => s.id !== id)
}
}
}
}