From d908dfb0d28c2ce1ba6b162f7d632e9eb3aceb40 Mon Sep 17 00:00:00 2001 From: Mari Date: Sat, 1 Jul 2023 01:52:26 -0400 Subject: [PATCH] big refactoring, doable progress --- package.json | 1 + public/api/current.json | 122 ++++++ src/App.tsx | 105 ----- src/CharacterEditor.ts | 155 -------- src/FixedInterpolation.ts | 11 - src/index.css | 2 +- src/index.tsx | 5 +- src/model/Character.test.ts | 19 + src/model/Character.ts | 357 +++++++++++++++++ src/model/Doable.ts | 372 ++++++++++++++++++ src/model/GameState.ts | 27 ++ src/{ => tests}/setupTests.ts | 0 src/{ => types}/react-app-env.d.ts | 0 src/{ => types}/type_check.ts | 0 src/{ => ui}/App.css | 0 src/{ => ui}/App.test.tsx | 0 src/ui/App.tsx | 45 +++ src/{ => ui}/CharacterStatus.css | 11 +- src/{ => ui}/CharacterStatus.tsx | 218 ++-------- src/ui/FixedInterpolation.ts | 17 + .../ResourceBarGradient.tsx} | 2 +- src/{ => ui}/SpringyValueHook.ts | 13 +- src/{ => ui}/default-background.jpg | Bin src/{ => ui}/default-portrait.svg | 0 src/{ => ui}/fabula-points.svg | 0 src/{ => ui}/reportWebVitals.ts | 0 src/{ => ui}/ultima-points.svg | 0 27 files changed, 1019 insertions(+), 463 deletions(-) create mode 100644 public/api/current.json delete mode 100644 src/App.tsx delete mode 100644 src/CharacterEditor.ts delete mode 100644 src/FixedInterpolation.ts create mode 100644 src/model/Character.test.ts create mode 100644 src/model/Character.ts create mode 100644 src/model/Doable.ts create mode 100644 src/model/GameState.ts rename src/{ => tests}/setupTests.ts (100%) rename src/{ => types}/react-app-env.d.ts (100%) rename src/{ => types}/type_check.ts (100%) rename src/{ => ui}/App.css (100%) rename src/{ => ui}/App.test.tsx (100%) create mode 100644 src/ui/App.tsx rename src/{ => ui}/CharacterStatus.css (95%) rename src/{ => ui}/CharacterStatus.tsx (69%) create mode 100644 src/ui/FixedInterpolation.ts rename src/{resource_bar.tsx => ui/ResourceBarGradient.tsx} (98%) rename src/{ => ui}/SpringyValueHook.ts (91%) rename src/{ => ui}/default-background.jpg (100%) rename src/{ => ui}/default-portrait.svg (100%) rename src/{ => ui}/fabula-points.svg (100%) rename src/{ => ui}/reportWebVitals.ts (100%) rename src/{ => ui}/ultima-points.svg (100%) diff --git a/package.json b/package.json index bfbe221..a3fab57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/api/current.json b/public/api/current.json new file mode 100644 index 0000000..f8bab27 --- /dev/null +++ b/public/api/current.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 952d565..0000000 --- a/src/App.tsx +++ /dev/null @@ -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) => setHp(parseInt(e.target.value)), [setHp]) - const onMaxHpChange = useCallback( - (e: ChangeEvent) => 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) => setMp(parseInt(e.target.value)), [setMp]) - const onMaxMpChange = useCallback( - (e: ChangeEvent) => setMpMax(parseInt(e.target.value)), [setMpMax]) - - const character = useMemo(() => ({ - 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 - - - - - Max HP - - - - - - Current HP - - - - - - - - Max MP - - - - - - Current MP - - - - - - - ; -} - -export default App; diff --git a/src/CharacterEditor.ts b/src/CharacterEditor.ts deleted file mode 100644 index 059bb88..0000000 --- a/src/CharacterEditor.ts +++ /dev/null @@ -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.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 -} \ No newline at end of file diff --git a/src/FixedInterpolation.ts b/src/FixedInterpolation.ts deleted file mode 100644 index ccd8e03..0000000 --- a/src/FixedInterpolation.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {Interpolation, InterpolatorArgs, Globals} from "@react-spring/web"; -import {getAnimated} from "@react-spring/animated"; - -export class FixedInterpolation extends Interpolation { - constructor(readonly source: unknown, args: InterpolatorArgs) { - super(source, args); - getAnimated(this)!.setValue(this._get()) - } -} - -Globals.assign({to: (source, args) => new FixedInterpolation(source, args)}) diff --git a/src/index.css b/src/index.css index 91fdc8b..9389aa9 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/index.tsx b/src/index.tsx index a50ce31..9557006 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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'; diff --git a/src/model/Character.test.ts b/src/model/Character.test.ts new file mode 100644 index 0000000..b5d84f3 --- /dev/null +++ b/src/model/Character.test.ts @@ -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() + }) + +}); \ No newline at end of file diff --git a/src/model/Character.ts b/src/model/Character.ts new file mode 100644 index 0000000..f22dd7f --- /dev/null +++ b/src/model/Character.ts @@ -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 +} \ No newline at end of file diff --git a/src/model/Doable.ts b/src/model/Doable.ts new file mode 100644 index 0000000..5373793 --- /dev/null +++ b/src/model/Doable.ts @@ -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 { + 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 +/** + * @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[] + +export type Doable = typeof DoableEvaluators[number] extends DoableEvaluator ? ActionType : never + +export enum DoableDirection { + Do = "do", + Undo = "undo", +} + +export function evaluateDoable(doable: T, gameState: GameState, direction: DoableDirection): DoableResults { + const evaluator: DoableEvaluator|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, + } +} \ No newline at end of file diff --git a/src/model/GameState.ts b/src/model/GameState.ts new file mode 100644 index 0000000..af97b36 --- /dev/null +++ b/src/model/GameState.ts @@ -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) +} \ No newline at end of file diff --git a/src/setupTests.ts b/src/tests/setupTests.ts similarity index 100% rename from src/setupTests.ts rename to src/tests/setupTests.ts diff --git a/src/react-app-env.d.ts b/src/types/react-app-env.d.ts similarity index 100% rename from src/react-app-env.d.ts rename to src/types/react-app-env.d.ts diff --git a/src/type_check.ts b/src/types/type_check.ts similarity index 100% rename from src/type_check.ts rename to src/types/type_check.ts diff --git a/src/App.css b/src/ui/App.css similarity index 100% rename from src/App.css rename to src/ui/App.css diff --git a/src/App.test.tsx b/src/ui/App.test.tsx similarity index 100% rename from src/App.test.tsx rename to src/ui/App.test.tsx diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 0000000..97403b7 --- /dev/null +++ b/src/ui/App.tsx @@ -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(url: string): T | null { + const [data, setData] = useState(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("/api/current.json") + return + + {state && state.characters.map((character) => + )} + + ; +} + +export default App; diff --git a/src/CharacterStatus.css b/src/ui/CharacterStatus.css similarity index 95% rename from src/CharacterStatus.css rename to src/ui/CharacterStatus.css index 4a5e026..a154fc5 100644 --- a/src/CharacterStatus.css +++ b/src/ui/CharacterStatus.css @@ -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 { diff --git a/src/CharacterStatus.tsx b/src/ui/CharacterStatus.tsx similarity index 69% rename from src/CharacterStatus.tsx rename to src/ui/CharacterStatus.tsx index 0e35dd5..7e3cc2b 100644 --- a/src/CharacterStatus.tsx +++ b/src/ui/CharacterStatus.tsx @@ -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 = { 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(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 + return
{isDefined(turnsState) && -
{turnStateToTitle(turnsState)} @@ -430,40 +290,34 @@ export function CharacterStatus({character, active}: {character: Character, acti
{isDefined(hpText) &&
- + - - {hpText} - + {hpText}
} {isDefined(mpText) &&
- + - - {mpText} - + {mpText}
} {isDefined(ipText) &&
- + - - {ipText} - + {ipText}
} {isDefined(spText) && -
{spType} Points - {sp}
+ {sp}{typeof spBank === "number" ? ` / ${spBank} banked`: null}
{isDefined(spType) &&
{spTypeToDescription(spType)}
} @@ -476,8 +330,8 @@ export function CharacterStatus({character, active}: {character: Character, acti } {isDefined(statuses) &&
- {statuses.map(({name, count, description, iconUrl}) => - +
{name} diff --git a/src/ui/FixedInterpolation.ts b/src/ui/FixedInterpolation.ts new file mode 100644 index 0000000..92e04f1 --- /dev/null +++ b/src/ui/FixedInterpolation.ts @@ -0,0 +1,17 @@ +import {Interpolation, InterpolatorArgs, InterpolatorFn, Arrify} from "@react-spring/web"; +import {getAnimated} from "@react-spring/animated"; + +export class FixedInterpolation extends Interpolation { + constructor(readonly source: unknown, args: InterpolatorArgs) { + super(source, args); + getAnimated(this)!.setValue(this._get()) + } +} + +export function altTo(source: unknown, args: InterpolatorArgs|InterpolatorFn, Output>): Interpolation { + if (Array.isArray(args)) { + return new FixedInterpolation(source, args) + } else { + return new FixedInterpolation(source, [args]) + } +} \ No newline at end of file diff --git a/src/resource_bar.tsx b/src/ui/ResourceBarGradient.tsx similarity index 98% rename from src/resource_bar.tsx rename to src/ui/ResourceBarGradient.tsx index 18221b6..d1de0a2 100644 --- a/src/resource_bar.tsx +++ b/src/ui/ResourceBarGradient.tsx @@ -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 { diff --git a/src/SpringyValueHook.ts b/src/ui/SpringyValueHook.ts similarity index 91% rename from src/SpringyValueHook.ts rename to src/ui/SpringyValueHook.ts index 9cb3f4f..53f2703 100644 --- a/src/SpringyValueHook.ts +++ b/src/ui/SpringyValueHook.ts @@ -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}, diff --git a/src/default-background.jpg b/src/ui/default-background.jpg similarity index 100% rename from src/default-background.jpg rename to src/ui/default-background.jpg diff --git a/src/default-portrait.svg b/src/ui/default-portrait.svg similarity index 100% rename from src/default-portrait.svg rename to src/ui/default-portrait.svg diff --git a/src/fabula-points.svg b/src/ui/fabula-points.svg similarity index 100% rename from src/fabula-points.svg rename to src/ui/fabula-points.svg diff --git a/src/reportWebVitals.ts b/src/ui/reportWebVitals.ts similarity index 100% rename from src/reportWebVitals.ts rename to src/ui/reportWebVitals.ts diff --git a/src/ultima-points.svg b/src/ui/ultima-points.svg similarity index 100% rename from src/ultima-points.svg rename to src/ui/ultima-points.svg