Files
i-want-to-heal/src/hooks/useFloatingCombatText.ts
T
2026-06-30 13:30:33 -04:00

127 lines
3.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
appendFloatingText,
groupFloatingTextsByMember,
stripFloatingTextSide,
type BasicFloatingCombatText,
} from '../combat/combatPresentation'
type FloatingSide = 'player' | 'cpu'
type SideFloatingCombatText = BasicFloatingCombatText & {
side: FloatingSide
}
type FloatingCombatTextOptions = {
durationMs?: number
}
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900,
}: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1)
const expirationsRef = useRef(new Map<number, number>())
const cleanupTimerRef = useRef<number | null>(null)
const clearCleanupTimer = useCallback(() => {
if (cleanupTimerRef.current === null) return
window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, [])
const scheduleCleanup = useCallback(() => {
function scheduleNext() {
clearCleanupTimer()
let nextExpiry = Infinity
for (const expiresAt of expirationsRef.current.values()) {
nextExpiry = Math.min(nextExpiry, expiresAt)
}
if (!Number.isFinite(nextExpiry)) return
cleanupTimerRef.current = window.setTimeout(() => {
cleanupTimerRef.current = null
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
if (expiresAt > now) continue
expirationsRef.current.delete(id)
removed = true
}
if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}
scheduleNext()
}, Math.max(0, nextExpiry - performance.now()))
}
scheduleNext()
}, [clearCleanupTimer])
const clearFloatingTexts = useCallback(() => {
clearCleanupTimer()
expirationsRef.current.clear()
setFloatingTexts([])
}, [clearCleanupTimer])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
if (entry.value <= 0) return
const id = nextId.current++
expirationsRef.current.set(id, performance.now() + durationMs)
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts),
[floatingTexts],
)
return {
floatingTexts,
floatingTextsByMember,
addFloatingText,
clearFloatingTexts,
}
}
export function useSidedFloatingCombatText(options?: FloatingCombatTextOptions) {
const {
floatingTexts,
addFloatingText,
clearFloatingTexts,
} = useFloatingCombatText<SideFloatingCombatText>(options)
const playerFloatingTexts = useMemo(
() => floatingTexts.filter((entry) => entry.side === 'player'),
[floatingTexts],
)
const cpuFloatingTexts = useMemo(
() => floatingTexts.filter((entry) => entry.side === 'cpu'),
[floatingTexts],
)
const playerFloatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(playerFloatingTexts),
[playerFloatingTexts],
)
const cpuFloatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(cpuFloatingTexts),
[cpuFloatingTexts],
)
const dualScreenFloatingTexts = useMemo(
() => stripFloatingTextSide(playerFloatingTexts),
[playerFloatingTexts],
)
return {
floatingTexts,
playerFloatingTexts,
cpuFloatingTexts,
playerFloatingTextsByMember,
cpuFloatingTextsByMember,
dualScreenFloatingTexts,
addFloatingText,
clearFloatingTexts,
}
}