parent
d908dfb0d2
commit
fbe0ff722c
File diff suppressed because it is too large
Load Diff
@ -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, |
||||
} |
||||
} |
@ -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: ")" |
||||
} |
@ -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> |
||||
} |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 860 B |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 4.4 KiB |
Loading…
Reference in new issue