big refactoring, doable progress

main
Mari 10 months ago
parent 0ea544a952
commit d908dfb0d2
  1. 1
      package.json
  2. 122
      public/api/current.json
  3. 105
      src/App.tsx
  4. 155
      src/CharacterEditor.ts
  5. 11
      src/FixedInterpolation.ts
  6. 2
      src/index.css
  7. 5
      src/index.tsx
  8. 19
      src/model/Character.test.ts
  9. 357
      src/model/Character.ts
  10. 372
      src/model/Doable.ts
  11. 27
      src/model/GameState.ts
  12. 0
      src/tests/setupTests.ts
  13. 0
      src/types/react-app-env.d.ts
  14. 0
      src/types/type_check.ts
  15. 0
      src/ui/App.css
  16. 0
      src/ui/App.test.tsx
  17. 45
      src/ui/App.tsx
  18. 11
      src/ui/CharacterStatus.css
  19. 218
      src/ui/CharacterStatus.tsx
  20. 17
      src/ui/FixedInterpolation.ts
  21. 2
      src/ui/ResourceBarGradient.tsx
  22. 13
      src/ui/SpringyValueHook.ts
  23. 0
      src/ui/default-background.jpg
  24. 0
      src/ui/default-portrait.svg
  25. 0
      src/ui/fabula-points.svg
  26. 0
      src/ui/reportWebVitals.ts
  27. 0
      src/ui/ultima-points.svg

@ -2,6 +2,7 @@
"name": "fabula-ultima-react",
"version": "0.1.0",
"private": true,
"main": "src/index.tsx",
"dependencies": {
"@react-spring/web": "^9.7.2",
"@testing-library/jest-dom": "^5.16.5",

@ -0,0 +1,122 @@
{
"session": {
"fpUsed": 0,
"upUsed": 0
},
"conflict": {
"round": 0,
"activeSideIsAllies": true,
"activeCharacterId": null
},
"clocks": [],
"characters": [
{
"id": "aelica",
"ally": true,
"name": "Aelica",
"level": 32,
"hp": 0,
"maxHp": 82,
"mp": 77,
"maxMp": 77,
"ip": 6,
"maxIp": 6,
"sp": 3,
"spBank": 2,
"spType": "Fabula",
"portraitUrl": "/portraits/aelica.png",
"turnsTotal": 1,
"turnsLeft": 1
},
{
"id": "athetyz",
"ally": true,
"name": "Athetyz",
"level": 32,
"hp": 0,
"maxHp": 63,
"mp": 45,
"maxMp": 55,
"ip": 9,
"maxIp": 10,
"sp": 3,
"spBank": 1,
"spType": "Fabula",
"portraitUrl": "/portraits/athetyz.png",
"turnsTotal": 1,
"turnsLeft": 0
},
{
"id": "echo",
"ally": true,
"name": "Echo",
"level": 32,
"hp": 0,
"maxHp": 67,
"mp": 62,
"maxMp": 62,
"ip": 4,
"maxIp": 12,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
"portraitUrl": "/portraits/echo.png",
"turnsTotal": 1,
"turnsLeft": 1
},
{
"id": "gravitas",
"ally": true,
"name": "Gravitas",
"level": 32,
"hp": 0,
"maxHp": 72,
"mp": 43,
"maxMp": 117,
"ip": 5,
"maxIp": 8,
"sp": 3,
"spBank": 6,
"spType": "Fabula",
"portraitUrl": "/portraits/gravitas.png",
"turnsTotal": 1,
"turnsLeft": 0
},
{
"id": "linnet",
"ally": true,
"name": "Linnet",
"level": 32,
"hp": 0,
"maxHp": 117,
"mp": 73,
"maxMp": 76,
"ip": 6,
"maxIp": 6,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
"portraitUrl": "/portraits/linnet.png",
"turnsTotal": 1,
"turnsLeft": 1
},
{
"id": "prandia",
"ally": true,
"name": "Prandia",
"level": 32,
"hp": 0,
"maxHp": 90,
"mp": 0,
"maxMp": 70,
"ip": 8,
"maxIp": 8,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
"portraitUrl": "/portraits/prandia.png",
"turnsTotal": 1,
"turnsLeft": 0
}
]
}

@ -1,105 +0,0 @@
import React, {ChangeEvent, useCallback, useMemo, useState} from 'react';
import './App.css';
import {Col, Container, Form, InputGroup, Row,} from "react-bootstrap";
import {Character, CharacterStatus, hpToHealth, SPType} from "./CharacterStatus";
import DefaultPortrait from "./default-portrait.svg"
function App() {
const [maxHp, _setMaxHp] = useState(50)
const [hp, _setHp] = useState(40)
const setHp = useCallback(function (v: number) {
v = Math.floor(v)
if (Number.isNaN(v) || v < 0 || v > maxHp || v === hp) {
return
}
_setHp(v)
}, [hp, maxHp])
const setHpMax = useCallback(function (v: number) {
v = Math.floor(v)
if (Number.isNaN(v) || v < 0 || v > 9999 || v === maxHp) {
return
}
if (v < hp) {
setHp(v)
}
_setMaxHp(v)
}, [hp, maxHp, setHp])
const onHpChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setHp(parseInt(e.target.value)), [setHp])
const onMaxHpChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setHpMax(parseInt(e.target.value)), [setHpMax])
const [maxMp, _setMaxMp] = useState(1)
const [mp, _setMp] = useState(1)
const setMp = useCallback(function (v: number) {
v = Math.floor(v)
if (Number.isNaN(v) || v < 0 || v > maxMp || v === mp) {
return
}
_setMp(v)
}, [mp, maxMp])
const setMpMax = useCallback(function (v: number) {
v = Math.floor(v)
if (Number.isNaN(v) || v < 0 || v > 9999 || v === maxMp) {
return
}
if (v < mp) {
setMp(v)
}
_setMaxMp(v)
}, [mp, maxMp, setMp])
const onMpChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setMp(parseInt(e.target.value)), [setMp])
const onMaxMpChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setMpMax(parseInt(e.target.value)), [setMpMax])
const character = useMemo<Character>(() => ({
name: "Test",
level: 26,
hp: hp,
maxHp: maxHp,
mp: 40,
maxMp: 50,
ip: 3,
maxIp: 6,
sp: 3,
spType: SPType.FabulaPoints,
portraitUrl: DefaultPortrait,
health: hpToHealth(hp, maxHp),
turnsTotal: 3,
turnsLeft: 2,}), [hp, maxHp, mp, maxMp])
return <React.Fragment>
<Container fluid>
<Row>
<Col>
<InputGroup>
<InputGroup.Text>Max HP</InputGroup.Text>
<Form.Control type="number" max="9999" min="0" step="1" value={maxHp} onChange={onMaxHpChange} />
</InputGroup>
</Col>
<Col>
<InputGroup>
<InputGroup.Text>Current HP</InputGroup.Text>
<Form.Control type="number" max={maxHp} min="0" step="1" value={hp} onChange={onHpChange} />
</InputGroup>
</Col>
</Row>
<Row>
<Col>
<InputGroup>
<InputGroup.Text>Max MP</InputGroup.Text>
<Form.Control type="number" max="9999" min="0" step="1" value={maxMp} onChange={onMaxMpChange} />
</InputGroup>
</Col>
<Col>
<InputGroup>
<InputGroup.Text>Current MP</InputGroup.Text>
<Form.Control type="number" max={maxMp} min="0" step="1" value={mp} onChange={onMpChange} />
</InputGroup>
</Col>
</Row>
</Container>
<CharacterStatus character={character} active={false} />
</React.Fragment>;
}
export default App;

@ -1,155 +0,0 @@
import {Character} from "./CharacterStatus";
interface CharacterPrivacySettings {
readonly showCharacter: boolean
readonly showHp: boolean
readonly showHealth: boolean
readonly showMp: boolean
readonly showIp: boolean
readonly showSp: boolean
readonly showName: boolean
readonly showPortrait: boolean
readonly showTurns: boolean
readonly showStatuses: boolean
readonly showLevel: boolean
}
enum CharacterPrivacy {
Friend = "friend",
ScannedEnemy = "scanned enemy",
LightlyScannedEnemy = "lightly scanned enemy",
UnscannedEnemy = "unscanned enemy",
SecretiveEnemy = "secretive",
Hidden = "hidden",
}
const CharacterPrivacySettings: Record<CharacterPrivacy, CharacterPrivacySettings> = {
[CharacterPrivacy.Friend]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: true,
showSp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.ScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: false,
showSp: 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,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.UnscannedEnemy]: {
showCharacter: true,
showHp: false,
showHealth: true,
showMp: false,
showIp: false,
showSp: 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,
showName: false,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: false,
},
[CharacterPrivacy.Hidden]: {
showCharacter: false,
showHp: false,
showHealth: false,
showMp: false,
showIp: false,
showSp: false,
showName: false,
showPortrait: false,
showTurns: false,
showStatuses: false,
showLevel: false,
}
}
function applyPrivacy(c: EditableCharacter): Character|null {
const p = CharacterPrivacySettings[c.privacy ?? CharacterPrivacy.Hidden]
if (!p.showCharacter) {
return null
}
const out: {-readonly [Field in keyof EditableCharacter]?: EditableCharacter[Field]} = Object.assign({}, c)
delete out.privacy
if (!p.showHp) {
delete out.hp
delete out.maxHp
if (!p.showHealth) {
delete out.health
}
}
if (!p.showMp) {
delete out.mp
delete out.maxMp
}
if (!p.showIp) {
delete out.ip
delete out.maxIp
}
if (!p.showSp) {
delete out.sp
delete out.spType
}
if (!p.showName) {
delete out.name
}
if (!p.showPortrait) {
delete out.portraitUrl
}
if (!p.showTurns) {
delete out.turnsLeft
delete out.turnsTotal
delete out.canAct
}
if (!p.showStatuses) {
delete out.statuses
}
if (!p.showLevel) {
delete out.level
}
return out
}
interface EditableCharacter extends Character {
privacy: CharacterPrivacy
}

@ -1,11 +0,0 @@
import {Interpolation, InterpolatorArgs, Globals} from "@react-spring/web";
import {getAnimated} from "@react-spring/animated";
export class FixedInterpolation<Input = any, Output = any> extends Interpolation<Input, Output> {
constructor(readonly source: unknown, args: InterpolatorArgs<Input, Output>) {
super(source, args);
getAnimated(this)!.setValue(this._get())
}
}
Globals.assign({to: (source, args) => new FixedInterpolation(source, args)})

@ -3,7 +3,7 @@ body {
background-position: center center;
background-size: cover;
background-attachment: fixed;
background-image: url("./default-background.jpg");
background-image: url("ui/default-background.jpg");
background-color: #222;
min-height: 100vh;
min-width: 100vw;

@ -1,8 +1,7 @@
import "./FixedInterpolation";
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import App from './ui/App';
import reportWebVitals from './ui/reportWebVitals';
import 'bootstrap/dist/css/bootstrap.css';
import './index.css';

@ -0,0 +1,19 @@
import {CharacterHealth, CharacterHealthLevelList} from "./Character";
describe('CharacterHealth', function () {
test("covers all possible values of health, descending", () => {
let nextBound = Number.POSITIVE_INFINITY
let nextBoundInclusive = null
for (const level of CharacterHealthLevelList) {
expect(level.maxPercentage ?? Number.POSITIVE_INFINITY).toEqual(nextBound)
expect(level.maxInclusive).toEqual(nextBoundInclusive)
nextBound = level.minPercentage ?? Number.NEGATIVE_INFINITY
nextBoundInclusive = typeof level.minInclusive === "boolean" ? !level.minInclusive : null
if (Number.isFinite(nextBound)) {
expect(typeof level.minInclusive).toEqual("boolean")
}
}
expect(nextBound).toEqual(Number.NEGATIVE_INFINITY)
expect(nextBoundInclusive).toBeNull()
})
});

@ -0,0 +1,357 @@
import {isDefined} from "../types/type_check";
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: number | undefined, maxHp: number | undefined): 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 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 type StatusId = string
export interface StatusEffect {
readonly id: StatusId
readonly name: string
readonly count?: number
readonly iconUrl: string
readonly description?: string
}
export type CharacterId = string
export interface Character {
readonly id: CharacterId
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 spBank?: number
readonly spType?: SPType
readonly turnsLeft?: number
readonly turnsTotal?: number
readonly canAct?: boolean
readonly statuses?: readonly StatusEffect[]
}
interface CharacterPrivacySettings {
readonly showCharacter: boolean
readonly showHp: boolean
readonly showHealth: boolean
readonly showMp: boolean
readonly showIp: boolean
readonly showSp: boolean
readonly showName: boolean
readonly showPortrait: boolean
readonly showTurns: boolean
readonly showStatuses: boolean
readonly showLevel: boolean
}
export enum CharacterPrivacy {
Friend = "friend",
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,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.ScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: false,
showSp: 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,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.UnscannedEnemy]: {
showCharacter: true,
showHp: false,
showHealth: true,
showMp: false,
showIp: false,
showSp: 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,
showName: false,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: false,
},
[CharacterPrivacy.Hidden]: {
showCharacter: false,
showHp: false,
showHealth: false,
showMp: false,
showIp: false,
showSp: false,
showName: false,
showPortrait: false,
showTurns: false,
showStatuses: false,
showLevel: false,
}
} as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySettings}
export function applyCharacterPrivacy(character: Character, privacy: CharacterPrivacy): Character|null {
const privacySettings = CharacterPrivacySettings[privacy ?? CharacterPrivacy.Hidden]
if (!privacySettings.showCharacter) {
return null
}
const out: {-readonly [Field in keyof Character]: Character[Field]} = Object.assign({}, character)
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.showName) {
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
}
return out
}

@ -0,0 +1,372 @@
import {GameState, getCharacterById} from "./GameState";
import {CharacterId} from "./Character";
export interface BaseDoable {
readonly type: string
}
export type LogEntry = {
readonly markdown: string
readonly children: readonly LogEntry[]
}
export interface DoableResults {
readonly resultState: GameState
readonly logEntry: LogEntry|null
}
export interface DoablesResults {
readonly resultState: GameState
readonly logEntries: readonly LogEntry[]
}
export interface DoableEvaluator<DataType extends BaseDoable> {
readonly type: DataType["type"]
evaluate(data: DataType, state: GameState, direction: DoableDirection): DoableResults
}
export interface GenericAction extends BaseDoable {
readonly type: "generic",
readonly text: string
readonly user: CharacterId|null
readonly target: CharacterId|null
readonly effects: readonly Doable[]
}
export const GenericActionEvaluator = {
type: "generic",
evaluate(data: GenericAction, state: GameState, direction: DoableDirection): DoableResults {
function runEffects(currentState: GameState): DoablesResults {
return evaluateDoables(data.effects, state, direction)
}
function logSelf(currentState: GameState): string {
return data.text.replaceAll(/@[TU]/g, (substring: string): string => {
switch (substring) {
case "@T":
// TODO: make "character links" a function, likely with identifier characters
return data.target !== null ? `[${getCharacterById(currentState, data.target)?.name ?? "???"}](#character/${data.target})` : "@T"
case "@U":
return data.user !== null ? `[${getCharacterById(currentState, data.user)?.name ?? "???"}](#character/${data.user})` : "@U"
default:
return substring
}
})
}
switch (direction) {
case DoableDirection.Do: {
const markdown = logSelf(state)
const {resultState, logEntries} = runEffects(state)
return {
resultState,
logEntry: {
markdown,
children: logEntries
}
}
}
case DoableDirection.Undo: {
const {resultState, logEntries} = runEffects(state)
const markdown = logSelf(resultState)
return {
resultState,
logEntry: {
markdown,
children: logEntries
}
}
}
}
},
} as const satisfies DoableEvaluator<GenericAction>
/**
* @dataclass(**JsonableDataclassArgs)
* class AbilityAction(Doable):
* name: str
* user: Target | None
* costs: tuple[DamageEffect, ...]
* effects: tuple[Effect, ...]
*
*
* @dataclass(**JsonableDataclassArgs)
* class ModifyCharacterEffect(Doable):
* index: int
* old_character_data: Character | None
* new_character_data: Character | None
*
* def __post_init__(self):
* if self.old_character_data is None and self.new_character_data is None:
* raise ValueError("At least one of old_character_data or new_character_data must be non-None")
*
* @dataclass(**JsonableDataclassArgs)
* class ModifyClockEffect(Doable):
* index: int
* old_clock_data: Clock | None
* new_clock_data: Clock | None
*
* def __post_init__(self):
* if self.old_clock_data is None and self.new_clock_data is None:
* raise ValueError("At least one of old_clock_data or new_clock_data must be non-None")
*
*
* @dataclass(**JsonableDataclassArgs)
* class EndTurnAction(Doable):
* turn_ending_index: int
* activating_side: CombatSide
*
*
* @dataclass(**JsonableDataclassArgs)
* class StartTurnAction(Doable):
* turn_starting_index: int
* old_active_side: CombatSide
*
*
* @dataclass(**JsonableDataclassArgs)
* class StartRoundAction(Doable):
* last_round: int
* old_active_side: CombatSide
* old_turns_remaining: tuple[int, ...]
* next_round: int
* activating_side: CombatSide
*
*
* @dataclass(**JsonableDataclassArgs)
* class StartBattleAction(Doable):
* starting_side: CombatSide
* starting_round: int
*
*
* @dataclass(**JsonableDataclassArgs)
* class EndBattleAction(Doable):
* old_round_number: int
* old_active_side: CombatSide
* old_starting_side: CombatSide
* old_turns_remaining: tuple[int, ...]
*
* @dataclass(**JsonableDataclassArgs)
* class OpportunityEffect(LogOnly, Effect):
* target: Target | None
* opportunity_text: str
*
* def log_message(self, user: Target | None = None) -> str:
* return f'**Opportunity!!** {log_substitute(self.opportunity_text, user=user, target=self.target)}'
*
* @dataclass(**JsonableDataclassArgs)
* class DamageEffect(Effect):
* target: Target
* target_type: CharacterType
* attribute: Counter
* damage: int
* old_value: int
* new_value: int
* max_value: int | None
* element: Element
* affinity: Affinity
* piercing: bool
*
* def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus":
* return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.new_value))
*
* def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus":
* return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.old_value))
*
* def log_message(self, user: Target | None = None) -> str:
* state_change = None
* if self.attribute == Counter.HP:
* if self.old_value > self.new_value == 0:
* state_change = ['+ **KO**']
* elif self.new_value > self.old_value == 0:
* if self.new_value * 2 > self.max_value:
* state_change = ['- **KO**']
* else:
* state_change = ['- **KO**', '+ **Crisis**']
* elif self.old_value * 2 > self.max_value >= self.new_value * 2:
* state_change = '+ **Crisis**'
* elif self.new_value * 2 > self.max_value >= self.old_value * 2:
* state_change = '- **Crisis**'
* affinity = ''
* if self.affinity == Affinity.Absorb:
* affinity = "?"
* elif self.affinity == Affinity.Immune:
* affinity = " ✖"
* elif self.affinity == Affinity.Resistant:
* affinity = "…"
* elif self.affinity == Affinity.Vulnerable:
* affinity = "‼"
* attribute = (f'{self.element.value}{"!" if self.piercing else ""}'
* if self.attribute == Counter.HP
* else f'{self.attribute.ctr_name_abbr(self.target_type)}')
* sign = '-' if self.damage >= 0 else '+'
* return '\n'.join(
* [log_substitute(
* f'@T: [{sign}{abs(self.damage)}{affinity}] {attribute}',
* user=user, target=self.target)] +
* [log_substitute(f'@T: [{s}]') for s in state_change])
*
*
* @dataclass(**JsonableDataclassArgs)
* class StatusEffect(Effect):
* target: Target
* old_status: str | None
* new_status: str | None
*
* @staticmethod
* def alter_status(c: Character, old_status: str | None, new_status: str | None) -> Character:
* if old_status is None and new_status is not None:
* return c.add_status(new_status)
* elif new_status is None and old_status is not None:
* return c.remove_status(old_status)
* elif new_status is not None and old_status is not None:
* return c.replace_status(old_status, new_status)
* else:
* return c
*
* def __post_init__(self):
* if self.old_status is None and self.new_status is None:
* raise ValueError("At least one of old_status or new_status must be non-None")
*
* def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus:
* return combat.alter_character(
* self.target, lambda c: StatusEffect.alter_status(c, self.old_status, self.new_status))
*
* def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus:
* return combat.alter_character(
* self.target, lambda c: StatusEffect.alter_status(c, self.new_status, self.old_status))
*
* def log_message(self, user: Target | None = None) -> str | None:
* if self.old_status is not None and self.new_status is None:
* return log_substitute(v=f'@T: [- {self.old_status}]', user=user, target=self.target)
* if self.new_status is not None and self.old_status is None:
* return log_substitute(v=f'@T: [+ {self.old_status}]', user=user, target=self.target)
* if self.old_status is not None and self.new_status is not None:
* return log_substitute(v=f'@T: [{self.old_status} -> {self.new_status}]', user=user, target=self.target)
* pass
*
*
* @dataclass(**JsonableDataclassArgs)
* class FPBonusEffect(Effect):
* user: Target
* rerolls: int
* modifier: int
* fp_spent: int
* old_fp: int
* new_fp: int
*
* def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus:
* return combat.alter_character(
* self.user,
* lambda c: c.set_counter_current(Counter.SP, self.new_fp)
* ).add_fp_spent(self.fp_spent)
*
* def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus:
* return combat.alter_character(
* self.user,
* lambda c: c.set_counter_current(Counter.SP, self.old_fp)
* ).add_fp_spent(-self.fp_spent)
*
* def log_message(self, user: Target | None = None) -> str | None:
* bonuses = []
* if self.rerolls > 0:
* if self.rerolls > 1:
* bonuses.append(f"{self.rerolls} rerolls")
* else:
* bonuses.append("a reroll")
* if self.modifier != 0:
* bonuses.append(f"a {self.modifier:+} {'bonus' if self.modifier > 0 else 'penalty'}")
* if len(bonuses) == 0:
* return None
* affected = ''
* if self.user != user:
* affected = log_substitute(" on @U's roll", user=user, target=self.user)
* return f"{log_substitute('@T', user=user, target=self.user)} " \
* f"spent **{self.fp_spent} FP** for {' and '.join(bonuses)}{affected}!"
*
* TODO: add an FP gain effect for villains (affects all party members) and fumbles and trait failures
*
* @dataclass(**JsonableDataclassArgs)
* class MissEffect(LogOnly, Effect):
* miss_type: MissType
* target: Target
*
* def log_message(self, user: Target | None = None) -> str | None:
* if self.miss_type == MissType.Missed:
* return log_substitute(f"It missed @T!", user=user, target=self.target)
* elif self.miss_type == MissType.Dodged:
* return log_substitute(f"@T dodged it!", user=user, target=self.target)
* elif self.miss_type == MissType.Avoided:
* return log_substitute(f"@T avoided it!", user=user, target=self.target)
* elif self.miss_type == MissType.Blocked:
* return log_substitute("@T blocked it!", user=user, target=self.target)
* elif self.miss_type == MissType.Immunity:
* return log_substitute("@T is immune!", user=user, target=self.target)
* elif self.miss_type == MissType.Repelled:
* return log_substitute("@T repelled it!", user=user, target=self.target)
* elif self.miss_type == MissType.Countered:
* return log_substitute("@T countered it!", user=user, target=self.target)
* elif self.miss_type == MissType.Parried:
* return log_substitute("@T parried it!", user=user, target=self.target)
* elif self.miss_type == MissType.Protected:
* return log_substitute("@T was protected from it!", user=user, target=self.target)
* elif self.miss_type == MissType.Resisted:
* return log_substitute("@T resisted it!", user=user, target=self.target)
* elif self.miss_type == MissType.Shielded:
* return log_substitute("@T shielded against it!", user=user, target=self.target)
* else:
* return log_substitute(f"@T: {self.miss_type.value}", user=user, target=self.target)
*
*
* @dataclass(**JsonableDataclassArgs)
* class ClockTickEffect(Effect):
* clock_index: int
* old_definition: Clock
* new_value: int
* delta: int
*
* def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus":
* return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.new_value))
*
* def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus":
* return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.old_definition.current))
*
* def log_message(self, user: Target | None = None) -> str | None:
* return (f'The clock **{self.old_definition.name}** ticked {"up" if self.delta > 0 else "down"} {self.delta} '
* f'tick{"" if abs(self.delta) == 1 else "s"}.')
*/
const DoableEvaluators = [
GenericActionEvaluator,
] as const satisfies readonly DoableEvaluator<unknown & BaseDoable>[]
export type Doable = typeof DoableEvaluators[number] extends DoableEvaluator<infer ActionType> ? ActionType : never
export enum DoableDirection {
Do = "do",
Undo = "undo",
}
export function evaluateDoable<T extends Doable>(doable: T, gameState: GameState, direction: DoableDirection): DoableResults {
const evaluator: DoableEvaluator<T>|undefined = DoableEvaluators.find((evaluator) => evaluator.type === doable.type)
if (!evaluator) {
return {
resultState: gameState,
logEntry: null
}
}
return evaluator.evaluate(doable, gameState, direction)
}
export function evaluateDoables(doables: readonly Doable[], gameState: GameState, direction: DoableDirection): DoablesResults {
let currentState = gameState
const sortedDoables = direction === DoableDirection.Undo ? doables.slice().reverse() : doables
const logEntries: LogEntry[] = []
for (const doable of sortedDoables) {
const {resultState, logEntry} = evaluateDoable(doable, gameState, direction)
currentState = resultState
if (logEntry) {
logEntries.push(logEntry)
}
}
return {
resultState: currentState,
logEntries: direction === DoableDirection.Undo ? logEntries.reverse() : logEntries,
}
}

@ -0,0 +1,27 @@
import {Character, CharacterId} from "./Character";
export interface Clock {}
export interface SessionState {
readonly fpUsed: number
readonly upUsed: number
}
export interface ConflictState {
readonly round: number|null
readonly activeSideIsAllies: boolean
readonly activeCharacterId: string|null
readonly timeoutAt: number|null
}
export interface GameState {
readonly session: SessionState
readonly conflict?: ConflictState
readonly clocks: readonly Clock[]
readonly characters: readonly Character[]
// TODO: add "status definitions" and have character statuses reference them
}
export function getCharacterById(state: GameState, id: CharacterId): Character|undefined {
return state.characters.find((character) => character.id === id)
}

@ -0,0 +1,45 @@
import React, {useEffect, useState} from 'react';
import './App.css';
import {Container} from "react-bootstrap";
import {Character, CharacterStatus} from "./CharacterStatus";
export interface PastState {
// This is the timestamp of the change that exited this state.
readonly timestampMs: number
// This is the list of actions that changed this state.
}
function useJson<T>(url: string): T | null {
const [data, setData] = useState<T|null>(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json() as T)
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data as T|null;
}
function App() {
const state = useJson<SavedState>("/api/current.json")
return <React.Fragment>
<Container fluid>
{state && state.characters.map((character) =>
<CharacterStatus
key={character.id}
character={character}
active={character.id === state.conflict?.activeCharacterId} />)}
</Container>
</React.Fragment>;
}
export default App;

@ -18,11 +18,13 @@
-webkit-text-stroke: 1px rgba(0, 0, 0, 0.2);
text-shadow: 0 0 2px black;
margin-right: 0.25em;
user-select: none;
}
.characterLevelLabel {
font-size: smaller;
font-variant: small-caps;
user-select: none;
}
.characterName {
@ -34,6 +36,7 @@
text-align: left;
-webkit-text-stroke: 1px rgba(0, 0, 0, 0.5);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
user-select: none;
}
.characterHpBar, .characterMpBar, .characterIpBar {
@ -58,6 +61,8 @@
text-shadow: 2px 2px rgba(0, 0, 0, 0.5);
position: absolute;
bottom: 0;
pointer-events: none;
user-select: none;
}
.characterHp {
@ -149,6 +154,7 @@
border-radius: 9px 0;
color: white;
transition: background-color 0.3s;
user-select: none;
}
.characterTurnsNone {
@ -163,7 +169,7 @@
background-color: mediumblue;
}
.characterTurnsKO {
.characterTurnsDowned {
background-color: #333;
}
@ -193,6 +199,7 @@
left: 5px;
width: 40px;
height: 40px;
user-select: none;
}
.characterSpFabula {
@ -243,6 +250,8 @@
position: absolute;
top: 0;
right: 0;
user-select: none;
pointer-events: none;
}
.characterStatusName, .characterHelpName {

@ -1,22 +1,22 @@
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 {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 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",
}
import {altTo as to} from "./FixedInterpolation";
import {
Character,
CharacterHealth,
CharacterTurnState,
healthToBounds,
healthToFraction,
hpToHealth, SPType, spTypeToDescription, turnStateToDescription,
turnStateToTitle
} from "../model/Character";
export function healthToColor(health: CharacterHealth | undefined): string {
switch (health) {
@ -36,155 +36,6 @@ export function healthToColor(health: CharacterHealth | undefined): string {
}
}
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",
@ -231,13 +82,18 @@ export function CharacterStatus({character, active}: {character: Character, acti
const {springs: [, , {v: hpRecentSpring}], flashSpring: {v: hpFlashSpring}, interpolate: hpInterpolate} = useSpringyValue({
current: effectiveHp,
max: effectiveMaxHp,
starting: 0,
flash: effectiveHp * 2 <= effectiveMaxHp && effectiveHp > 0,
})
const logged = function<T>(v:T): T {
console.log(v)
return v
}
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 => `${Math.round(logged(recentValue))}`)
: to([hpRecentSpring], recentValue => hpToHealth(recentValue, maxHp) ?? "???"),
hpBarStyleInterpolated: evaluateResourceBarStyles(hpBarStyle, hpInterpolate),
hpTextStyleInterpolated: {
@ -253,6 +109,7 @@ export function CharacterStatus({character, active}: {character: Character, acti
const {springs: [, , {v: mpRecentSpring}], interpolate: mpInterpolate} = useSpringyValue({
current: mp,
max: maxMp,
starting: 0,
flash: false,
})
const {mpText, mpBarStyleInterpolated} = useMemo(() => {
@ -270,6 +127,7 @@ export function CharacterStatus({character, active}: {character: Character, acti
const {springs: [, , {v: ipRecentSpring}], interpolate: ipInterpolate} = useSpringyValue({
current: ip,
max: maxIp,
starting: 0,
flash: false,
})
const {ipText, ipBarStyleInterpolated} = useMemo(() => {
@ -283,9 +141,10 @@ export function CharacterStatus({character, active}: {character: Character, acti
}
}, [ip, maxIp, ipRecentSpring, ipInterpolate])
const {sp, spType} = character
const {sp, spBank, spType} = character
const {springs: [, , {v: spRecentSpring}]} = useSpringyValue({
current: sp,
starting: 0,
flash: isDefined(spType) && isDefined(sp) && sp > 0,
})
const {spText} = useMemo(() => {
@ -403,10 +262,11 @@ export function CharacterStatus({character, active}: {character: Character, acti
</div>
</Tooltip>
return <div className="characterStatus">
<animated.div className={"characterPortrait"} style={characterPortraitStyleInterpolated} />
{isDefined(turnsState) &&
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>{turnStateToTitle(turnsState)}</span>
@ -430,40 +290,34 @@ export function CharacterStatus({character, active}: {character: Character, acti
</div>
{isDefined(hpText) &&
<div className={"characterHp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={hpTooltip} placement={"top"}>
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} 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>
<animated.div
className={isDefined(hp) ? "characterHpValue" : "characterHealthText"}
style={hpTextStyleInterpolated}>{hpText}</animated.div>
</div>}
{isDefined(mpText) &&
<div className={"characterMp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={mpTooltip} placement={"top"}>
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} 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>
<animated.div className={"characterMpValue"}>{mpText}</animated.div>
</div>}
{isDefined(ipText) &&
<div className={"characterIp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={ipTooltip} placement={"top"}>
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} 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>
<animated.div className={"characterIpValue"}>{ipText}</animated.div>
</div>
}
{isDefined(spText) &&
<OverlayTrigger delay={{show: 750, hide: 0}} overlay={
<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}</span></div>
<span className={"characterHelpValue"}>{sp}{typeof spBank === "number" ? ` / ${spBank} banked`: null}</span></div>
{isDefined(spType) && <div className={"characterHelpDescription"}>
{spTypeToDescription(spType)}
</div>}
@ -476,8 +330,8 @@ export function CharacterStatus({character, active}: {character: Character, acti
</OverlayTrigger>}
{isDefined(statuses) &&
<div className={"characterStatuses"}>
{statuses.map(({name, count, description, iconUrl}) =>
<OverlayTrigger key={iconUrl} delay={{show: 300, hide: 0}} overlay={
{statuses.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>

@ -0,0 +1,17 @@
import {Interpolation, InterpolatorArgs, InterpolatorFn, Arrify} from "@react-spring/web";
import {getAnimated} from "@react-spring/animated";
export class FixedInterpolation<Input = any, Output = any> extends Interpolation<Input, Output> {
constructor(readonly source: unknown, args: InterpolatorArgs<Input, Output>) {
super(source, args);
getAnimated(this)!.setValue(this._get())
}
}
export function altTo<Input = any, Output = any>(source: unknown, args: InterpolatorArgs<Input, Output>|InterpolatorFn<Arrify<Input>, Output>): Interpolation<Input, Output> {
if (Array.isArray(args)) {
return new FixedInterpolation(source, args)
} else {
return new FixedInterpolation(source, [args])
}
}

@ -5,7 +5,7 @@ import {
SpringyValueInterpolate,
SpringyValues
} from "./SpringyValueHook";
import {isDefined} from "./type_check";
import {isDefined} from "../types/type_check";
import {AnimatedProps} from "@react-spring/web";
export interface ResourceBarColors {

@ -1,9 +1,10 @@
import {useCallback, useMemo, useState} from "react";
import {AnimatedProps, SpringConfig, SpringValue, to, useSpring, useTrail} from "@react-spring/web";
import {AnimatedProps, SpringConfig, SpringValue, to, useSpring, useSprings, useTrail} from "@react-spring/web";
import {FluidValue} from "@react-spring/shared";
export interface UseSpringyValueProps {
current?: number
starting?: number
max?: number
flash?: boolean
springDelays?: readonly [number, number, number]
@ -68,12 +69,16 @@ const DEFAULT_FLASH_CONFIG: SpringConfig = {
clamp: true,
}
export function useSpringyValue({current, max, flash, springConfigs=DEFAULT_SPRING_CONFIGS, springDelays=DEFAULT_SPRING_DELAYS, flashConfig=DEFAULT_FLASH_CONFIG}: UseSpringyValueProps): UseSpringyValueOutput {
export function useSpringyValue({current, starting, max, flash, springConfigs=DEFAULT_SPRING_CONFIGS, springDelays=DEFAULT_SPRING_DELAYS, flashConfig=DEFAULT_FLASH_CONFIG}: UseSpringyValueProps): UseSpringyValueOutput {
const [lastCurrent, setLastCurrent] = useState(current)
const [wasFlashing, setWasFlashing] = useState(flash)
const [springs, barApi] = useTrail(3, () => ({
v: current ?? 0
const [springs, barApi] = useTrail(3, (i) => ({
from: {v: starting ?? current ?? 0},
to: {v: current ?? 0},
config: springConfigs[i],
delay: springDelays[i],
immediate: false,
}), [])
const [flashSpring, flashApi] = useSpring({
from: {v: 0},

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Loading…
Cancel
Save