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