/* eslint-disable react-refresh/only-export-components */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import type { CombatLogEntry, PartyMember, Spell } from './game' import { hasNativeDualScreenBridge, openNativeTopDisplay, } from './nativeDualScreen' import { dispatchExternalGameAction, type ControllerIconStyle, type InputAction, } from './input' import { ControllerBindingLabel } from './components/ControllerIcons' const STORAGE_KEY = 'ashen-halls-dual-screen-enabled' const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot' const CHANNEL_NAME = 'ashen-halls-dual-screen' export type DualScreenCombatState = { difficultyName: string dungeonName: string contentName: string encounterName: string encounterDescription: string encounterHealth: number encounterMaxHealth: number encounterIsBoss: boolean encounterIndex: number encounterCount: number party: PartyMember[] partySize: number selectedId: string log: CombatLogEntry[] status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice' resource: number maxResource: number resourceName: string playerIsAlive: boolean spells: Array<(Spell & { slotIndex: number; remaining: number }) | null> activeDevice: 'pc' | 'controller' bindings: Record controllerIconStyle: ControllerIconStyle directPartyTargeting: boolean paused: boolean targetGroup: 0 | 1 } type DualScreenMessage = | { type: 'combat-state'; state: DualScreenCombatState } | { type: 'companion-ready' } | { type: 'companion-heartbeat' } | { type: 'control-action'; action: InputAction } | { type: 'combat-ended' } type DualScreenContextValue = { enabled: boolean connected: boolean setEnabled: (enabled: boolean) => void openTopDisplay: () => Promise } const DualScreenContext = createContext(null) function createChannel() { return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME) } function saveSnapshot(state: DualScreenCombatState) { try { localStorage.setItem(SNAPSHOT_KEY, JSON.stringify({ savedAt: Date.now(), state, })) } catch { // Live BroadcastChannel updates still work if storage is unavailable. } } function loadRecentSnapshot() { try { const snapshot = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) ?? 'null') as { savedAt: number state: DualScreenCombatState } | null if (!snapshot || Date.now() - snapshot.savedAt > 15000) return null return snapshot.state } catch { return null } } export function DualScreenProvider({ children }: { children: ReactNode }) { const [enabled, setEnabledState] = useState( () => localStorage.getItem(STORAGE_KEY) === 'true', ) const [connected, setConnected] = useState(false) const heartbeatRef = useRef(0) const setEnabled = useCallback((nextEnabled: boolean) => { localStorage.setItem(STORAGE_KEY, String(nextEnabled)) setEnabledState(nextEnabled) if (!nextEnabled) setConnected(false) }, []) const openTopDisplay = useCallback(async () => { setEnabled(true) if (hasNativeDualScreenBridge()) { try { await openNativeTopDisplay() return true } catch { return false } } const url = new URL(window.location.href) url.searchParams.set('display', 'bottom') const companion = window.open( url.toString(), 'ashen-halls-top-display', 'popup=yes,width=1280,height=720', ) companion?.focus() return Boolean(companion) }, [setEnabled]) useEffect(() => { const channel = createChannel() if (!channel) return channel.onmessage = (event: MessageEvent) => { if ( event.data.type !== 'companion-ready' && event.data.type !== 'companion-heartbeat' ) return heartbeatRef.current = Date.now() setConnected(true) } const timer = window.setInterval(() => { if (Date.now() - heartbeatRef.current > 3500) setConnected(false) }, 1000) return () => { window.clearInterval(timer) channel.close() } }, []) const value = useMemo( () => ({ enabled, connected, setEnabled, openTopDisplay }), [connected, enabled, openTopDisplay, setEnabled], ) return ( {children} ) } export function useDualScreen() { const context = useContext(DualScreenContext) if (!context) throw new Error('useDualScreen must be used inside DualScreenProvider') return context } export function useDualScreenPublisher( state: DualScreenCombatState, enabled: boolean, ) { const stateRef = useRef(state) useEffect(() => { stateRef.current = state }, [state]) useEffect(() => { if (!enabled) return const channel = createChannel() if (!channel) return const publish = () => channel.postMessage({ type: 'combat-state', state: stateRef.current, } satisfies DualScreenMessage) channel.onmessage = (event: MessageEvent) => { if (event.data.type === 'companion-ready') publish() if (event.data.type === 'control-action') { dispatchExternalGameAction(event.data.action, 'controller') } } publish() return () => { channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage) channel.close() } }, [enabled]) useEffect(() => { if (!enabled) return saveSnapshot(state) const channel = createChannel() channel?.postMessage({ type: 'combat-state', state } satisfies DualScreenMessage) channel?.close() }, [enabled, state]) } export function DualScreenBottomDisplay() { const [state, setState] = useState(loadRecentSnapshot) useEffect(() => { const channel = createChannel() if (!channel) return const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage) channel.onmessage = (event: MessageEvent) => { if (event.data.type === 'combat-state') setState(event.data.state) if (event.data.type === 'combat-ended') setState(null) } announce() const timer = window.setInterval(() => { channel.postMessage({ type: 'companion-heartbeat' } satisfies DualScreenMessage) }, 1500) return () => { window.clearInterval(timer) channel.close() } }, []) function sendAction(action: InputAction) { const channel = createChannel() channel?.postMessage({ type: 'control-action', action } satisfies DualScreenMessage) channel?.close() } if (!state) { return (

Dual-Screen HUD

Waiting for Combat

Choose a dungeon or raid on the upper screen.

) } return (

{state.difficultyName} {state.contentName}

{state.dungeonName}

Encounter {state.encounterIndex + 1}/{state.encounterCount}

Active Target

{state.party.find((member) => member.id === state.selectedId)?.name ?? 'No Target'}
{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}
{state.directPartyTargeting ? ( <> {([1, 2, 3, 4, 5] as const).map((slot) => { const action = `targetParty${slot}` as InputAction const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0) return ( ) })} {state.partySize === 10 && ( )} ) : ( <> )}
{state.spells.map((spell, slotIndex) => { if (!spell) { return (
{slotIndex + 1} Empty
) } const action = `ability${slotIndex + 1}` as InputAction return ( ) })}
) } export function DualScreenTopCombat({ state, onSelectTarget, }: { state: DualScreenCombatState onSelectTarget: (id: string) => void }) { const enemyPercent = Math.max( 0, (state.encounterHealth / state.encounterMaxHealth) * 100, ) return (
{state.encounterName} {Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}

{state.encounterDescription}

{state.party.map((member, index) => { const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0) const targetAction = `targetParty${partySlot}` as InputAction const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null return ( ) })}
{state.log.slice(0, 3).map((entry) => ( {entry.text} ))}
) }