Files
i-want-to-heal/src/dualScreen.tsx
T
2026-06-19 20:55:23 -04:00

516 lines
16 KiB
TypeScript

/* 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 {
getNativeDisplays,
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 STARTUP_CHOICE_KEY = 'ashen-halls-dual-screen-startup-choice'
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<InputAction, string>
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1 | 2
}
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<boolean>
}
const DualScreenContext = createContext<DualScreenContextValue | null>(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<DualScreenMessage>) => {
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 (
<DualScreenContext.Provider value={value}>
{children}
</DualScreenContext.Provider>
)
}
export function useDualScreen() {
const context = useContext(DualScreenContext)
if (!context) throw new Error('useDualScreen must be used inside DualScreenProvider')
return context
}
export function DualScreenStartupPrompt() {
const { openTopDisplay, setEnabled } = useDualScreen()
const [visible, setVisible] = useState(false)
const [displayCount, setDisplayCount] = useState<number | null>(null)
const [message, setMessage] = useState('')
const autoOpenedRef = useRef(false)
useEffect(() => {
if (!hasNativeDualScreenBridge()) return
if (new URLSearchParams(window.location.search).has('display')) return
const choice = localStorage.getItem(STARTUP_CHOICE_KEY)
if (choice === 'yes') {
if (autoOpenedRef.current) return
autoOpenedRef.current = true
openTopDisplay().catch(() => {
// Settings can still launch the display manually if Android rejects startup launch.
})
return
}
if (choice === 'no') return
getNativeDisplays()
.then((result) => setDisplayCount(result.displays.length))
.catch(() => setDisplayCount(null))
.finally(() => setVisible(true))
}, [openTopDisplay])
async function enableDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'yes')
setMessage('Opening second display...')
const opened = await openTopDisplay()
if (opened) {
setVisible(false)
return
}
setMessage('No second display found. Check Thor display mode, then try again.')
}
function skipDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'no')
setEnabled(false)
setVisible(false)
}
if (!visible) return null
return (
<div className="dual-startup-prompt" role="dialog" aria-modal="true">
<section>
<p className="eyebrow">Display Setup</p>
<h2>Use Dual-Screen Mode?</h2>
<p>
Choose yes on AYN Thor. The game opens the combat view on the upper
display and keeps controls on the lower display.
</p>
{displayCount !== null && (
<small>{displayCount} Android display{displayCount === 1 ? '' : 's'} detected.</small>
)}
{message && <small>{message}</small>}
<div>
<button onClick={enableDualScreen} type="button">Yes, Enable</button>
<button onClick={skipDualScreen} type="button">No</button>
</div>
</section>
</div>
)
}
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<DualScreenMessage>) => {
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<DualScreenCombatState | null>(loadRecentSnapshot)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
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 (
<main className="dual-bottom-display dual-bottom-waiting">
<section>
<p className="eyebrow">Dual-Screen HUD</p>
<h1>Waiting for Combat</h1>
<p>Choose a dungeon or raid on the upper screen.</p>
</section>
</main>
)
}
return (
<main className="dual-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
<h1>{state.dungeonName}</h1>
</div>
<div className="dual-controls-progress">
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
</div>
</header>
<section className="dual-controls-resource">
<div>
<p className="eyebrow">Active Target</p>
<strong>
{state.party.find((member) => member.id === state.selectedId)?.name ?? 'No Target'}
</strong>
</div>
<div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div>
</div>
</section>
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? (
<>
{([1, 2, 3, 4, 5, 6] as const).map((slot) => {
const action = `targetParty${slot}` as InputAction
const memberIndex = slot - 1 + (state.partySize > 6 ? state.targetGroup * 6 : 0)
return (
<button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel
binding={state.bindings[action]}
iconStyle={state.controllerIconStyle}
/>{' '}
{state.party[memberIndex]?.name ?? `Party ${slot}`}
</button>
)
})}
{state.partySize > 6 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel
binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle}
/>{' '}
Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
</button>
)}
</>
) : (
<>
<button onClick={() => sendAction('previousTarget')} type="button">
<ControllerBindingLabel
binding={state.bindings.previousTarget}
iconStyle={state.controllerIconStyle}
/> Previous Target
</button>
<button onClick={() => sendAction('nextTarget')} type="button">
Next Target <ControllerBindingLabel
binding={state.bindings.nextTarget}
iconStyle={state.controllerIconStyle}
/>
</button>
</>
)}
</div>
<section className="dual-controls-spells">
{state.spells.map((spell, slotIndex) => {
if (!spell) {
return (
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
<kbd>{slotIndex + 1}</kbd>
<strong>Empty</strong>
</div>
)
}
const action = `ability${slotIndex + 1}` as InputAction
return (
<button
className="spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => sendAction(action)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={state.bindings[action]}
compact
iconStyle={state.controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{spell.cost} {state.resourceName}</small>
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</main>
)
}
export function DualScreenTopCombat({
state,
onSelectTarget,
}: {
state: DualScreenCombatState
onSelectTarget: (id: string) => void
}) {
const enemyPercent = Math.max(
0,
(state.encounterHealth / state.encounterMaxHealth) * 100,
)
return (
<div className="dual-top-main">
<section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'}
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{state.encounterName}</strong>
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div>
<div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} />
</div>
<p>{state.encounterDescription}</p>
</div>
</section>
<section className="dual-top-party">
<div className={`dual-top-party-grid ${state.partySize > 6 ? 'raid' : ''}`}>
{state.party.map((member, index) => {
const partySlot = (index % 6) + 1
const targetAction = `targetParty${partySlot}` as InputAction
const groupStart = state.partySize > 6 ? state.targetGroup * 6 : 0
const inCurrentTargetGroup = index >= groupStart && index < groupStart + 6
const targetBinding = state.directPartyTargeting && inCurrentTargetGroup ? state.bindings[targetAction] : null
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => onSelectTarget(member.id)}
type="button"
>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
{member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)}
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
</div>
{state.directPartyTargeting && targetBinding && (
<div className="member-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={state.controllerIconStyle}
/>
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
)
})}
</div>
</section>
</div>
)
}