Initial I Want to Heal app
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
/* 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<InputAction, string>
|
||||
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<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 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] as const).map((slot) => {
|
||||
const action = `targetParty${slot}` as InputAction
|
||||
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 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 === 10 && (
|
||||
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings.toggleTargetGroup}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>{' '}
|
||||
Party Group {state.targetGroup + 1}
|
||||
</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 === 10 ? 'raid' : ''}`}>
|
||||
{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 (
|
||||
<button
|
||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||
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}%` }} />
|
||||
)}
|
||||
</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>
|
||||
|
||||
<footer className="dual-top-log">
|
||||
{state.log.slice(0, 3).map((entry) => (
|
||||
<span className={entry.tone} key={entry.id}>{entry.text}</span>
|
||||
))}
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user