Zero Power + Blood Points

main
Mari 8 months ago
parent d908dfb0d2
commit fbe0ff722c
  1. 3
      README.md
  2. 945
      package-lock.json
  3. 5
      package.json
  4. 121
      public/api/current.json
  5. 63
      src/model/Character.ts
  6. 372
      src/model/Doable.ts
  7. 51
      src/model/GameState.ts
  8. 22
      src/ui/AnimationHook.ts
  9. 122
      src/ui/App.css
  10. 119
      src/ui/App.tsx
  11. 108
      src/ui/CharacterStatus.css
  12. 121
      src/ui/CharacterStatus.tsx
  13. 2
      src/ui/SpringyValueHook.ts
  14. 20
      src/ui/TurnTimer.css
  15. 56
      src/ui/TurnTimer.tsx
  16. 98
      src/ui/blood-points.svg
  17. 1
      src/ui/default-status.svg
  18. 101
      src/ui/zero-bar-empty.svg
  19. 105
      src/ui/zero-bar-full-pulse.svg
  20. 117
      src/ui/zero-bar.svg

@ -3,4 +3,5 @@
* src/fabula-points.svg: https://game-icons.net/1x1/lorc/star-swirl.html
* src/ultima-points.svg: https://game-icons.net/1x1/lorc/evil-moon.html
* src/default-portrait.svg: https://pixabay.com/vectors/woman-profile-silhouette-people-5786062/
* src/default-background.jpg: https://www.wallpaperflare.com/dark-insubstantial-spotlight-art-lighting-equipment-no-people-wallpaper-geayr/download
* src/default-background.jpg: https://www.wallpaperflare.com/dark-insubstantial-spotlight-art-lighting-equipment-no-people-wallpaper-geayr/download
* src/blood-points.svg: https://game-icons.net/1x1/lorc/rose.html

945
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -2,7 +2,6 @@
"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",
@ -14,9 +13,13 @@
"@types/react-dom": "^18.0.11",
"bootstrap": "^5.2.3",
"csstype": "^3.1.2",
"format-duration": "^3.0.2",
"react": "^18.2.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-markdown": "^8.0.7",
"react-minimal-pie-chart": "^8.4.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

@ -1,21 +1,20 @@
{
"session": {
"fpUsed": 0,
"upUsed": 0
"usedSp": {"Fabula": 5, "Ultima": 2}
},
"conflict": {
"round": 0,
"activeSideIsAllies": true,
"activeSide": "ally",
"activeCharacterId": null
},
"clocks": [],
"characters": [
{
"id": "aelica",
"ally": true,
"side": "ally",
"name": "Aelica",
"level": 32,
"hp": 0,
"hp": 70,
"maxHp": 82,
"mp": 77,
"maxMp": 77,
@ -23,6 +22,10 @@
"maxIp": 6,
"sp": 3,
"spBank": 2,
"bp": 2,
"maxBp": 4,
"zp": 4,
"maxZp": 6,
"spType": "Fabula",
"portraitUrl": "/portraits/aelica.png",
"turnsTotal": 1,
@ -30,15 +33,17 @@
},
{
"id": "athetyz",
"ally": true,
"side": "ally",
"name": "Athetyz",
"level": 32,
"hp": 0,
"hp": 63,
"maxHp": 63,
"mp": 45,
"mp": 55,
"maxMp": 55,
"ip": 9,
"maxIp": 10,
"zp": 3,
"maxZp": 6,
"sp": 3,
"spBank": 1,
"spType": "Fabula",
@ -48,15 +53,17 @@
},
{
"id": "echo",
"ally": true,
"side": "ally",
"name": "Echo",
"level": 32,
"hp": 0,
"hp": 67,
"maxHp": 67,
"mp": 62,
"maxMp": 62,
"ip": 4,
"maxIp": 12,
"zp": 5,
"maxZp": 6,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
@ -66,33 +73,37 @@
},
{
"id": "gravitas",
"ally": true,
"side": "ally",
"name": "Gravitas",
"level": 32,
"hp": 0,
"hp": 72,
"maxHp": 72,
"mp": 43,
"maxMp": 117,
"ip": 5,
"maxIp": 8,
"zp": 0,
"maxZp": 6,
"sp": 3,
"spBank": 6,
"spType": "Fabula",
"portraitUrl": "/portraits/gravitas.png",
"turnsTotal": 1,
"turnsLeft": 0
"turnsLeft": 1
},
{
"id": "linnet",
"ally": true,
"side": "ally",
"name": "Linnet",
"level": 32,
"hp": 0,
"hp": 117,
"maxHp": 117,
"mp": 73,
"maxMp": 76,
"ip": 6,
"maxIp": 6,
"zp": 1,
"maxZp": 6,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
@ -102,21 +113,95 @@
},
{
"id": "prandia",
"ally": true,
"side": "ally",
"name": "Prandia",
"level": 32,
"hp": 0,
"hp": 90,
"maxHp": 90,
"koText": "rip lmao",
"mp": 0,
"maxMp": 70,
"ip": 8,
"maxIp": 8,
"zp": 2,
"maxZp": 6,
"sp": 3,
"spBank": 4,
"spType": "Fabula",
"portraitUrl": "/portraits/prandia.png",
"turnsTotal": 1,
"turnsLeft": 0
"turnsLeft": 1,
"statuses": [
{
"id": "digested",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested2",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested3",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested4",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested5",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested6",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested7",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested8",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested9",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
},
{
"id": "digested10",
"name": "Digested",
"count": 3,
"description": "Is currently being melted down for sustenance."
}]
},
{
"id": "werespider",
"side": "enemy",
"name": "Werespider",
"health": "KO",
"spType": "Ultima",
"sp": 5,
"turnsLeft": 3,
"turnsTotal": 3
}
]
}

@ -179,20 +179,28 @@ export interface StatusEffect {
readonly id: StatusId
readonly name: string
readonly count?: number
readonly iconUrl: string
readonly iconUrl?: string
readonly description?: string
}
export type CharacterId = string
export enum CharacterSide {
Ally = "ally",
Enemy = "enemy"
}
export interface Character {
readonly id: CharacterId
readonly side: CharacterSide
readonly portraitUrl?: string
readonly name?: string
readonly altName?: string
readonly level?: number
readonly hp?: number
readonly maxHp?: number
readonly health?: CharacterHealth
readonly koText?: string
readonly mp?: number
readonly maxMp?: number
readonly ip?: number
@ -200,19 +208,25 @@ export interface Character {
readonly sp?: number
readonly spBank?: number
readonly spType?: SPType
readonly zp?: number
readonly maxZp?: number
readonly bp?: number
readonly maxBp?: number
readonly turnsLeft?: number
readonly turnsTotal?: number
readonly canAct?: boolean
readonly statuses?: readonly StatusEffect[]
readonly privacy?: CharacterPrivacy
}
interface CharacterPrivacySettings {
interface CharacterPrivacySetting {
readonly showCharacter: boolean
readonly showHp: boolean
readonly showHealth: boolean
readonly showMp: boolean
readonly showIp: boolean
readonly showSp: boolean
readonly showZp: boolean
readonly showName: boolean
readonly showPortrait: boolean
readonly showTurns: boolean
@ -222,6 +236,7 @@ interface CharacterPrivacySettings {
export enum CharacterPrivacy {
Friend = "friend",
FullyScannedEnemy = "fully scanned enemy",
ScannedEnemy = "scanned enemy",
LightlyScannedEnemy = "lightly scanned enemy",
UnscannedEnemy = "unscanned enemy",
@ -237,19 +252,35 @@ export const CharacterPrivacySettings = {
showMp: true,
showIp: true,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.ScannedEnemy]: {
[CharacterPrivacy.FullyScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: true,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
showStatuses: true,
showLevel: true,
},
[CharacterPrivacy.ScannedEnemy]: {
showCharacter: true,
showHp: true,
showHealth: true,
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
@ -263,6 +294,7 @@ export const CharacterPrivacySettings = {
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
@ -276,6 +308,7 @@ export const CharacterPrivacySettings = {
showMp: false,
showIp: false,
showSp: true,
showZp: true,
showName: true,
showPortrait: true,
showTurns: true,
@ -289,8 +322,9 @@ export const CharacterPrivacySettings = {
showMp: false,
showIp: false,
showSp: false,
showZp: false,
showName: false,
showPortrait: true,
showPortrait: false,
showTurns: true,
showStatuses: true,
showLevel: false,
@ -302,20 +336,22 @@ export const CharacterPrivacySettings = {
showMp: false,
showIp: false,
showSp: false,
showZp: false,
showName: false,
showPortrait: false,
showTurns: false,
showStatuses: false,
showLevel: false,
}
} as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySettings}
} as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySetting}
export function applyCharacterPrivacy(character: Character, privacy: CharacterPrivacy): Character|null {
const privacySettings = CharacterPrivacySettings[privacy ?? CharacterPrivacy.Hidden]
export function applyCharacterPrivacy(character: Character): Character|null {
const privacySettings = CharacterPrivacySettings[character.privacy ?? CharacterPrivacy.Hidden]
if (!privacySettings.showCharacter) {
return null
}
const out: {-readonly [Field in keyof Character]: Character[Field]} = Object.assign({}, character)
delete out.privacy
if (!privacySettings.showHp) {
delete out.hp
delete out.maxHp
@ -336,8 +372,19 @@ export function applyCharacterPrivacy(character: Character, privacy: CharacterPr
delete out.spBank
delete out.spType
}
if (!privacySettings.showZp) {
delete out.zp
delete out.maxZp
delete out.bp
delete out.maxBp
}
if (!privacySettings.showName) {
delete out.name
if (isDefined(out.altName)) {
out.name = out.altName
delete out.altName
} else {
delete out.name
}
}
if (!privacySettings.showPortrait) {
delete out.portraitUrl

@ -1,372 +0,0 @@
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,
}
}

@ -1,25 +1,60 @@
import {Character, CharacterId} from "./Character";
import {Character, CharacterId, CharacterSide, SPType} from "./Character";
export interface Clock {}
export enum ClockMode {
HEROES_FILL = "heroes fill",
HEROES_EMPTY = "heroes empty",
}
export interface Clock {
readonly id: string
readonly text: string
readonly segments: number
readonly filled: number
readonly mode: ClockMode
}
export interface SessionState {
readonly fpUsed: number
readonly upUsed: number
readonly usedSp: {readonly [key in SPType]?: number}
}
export interface ConflictState {
readonly round: number|null
readonly activeSideIsAllies: boolean
readonly round: number
readonly activeSide: CharacterSide
readonly activeCharacterId: string|null
readonly timeoutAt: number|null
readonly timers: readonly TimerState[]
}
export interface BaseTimerState {
readonly type: string
readonly id: string
readonly text: string
}
export interface CountupTimerState extends BaseTimerState {
readonly type: "up"
readonly timeStartAt: number
}
export interface CountdownTimerState extends BaseTimerState {
readonly type: "down"
readonly timeEndAt: number
readonly timeStartAt: number|null
}
export type TimerState = CountupTimerState|CountdownTimerState;
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 interface PastState {
// The unix timestamp in ms when changing _away_ from this state.
readonly timestamp: number
readonly logMarkdown: string
readonly state: GameState
}
export function getCharacterById(state: GameState, id: CharacterId): Character|undefined {

@ -0,0 +1,22 @@
import {useCallback, useEffect, useRef} from "react";
export function useAnimationFrame(callback: (delta: number, current: number) => void): void {
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change
const requestRef = useRef<number>(0);
const previousTimeRef = useRef(0);
const animate = useCallback(function animate(time: number) {
if (previousTimeRef.current != 0) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime, time)
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
}, [callback])
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, [animate]);
}

@ -0,0 +1,122 @@
.ally-head, .enemy-head, .session-head {
color: white;
position: sticky;
text-align: center;
padding: 2px 0 5px;
text-shadow: 0 0 5px black;
align-self: stretch;
user-select: none;
border-bottom: 1px solid;
top: 0;
z-index: 90;
}
.ally-head.inactive, .enemy-head.inactive {
color: #aaa;
}
.totalFPSpent, .totalUPSpent {
color: white;
line-height: 60px;
-webkit-text-stroke: 2px black;
text-shadow: 2px 2px 2px black;
font-size: 45px;
letter-spacing: -3px;
text-align: center;
font-weight: bold;
height: 60px;
width: 60px;
background-repeat: no-repeat;
background-position: center;
user-select: none;
flex: 0 0 auto;
}
.totalFPSpent {
background-image: url("fabula-points.svg");
}
.totalUPSpent {
background-image: url("ultima-points.svg");
}
.totalSPSpent {
color: white;
line-height: 60px;
text-shadow: 1px 1px 3px black;
font-size: 30px;
text-align: center;
font-weight: bold;
height: 60px;
width: 200px;
white-space: nowrap;
user-select: none;
flex: 0 0 auto;
}
.ally-head.inactive {
background: linear-gradient(to right, transparent 0%, darkcyan 50%, transparent 100%);
border-bottom-color: darkcyan;
}
.session-head {
background: linear-gradient(to right, transparent 0%, goldenrod 10%, gold 50%, goldenrod 90%, transparent 100%);
border-bottom-color: gold;
}
.enemy-head.inactive {
background: linear-gradient(to right, transparent 0%, maroon 50%, transparent 100%);
border-bottom: 1px solid maroon;
}
.ally-head {
background: linear-gradient(to right, transparent 0%, darkcyan 10%, cyan 50%, darkcyan 90%, transparent 100%);
border-bottom-color: cyan;
}
.enemy-head {
background: linear-gradient(to right, transparent 0%, maroon 10%, red 50%, maroon 90%, transparent 100%);
border-bottom: 1px solid red;
}
.ally-head.active::before, .ally-head.active::after {
color: lightpink;
}
.enemy-head.active::before, .enemy-head.active::after {
color: lightskyblue;
}
.ally-head.active::before, .enemy-head.active::before {
content: "❮";
}
.ally-head.active::after, .enemy-head.active::after {
content: "❯";
}
.appHelpName {
font-weight: bold;
}
.appHelpValue {
margin-left: 5px;
font-style: italic;
font-size: smaller;
}
.appHelpHeader {
text-align: left;
}
.appHelpDescription {
text-align: left;
font-size: smaller;
}
.appHelpValue::before {
content: "("
}
.appHelpValue::after {
content: ")"
}

@ -1,43 +1,96 @@
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.
}
import {Col, Container, Row, Stack} from "react-bootstrap";
import {CharacterStatus} from "./CharacterStatus";
import {GameState} from "../model/GameState";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Tooltip from "react-bootstrap/Tooltip";
import {TurnTimer} from "./TurnTimer";
import {CharacterSide} from "../model/Character";
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;
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} />)}
const state = useJson<GameState>("/api/current.json")
const startTime = Date.now()
const endTime = Date.now() + 2 * 60 * 1000
return <React.Fragment>
<Container fluid>
<Row>
<Col xs={{span: false}} xxl={{span: true}}>
<Stack direction="vertical">
<h1 className={"session-head"}>Session</h1>
<Row><Col xs={{span: true}} xxl={{span: false}}>
<Stack direction="horizontal">
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
<div className={"appHelpHeader"}>
<span className={"appHelpName"}>Fabula Points spent</span>
<span className={"appHelpValue"}>{state?.session.usedSp.Fabula ?? 0}</span></div>
<div className={"appHelpDescription"}>
The party earns 1 EXP for each (#-players) Fabula Points spent during the session.
</div>
</Tooltip>
} placement={"right"}>
<div className={"totalFPSpent"}>{state?.session.usedSp.Fabula ?? 0}</div>
</OverlayTrigger>
<div className={"mx-auto totalSPSpent"}>Points Spent</div>
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
<div className={"appHelpHeader"}>
<span className={"appHelpName"}>Ultima Points spent</span>
<span className={"appHelpValue"}>{state?.session.usedSp.Ultima ?? 0}</span></div>
<div className={"appHelpDescription"}>
The party earns 1 EXP for each Ultima Point spent during the session.
</div>
</Tooltip>
} placement={"left"}>
<div className={"totalUPSpent"}>{state?.session.usedSp.Ultima ?? 0}</div>
</OverlayTrigger>
</Stack>
</Col>
<Col xs={{span: true}} xxl={{span: false}}>
<TurnTimer title={"Timer"} startTime={startTime} endTime={endTime} />
</Col></Row>
</Stack>
</Col>
<Col xs={{span: true}} xxl={{span: true, order: "first"}}>
<Stack direction="vertical" className={"align-items-center"}>
<h1 className={"ally-head " + (state?.conflict?.activeSide === CharacterSide.Ally ? "active" : state?.conflict?.activeSide === CharacterSide.Enemy ? "inactive" : "")}>Allies</h1>
{state && state.characters.filter((character) => character.side === CharacterSide.Ally).map((character) =>
<CharacterStatus
key={character.id}
character={character}
active={character.id === state.conflict?.activeCharacterId} />)}
</Stack>
</Col>
<Col xs={{span: true}} xxl={{span: true, order: "last"}}>
<Stack direction="vertical" className={"align-items-center"}>
<h1 className={"enemy-head " + (state?.conflict?.activeSide === CharacterSide.Enemy ? "active" : state?.conflict?.activeSide === CharacterSide.Ally ? "inactive" : "")}>Enemies</h1>
{state && state.characters.filter((character) => character.side === CharacterSide.Enemy).map((character) =>
<CharacterStatus
key={character.id}
character={character}
active={character.id === state.conflict?.activeCharacterId} />)}
</Stack>
</Col>
</Row>
</Container>
</React.Fragment>;
}

@ -1,13 +1,13 @@
.characterStatus {
height: 150px;
width: 500px;
width: 545px;
position: relative;
box-sizing: content-box;
}
.characterHeader {
position: absolute;
left: 160px;
left: 205px;
bottom: 55px;
z-index: 4;
}
@ -68,7 +68,7 @@
.characterHp {
position: absolute;
height: 60px;
left: 112px;
left: 157px;
right: 0;
bottom: 28px;
overflow: visible;
@ -104,7 +104,7 @@
}
.characterMp {
left: 95px;
left: 140px;
right: 155px;
z-index: 3;
}
@ -128,7 +128,7 @@
position: absolute;
top: 10px;
bottom: 15px;
left: 50px;
left: 90px;
width: 125px;
background-repeat: no-repeat;
background-position: center;
@ -137,6 +137,77 @@
z-index: 0;
}
.characterZeroGauge {
position: absolute;
top: 5px;
bottom: 10px;
left: 50px;
width: 65px;
}
.characterZeroBar, .characterZeroBarBack, .characterZeroBarPulse{
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-repeat: no-repeat;
background-position: left bottom;
background-size: auto 135px;
z-index: 0;
}
.characterZeroBarBack {
background-image: url("./zero-bar-empty.svg");
}
.characterZeroBar {
background-image: url("./zero-bar.svg");
}
@keyframes zeroBarPulse {
from {
opacity: 0;
}
50% {
opacity: 80%;
transform: scaleX(120%) scaleY(110%) translateX(-3%);
}
to {
opacity: 0;
transform: scaleX(150%) scaleY(120%) translateX(-6%);
}
}
.characterZeroBarPulse {
background-image: url("./zero-bar-full-pulse.svg");
transform-origin: 10% 50%;
opacity: 0;
pointer-events: none;
transition: opacity 0.5s;
}
.characterZeroBarPulse.active {
animation: 1s ease-out infinite zeroBarPulse;
pointer-events: none;
}
.characterKOBar {
position: absolute;
top: 43px;
bottom: 68px;
left: 90px;
width: 125px;
background-color: black;
z-index: 0;
transform: rotate(-20deg);
text-align: center;
font-size: 27px;
line-height: 42px;
font-weight: bold;
color: white;
user-select: none;
border-radius: 15px 0;
}
.characterTurns {
position: absolute;
top: 5px;
@ -210,12 +281,30 @@
background: url("ultima-points.svg");
}
.characterSp {
.characterBp {
position: absolute;
color: white;
line-height: 40px;
-webkit-text-stroke: 2px black;
text-shadow: 2px 2px 2px black;
font-size: 30px;
letter-spacing: -3px;
text-align: center;
font-weight: bold;
top: 95px;
left: 5px;
width: 40px;
height: 40px;
user-select: none;
background: url("blood-points.svg")
}
.characterSp, .characterBp {
opacity: 50%;
transition: opacity 0.3s ease-out;
}
.characterSp:hover {
.characterSp:hover, .characterBp:hover {
opacity: 100%;
}
@ -223,17 +312,20 @@
position: absolute;
top: 5px;
right: 5px;
left: 180px;
left: 225px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
}
.characterStatusIcon {
position: relative;
flex: 0 0 36px;
width: 36px;
height: 48px;
background-size: contain;
background-repeat: no-repeat;
margin-left: 5px;
overflow: visible;
}

@ -5,6 +5,7 @@ import {isDefined} from "../types/type_check";
import {SpringyValueInterpolatables, useSpringyValue} from "./SpringyValueHook";
import "./CharacterStatus.css";
import DefaultPortrait from "./default-portrait.svg";
import DefaultStatus from "./default-status.svg";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Tooltip from "react-bootstrap/Tooltip";
import {altTo as to} from "./FixedInterpolation";
@ -14,7 +15,7 @@ import {
CharacterTurnState,
healthToBounds,
healthToFraction,
hpToHealth, SPType, spTypeToDescription, turnStateToDescription,
hpToHealth, spTypeToDescription, turnStateToDescription,
turnStateToTitle
} from "../model/Character";
@ -74,7 +75,7 @@ const ipBarStyle: SpringyValueInterpolatables<ResourceBarStyles> = {
}
export function CharacterStatus({character, active}: {character: Character, active: boolean}): ReactElement {
const {name, level, health, statuses} = character
const {name, altName, level, health, koText, statuses} = character
const {hp, maxHp} = character
const effectiveMaxHp = maxHp ?? 100
@ -94,7 +95,7 @@ export function CharacterStatus({character, active}: {character: Character, acti
return {
hpText: isDefined(hp)
? to([hpRecentSpring], recentValue => `${Math.round(logged(recentValue))}`)
: to([hpRecentSpring], recentValue => hpToHealth(recentValue, maxHp) ?? "???"),
: "",
hpBarStyleInterpolated: evaluateResourceBarStyles(hpBarStyle, hpInterpolate),
hpTextStyleInterpolated: {
color: to([hpRecentSpring], recentValue => healthToColor(hpToHealth(recentValue, maxHp)))
@ -157,6 +158,44 @@ export function CharacterStatus({character, active}: {character: Character, acti
}
}, [sp, spType, spRecentSpring])
const {bp, maxBp} = character
const {springs: [, , {v: bpRecentSpring}]} = useSpringyValue({
current: bp,
starting: 0,
flash: isDefined(bp) && bp > 0,
})
const {bpText} = useMemo(() => {
if (isDefined(bp) && isDefined(maxBp)) {
return {
bpText: to([bpRecentSpring], (recentValue) => recentValue.toFixed(0))
}
} else {
return {}
}
}, [bp, bpRecentSpring])
const {zp, maxZp} = character
const {springs: [, , {v: zpRecentSpring}]} = useSpringyValue({
current: zp,
max: maxZp,
starting: 0,
flash: isDefined(maxZp) && isDefined(zp) && zp >= maxZp,
})
const {zpStyle} = useMemo(() => {
if (isDefined(zp) && isDefined(maxZp)) {
return {
zpStyle: {
bottom: 0,
top: "auto",
height: to([zpRecentSpring],(recentValue) => {
return ((225/104) + (600 * ((recentValue / maxZp) ** 2) / 13) + (50 * (recentValue / maxZp))).toFixed(3) + "%"
})
}
}
} else {
return {}
}
}, [zp, zpRecentSpring])
const {turnsLeft, turnsTotal, canAct} = character
const {turnsState, turnsText} = useMemo(() => {
if (!isDefined(turnsTotal) || !isDefined(turnsLeft)) {
@ -168,7 +207,7 @@ export function CharacterStatus({character, active}: {character: Character, acti
turnsState: CharacterTurnState.Active,
turnsText: "🞂",
}
} else if (hp === 0 && isDefined(maxHp) && maxHp > 0) {
} else if (effectiveHp === 0) {
return {
turnsState: CharacterTurnState.Downed,
turnsText: (isDefined(turnsTotal) && turnsLeft === 0) ? "✓" : "",
@ -201,18 +240,15 @@ export function CharacterStatus({character, active}: {character: Character, acti
const filter = {
color: 100,
brightness: 100,
showKOBar: false,
}
if (isDefined(effectiveMaxHp) && Math.round(recentValue) < 1 && effectiveMaxHp > 0) {
filter.color *= 0.50
filter.brightness *= 0.25
}
if (canAct === false || turnsTotal === 0) {
filter.color *= 0.50
filter.brightness *= 0.50
}
if (isDefined(turnsTotal) && turnsLeft === 0) {
filter.color *= 0.75
filter.brightness *= 0.75
filter.color *= 0.35
filter.brightness *= 0.40
filter.showKOBar = true
} else if (canAct === false || turnsTotal === 0) {
filter.color *= 1
filter.brightness *= 0.40
}
return filter
})
@ -221,6 +257,14 @@ export function CharacterStatus({character, active}: {character: Character, acti
grayscale: to([portraitFilterInterpolated], ({color}) => 100 - color),
brightness: to([portraitFilterInterpolated], ({brightness}) => brightness),
})
const {opacity: koOpacitySpring} = useSpring({
opacity: to([portraitFilterInterpolated], ({showKOBar}) => showKOBar ? 100 : 0)
})
const characterKOBarStyleInterpolated = useMemo(() => {
return {
opacity: to([koOpacitySpring], (opacity: number) => `${opacity}%`)
}
}, [koOpacitySpring])
const characterPortraitStyleInterpolated = useMemo(() => {
return {
backgroundImage: to([hpFlashSpring], (flashValue: number) => {
@ -265,6 +309,23 @@ export function CharacterStatus({character, active}: {character: Character, acti
return <div className="characterStatus">
<animated.div className={"characterPortrait"} style={characterPortraitStyleInterpolated} />
{isDefined(maxZp) && isDefined(zp) && <OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>Zero Charge</span>
<span className={"characterHelpValue"}>{(100 * zp / maxZp).toFixed(0)}% - {zp}/{maxZp}</span></div>
{<div className={"characterHelpDescription"}>
The amount of energy stored up towards unleashing the might of a Zero Power. When the gauge is full, let loose!
</div>}
</Tooltip>
} placement={"right"}>
<div className={"characterZeroGauge"}>
<div className={"characterZeroBarBack"} />
<animated.div className={"characterZeroBar"} style={zpStyle} />
<div className={"characterZeroBarPulse" + (zp >= maxZp ? " active" : "")} />
</div>
</OverlayTrigger>}
{isDefined(effectiveMaxHp) && effectiveHp < 1 && effectiveMaxHp > 0 && <animated.div className={"characterKOBar"} style={characterKOBarStyleInterpolated}>{koText ?? "KO"}</animated.div>}
{isDefined(turnsState) &&
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
@ -283,19 +344,19 @@ export function CharacterStatus({character, active}: {character: Character, acti
</OverlayTrigger>}
<div className={"characterHeader"}>
<div className="characterLevel">
<span className="characterLevelLabel">Lv</span>
<span className="characterLevelValue">{level ?? "??"}</span>
</div>
<div className={"characterName"}>{name ?? "???"}</div>
<span className="characterLevelLabel">Lv</span>
<span className="characterLevelValue">{level ?? "??"}</span>
</div>
<div className={"characterName"}>{name ?? altName ?? "???"}</div>
</div>
{isDefined(hpText) &&
<div className={"characterHp"}>
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={hpTooltip} placement={"top"}>
<animated.div className={"characterHpBar"} style={hpBarStyleInterpolated} />
</OverlayTrigger>
<animated.div
className={isDefined(hp) ? "characterHpValue" : "characterHealthText"}
style={hpTextStyleInterpolated}>{hpText}</animated.div>
{isDefined(hp) && <animated.div
className={"characterHpValue"}
style={hpTextStyleInterpolated}>{hpText}</animated.div>}
</div>}
{isDefined(mpText) &&
<div className={"characterMp"}>
@ -328,6 +389,22 @@ export function CharacterStatus({character, active}: {character: Character, acti
{spText}</animated.span>
</animated.div>
</OverlayTrigger>}
{isDefined(bpText) &&
<OverlayTrigger delay={{show: 750, hide: 0}} trigger={["hover", "click", "focus"]} overlay={
<Tooltip>
<div className={"characterHelpHeader"}>
<span className={"characterHelpName"}>Blood Points</span>
<span className={"characterHelpValue"}>{bp}/{maxBp}</span></div>
{isDefined(spType) && <div className={"characterHelpDescription"}>
The current number of blood points, used for Vampire abilities.
</div>}
</Tooltip>
} placement={"right"}>
<animated.div className={"characterBp"}>
<animated.span className={"characterBpValue"}>
{bpText}</animated.span>
</animated.div>
</OverlayTrigger>}
{isDefined(statuses) &&
<div className={"characterStatuses"}>
{statuses.map(({id, name, count, description, iconUrl}) =>
@ -341,7 +418,7 @@ export function CharacterStatus({character, active}: {character: Character, acti
</div>}
</Tooltip>
} placement={"bottom"}>
<div className={"characterStatusIcon"} style={{backgroundImage: `url("${iconUrl}")`}}><span className={"characterStatusIconCountBadge"}>{count}</span></div>
<div className={"characterStatusIcon"} style={{backgroundImage: `url("${iconUrl ?? DefaultStatus}")`}}><span className={"characterStatusIconCountBadge"}>{count}</span></div>
</OverlayTrigger>
)}
</div>}

@ -1,5 +1,5 @@
import {useCallback, useMemo, useState} from "react";
import {AnimatedProps, SpringConfig, SpringValue, to, useSpring, useSprings, useTrail} from "@react-spring/web";
import {AnimatedProps, SpringConfig, SpringValue, to, useSpring, useTrail} from "@react-spring/web";
import {FluidValue} from "@react-spring/shared";
export interface UseSpringyValueProps {

@ -0,0 +1,20 @@
.turnTimer {
min-width: 320px;
color: white;
text-shadow: 1px 1px 3px black;
}
.turnTimerTitle {
font-size: 25px;
text-align: center;
margin: 0;
user-select: none;
}
.turnTimerTime {
margin: 0;
font-size: 60px;
text-align: center;
font-weight: bold;
user-select: none;
}

@ -0,0 +1,56 @@
import {ReactElement, useCallback, useRef, useState} from "react";
import {ProgressBar, Stack} from "react-bootstrap";
import {useAnimationFrame} from "./AnimationHook";
import {isDefined} from "../types/type_check";
import "./TurnTimer.css";
import formatDuration from "format-duration";
export interface TurnTimerArgs {
readonly title?: string
readonly startTime?: number
readonly endTime?: number
readonly displayedTime?: number
readonly resolutionMs?: number
}
const DEFAULT_RESOLUTION_MS = 100;
export function TurnTimer({title, startTime, endTime, displayedTime, resolutionMs = DEFAULT_RESOLUTION_MS}: TurnTimerArgs): ReactElement {
const [currentTime, setCurrentTime] = useState(() => displayedTime ?? Date.now())
const accumulatedTime = useRef(0)
const animationCallback = useCallback(isDefined(displayedTime)
? () => null
: (delta: number) => {
accumulatedTime.current += delta
if (accumulatedTime.current > resolutionMs) {
accumulatedTime.current %= resolutionMs
setCurrentTime(Date.now())
}
}, [displayedTime, setCurrentTime, accumulatedTime])
useAnimationFrame(animationCallback)
if (isDefined(displayedTime) && displayedTime !== currentTime) {
setCurrentTime(displayedTime)
accumulatedTime.current = resolutionMs
}
let totalTime: number|null = null
let timeRemaining: number|null = null
let timeElapsed: number|null = null
if (isDefined(startTime)) {
if (isDefined(endTime)) {
totalTime = endTime - startTime
timeRemaining = endTime - currentTime
} else {
timeElapsed = currentTime - startTime
}
} else if (isDefined(endTime)) {
timeRemaining = endTime - currentTime
}
return <Stack className={"turnTimer"} direction="vertical">
<h2 className={"turnTimerTitle"}>{title}</h2>
{(timeRemaining !== null || timeElapsed !== null) && <div className={"turnTimerTime"}>{formatDuration(timeRemaining ?? timeElapsed ?? 0)}</div>}
{timeRemaining !== null && totalTime !== null && <ProgressBar className={"turnTimerBar"} now={timeRemaining} max={totalTime} />}
</Stack>
}

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
style="height: 512px; width: 512px;"
viewBox="0 0 512 512"
version="1.1"
id="svg25043"
sodipodi:docname="blood-points.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview25045"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="1.4667969"
inkscape:cx="278.83888"
inkscape:cy="152.03196"
inkscape:window-width="1920"
inkscape:window-height="923"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg25043"
showguides="false" />
<defs
id="defs25037">
<filter
id="shadow-1"
height="1.2098526"
width="1.206267"
x="-0.10716398"
y="-0.10515126">
<feFlood
flood-color="rgba(0, 0, 0, 1)"
result="flood"
id="feFlood25021" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="atop"
result="composite"
id="feComposite25023" />
<feGaussianBlur
in="composite"
stdDeviation="15"
result="blur"
id="feGaussianBlur25025" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset25027" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
id="feComposite25029" />
</filter>
<radialGradient
id="lorc-rose-gradient-1"
gradientTransform="matrix(0.96372491,0,0,1.0376405,-29.660702,34.767227)"
cx="284.72337"
cy="247.0172"
fx="284.72337"
fy="247.0172"
r="267.13004"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
stop-color="#d0021b"
stop-opacity="1"
id="stop25032" />
<stop
offset="100%"
stop-color="#f81c1c"
stop-opacity="0.73"
id="stop25034" />
</radialGradient>
</defs>
<path
d="m 251.6493,55.984227 -41.313,105.913003 76.01,103.673 97.135,-7.532 -3.1,-79.284 -78.2,-12.468 -1.61,41.535 29.11,7.568 -0.766,-14.1 18.662,-1.012 2.15,39.635 -68.41,-17.788 3.004,-77.61 68.65,10.946 -2.456,-86.044003 -98.863,-13.43 z m -37.68,45.173003 -73.702,39.917 25.957,137.393 141.306,80.704 154.447,-80.037 -11.252,-142.205 -79.617,-0.988 0.642,22.512 26.705,4.257 4.403,112.57 -125.436,9.727 -88.227,-120.338 24.774,-63.51 z m -93.107,88.706 c -2.992,-0.017 -6.01,0.004 -9.054,0.06 -9.456,0.174 -19.425002,0.853 -29.440002,1.594 9.427,13.32 18.694002,26.165 30.157002,35.938 7.894,6.73 16.835,12.308 28.075,16.056 l -10.1,-53.453 c -3.184,-0.11 -6.396,-0.176 -9.64,-0.194 z m 25.57,84.51 c -14.278,5.27 -27.16,13.25 -39.437,23.55 -17.875002,14.995 -34.273002,35.22 -50.625002,58.47 56.900002,2.6 100.160002,-6.41 147.316002,-35.01 l -54.223,-30.966 -3.03,-16.045 z m 270.854,48.968 -50.64,26.244 c 27.874,20.83 54.865,27.206 90.162,28.557 -8.76,-21.213 -22.617,-39.484 -39.523,-54.8 z m -171.72,21.96 c 1.205,25.213 10.463,44.01 24.648,60.12 17.914,20.346 44.73,35.942 73.625,50.814 7.79,-33.575 9.555,-62.664 -2.05,-93.77 l -34.692,17.978 -61.53,-35.143 z"
fill="url(#lorc-rose-gradient-1)"
stroke="#ffcbcb"
stroke-opacity="1"
stroke-width="8"
filter="url(#shadow-1)"
id="path25039"
style="fill:url(#lorc-rose-gradient-1)"
sodipodi:nodetypes="ccccccccccccccccccccccccccccccccsccccccccccccccccccccccccc"
transform="matrix(1.0980469,0,0,1.1321606,-48.941291,-22.846027)" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="48"><path d="m11.068 22.211 4-1.811s-2.532-1.805-2.58-3.454c-.048-1.649 1.726-3.173 4.604-3.137 4.182.053 5.998 2.682 4.944 4.004-2.809 3.524-5.122 5.292-5.381 8.064-.25 2.664.216 6.959.216 6.959l4.139.012s-.745-4.99.189-7.327c.933-2.338 5.419-5.987 5.105-8.636-.313-2.65-1.208-5.719-3.6-6.92C20.051 8.635 12 9.036 9.805 11.75c-2.194 2.714-2.23 5.245-.828 7.324a258.383 258.383 0 0 1 2.09 3.137z" style="fill:#ccc;stroke:#000;stroke-width:.820001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/><circle cx="19.13" cy="37.305" r="2.68" style="opacity:1;fill:#ccc;fill-opacity:1;stroke:#000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 860 B

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="61.471115"
height="104.80501"
viewBox="0 0 16.264232 27.729659"
version="1.1"
id="svg5"
sodipodi:docname="zero-bar-empty.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="6.9649336"
inkscape:cx="-3.3740451"
inkscape:cy="48.672395"
inkscape:window-width="1920"
inkscape:window-height="923"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
height="100px" />
<defs
id="defs2">
<linearGradient
id="barColors"
y1="57.234005"
y2="78.470796"
x1="49.527314"
x2="49.527314"
gradientTransform="scale(0.76585128,1.3057365)"
gradientUnits="userSpaceOnUse">
<stop
style="stop-color:#383838;stop-opacity:1"
offset="0"
id="color6" />
<stop
style="stop-color:#5b5b5b;stop-opacity:1"
offset="0.62"
id="color3" />
<stop
style="stop-color:#3d3d3d;stop-opacity:1"
offset="1"
id="color0" />
</linearGradient>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-37.930555,-74.73253)">
<path
id="bar"
style="opacity:1;fill:url(#barColors);fill-opacity:1;stroke:#000000;stroke-width:1.058;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:6.0999999;stroke-dasharray:none;stroke-opacity:1;paint-order:markers stroke fill"
d="m 42.893272,75.273442 c -2.63547,3.582753 -4.47125,8.144937 -4.432958,12.645838 0.0753,4.947398 3.146186,9.601111 7.498107,11.874765 1.814396,0.973735 3.831391,1.764055 5.88233,1.937735 -4.494264,-2.556791 -7.467664,-7.96817 -6.944271,-13.168699 0.506588,-3.430017 2.042516,-6.628372 3.880553,-9.535592 0.821948,-1.243836 1.942636,-2.628441 2.863921,-3.754047 -2.915894,-0.04051 -5.831788,0.04051 -8.747682,0 z"
sodipodi:nodetypes="cccccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 39.457149,81.446965 h 8.046985"
id="bar5"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 38.427115,86.915109 h 6.854406"
id="bar4"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 38.948258,91.677609 h 6.10549"
id="bar3"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 41.043111,95.734465 h 5.347301"
id="bar2"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 44.606737,99.114227 h 4.144955"
id="bar1"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="61.471115"
height="104.80501"
viewBox="0 0 16.264232 27.729659"
version="1.1"
id="svg5"
sodipodi:docname="zero-bar-full-pulse.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="6.9649336"
inkscape:cx="37.186284"
inkscape:cy="41.493576"
inkscape:window-width="1920"
inkscape:window-height="923"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
height="100px" />
<defs
id="defs2">
<linearGradient
id="barColors"
y1="57.234005"
y2="78.470796"
x1="49.527314"
x2="49.527314"
gradientTransform="scale(0.76585128,1.3057365)"
gradientUnits="userSpaceOnUse">
<stop
style="stop-color:#ff1c70;stop-opacity:1"
offset="0"
id="color6" />
<stop
style="stop-color:#f45906;stop-opacity:1"
offset="0.2333333"
id="color5" />
<stop
style="stop-color:#ff9119;stop-opacity:1"
offset="0.44"
id="color4" />
<stop
style="stop-color:#ffd917;stop-opacity:1"
offset="0.62"
id="color3" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="0.77333331"
id="color2" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="0.9"
id="color1" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="color0" />
</linearGradient>
<filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter24903"
x="-0.04618996"
y="-0.023349764"
width="1.0923799"
height="1.0466995">
<feGaussianBlur
inkscape:collect="always"
stdDeviation="0.25752876"
id="feGaussianBlur24905" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-37.930555,-74.73253)">
<path
id="bar"
style="opacity:1;fill:url(#barColors);fill-opacity:1;stroke:none;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:6.1;stroke-dasharray:none;stroke-opacity:1;paint-order:markers stroke fill;filter:url(#filter24903)"
d="m 42.893272,75.273442 c -2.63547,3.582753 -4.47125,8.144937 -4.432958,12.645838 0.0753,4.947398 3.146186,9.601111 7.498107,11.874765 1.814396,0.973735 3.831391,1.764055 5.88233,1.937735 -4.494264,-2.556791 -7.467664,-7.96817 -6.944271,-13.168699 0.506588,-3.430017 2.042516,-6.628372 3.880553,-9.535592 0.821948,-1.243836 1.942636,-2.628441 2.863921,-3.754047 -2.915894,-0.04051 -5.831788,0.04051 -8.747682,0 z"
sodipodi:nodetypes="cccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="61.471115"
height="104.80501"
viewBox="0 0 16.264232 27.729659"
version="1.1"
id="svg5"
sodipodi:docname="zero-bar.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="6.9649336"
inkscape:cx="-4.2355034"
inkscape:cy="-10.193923"
inkscape:window-width="1920"
inkscape:window-height="923"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
height="100px" />
<defs
id="defs2">
<linearGradient
id="barColors"
y1="57.234005"
y2="78.470796"
x1="49.527314"
x2="49.527314"
gradientTransform="scale(0.76585128,1.3057365)"
gradientUnits="userSpaceOnUse">
<stop
style="stop-color:#ff1c70;stop-opacity:1"
offset="0"
id="color6" />
<stop
style="stop-color:#f45906;stop-opacity:1"
offset="0.2333333"
id="color5" />
<stop
style="stop-color:#ff9119;stop-opacity:1"
offset="0.44"
id="color4" />
<stop
style="stop-color:#ffd917;stop-opacity:1"
offset="0.62"
id="color3" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="0.77333331"
id="color2" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="0.9"
id="color1" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="color0" />
</linearGradient>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-37.930555,-74.73253)">
<path
id="bar"
style="opacity:1;fill:url(#barColors);fill-opacity:1;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:6.1;stroke-dasharray:none;stroke-opacity:1;paint-order:markers stroke fill"
d="m 42.893272,75.273442 c -2.63547,3.582753 -4.47125,8.144937 -4.432958,12.645838 0.0753,4.947398 3.146186,9.601111 7.498107,11.874765 1.814396,0.973735 3.831391,1.764055 5.88233,1.937735 -4.494264,-2.556791 -7.467664,-7.96817 -6.944271,-13.168699 0.506588,-3.430017 2.042516,-6.628372 3.880553,-9.535592 0.821948,-1.243836 1.942636,-2.628441 2.863921,-3.754047 -2.915894,-0.04051 -5.831788,0.04051 -8.747682,0 z"
sodipodi:nodetypes="cccccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 39.457149,81.446965 h 8.046985"
id="bar5"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 38.427115,86.915109 h 6.854406"
id="bar4"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 38.948258,91.677609 h 6.10549"
id="bar3"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 41.043111,95.734465 h 5.347301"
id="bar2"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.396875;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 44.606737,99.114227 h 4.144955"
id="bar1"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Loading…
Cancel
Save