932 lines
31 KiB
TypeScript
932 lines
31 KiB
TypeScript
/* eslint-disable react-refresh/only-export-components */
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import type { PartyMember, Spell } from './game'
|
|
import {
|
|
getNativeDisplays,
|
|
hasNativeDualScreenBridge,
|
|
openNativeTopDisplay,
|
|
} from './nativeDualScreen'
|
|
import {
|
|
dispatchExternalGameAction,
|
|
type ControllerIconStyle,
|
|
type InputAction,
|
|
useGameAction,
|
|
} from './input'
|
|
import { ControllerBindingLabel } from './components/ControllerIcons'
|
|
import { PartyMemberFrame } from './components/PartyFrames'
|
|
import { barFillStyle } from './components/barStyles'
|
|
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
|
|
|
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'
|
|
const COMBAT_PUBLISH_INTERVAL_MS = 100
|
|
const COMBAT_SNAPSHOT_INTERVAL_MS = 1000
|
|
|
|
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[]
|
|
opponentName?: string
|
|
opponentClassName?: string
|
|
opponentParty?: PartyMember[]
|
|
opponentEnemyHealth?: number
|
|
opponentArena?: {
|
|
bounds: { width: number, height: number }
|
|
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
|
|
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
|
|
}
|
|
opponentResource?: number
|
|
opponentMaxResource?: number
|
|
opponentResourceName?: string
|
|
opponentBuffSummary?: string
|
|
opponentDebuffSummary?: string
|
|
floatingTexts: Array<{
|
|
id: number
|
|
memberId: string
|
|
value: number
|
|
}>
|
|
partySize: number
|
|
selectedId: string
|
|
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
|
|
resource: number
|
|
maxResource: number
|
|
resourceName: string
|
|
playerIsAlive: boolean
|
|
spells: Array<(Spell & { slotIndex: number; remaining: number }) | null>
|
|
bindings: Record<InputAction, string>
|
|
controllerIconStyle: ControllerIconStyle
|
|
directPartyTargeting: boolean
|
|
paused: boolean
|
|
targetGroup: 0 | 1 | 2
|
|
speedMultiplier: 1 | 2
|
|
stadium?: {
|
|
dampeningPercent: number
|
|
roundIndex: number
|
|
playerWins: number
|
|
opponentWins: number
|
|
survivalSeconds: number
|
|
opponentSurvivalSeconds: number
|
|
}
|
|
}
|
|
|
|
export type DualScreenWorkshopState = {
|
|
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
|
|
title: string
|
|
subtitle: string
|
|
summary?: string
|
|
items: Array<{
|
|
glyph?: string
|
|
title: string
|
|
meta?: string
|
|
detail?: string
|
|
status?: string
|
|
}>
|
|
}
|
|
|
|
export type DualScreenSetupState = {
|
|
contentType: 'dungeon' | 'raid'
|
|
title: string
|
|
subtitle: string
|
|
description: string
|
|
initials: string
|
|
difficultyName: string
|
|
itemLevel: number
|
|
experience: number
|
|
lockedReason?: string
|
|
stats: {
|
|
health: string
|
|
damage: string
|
|
xp: string
|
|
loot: string
|
|
}
|
|
}
|
|
|
|
type DualScreenMessage =
|
|
| { type: 'combat-state'; state: DualScreenCombatState }
|
|
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
|
| { type: 'setup-state'; state: DualScreenSetupState }
|
|
| { type: 'companion-ready' }
|
|
| { type: 'companion-heartbeat' }
|
|
| { type: 'control-action'; action: InputAction }
|
|
| { type: 'combat-ended' }
|
|
| { type: 'workshop-ended' }
|
|
| { type: 'setup-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
|
|
}
|
|
}
|
|
|
|
function formatDualTime(seconds: number) {
|
|
const total = Math.max(0, Math.floor(seconds))
|
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function shouldRelayBottomAction(
|
|
action: InputAction,
|
|
state: DualScreenCombatState | null,
|
|
) {
|
|
if (!state) return false
|
|
if (action === 'confirm' || action === 'toggleTouchLock') return false
|
|
if (state.status === 'playing') return true
|
|
return action === 'pause' || action === 'back'
|
|
}
|
|
|
|
function shouldRelaySetupAction(
|
|
action: InputAction,
|
|
state: DualScreenSetupState | null,
|
|
) {
|
|
if (!state) return false
|
|
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
|
}
|
|
|
|
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()
|
|
saveSnapshot(stateRef.current)
|
|
const publishTimer = window.setInterval(publish, COMBAT_PUBLISH_INTERVAL_MS)
|
|
const snapshotTimer = window.setInterval(() => {
|
|
saveSnapshot(stateRef.current)
|
|
}, COMBAT_SNAPSHOT_INTERVAL_MS)
|
|
return () => {
|
|
window.clearInterval(publishTimer)
|
|
window.clearInterval(snapshotTimer)
|
|
saveSnapshot(stateRef.current)
|
|
channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage)
|
|
channel.close()
|
|
}
|
|
}, [enabled])
|
|
}
|
|
|
|
export function useDualScreenWorkshopPublisher(
|
|
state: DualScreenWorkshopState | null,
|
|
enabled: boolean,
|
|
) {
|
|
const stateRef = useRef(state)
|
|
useEffect(() => {
|
|
stateRef.current = state
|
|
}, [state])
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !state) return
|
|
const channel = createChannel()
|
|
if (!channel) return
|
|
const publish = () => {
|
|
if (stateRef.current) {
|
|
channel.postMessage({
|
|
type: 'workshop-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: 'workshop-ended' } satisfies DualScreenMessage)
|
|
channel.close()
|
|
}
|
|
}, [enabled, state])
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !state) return
|
|
const channel = createChannel()
|
|
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
|
channel?.close()
|
|
}, [enabled, state])
|
|
}
|
|
|
|
export function useDualScreenSetupPublisher(
|
|
state: DualScreenSetupState | null,
|
|
enabled: boolean,
|
|
) {
|
|
const stateRef = useRef(state)
|
|
useEffect(() => {
|
|
stateRef.current = state
|
|
}, [state])
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !state) return
|
|
const channel = createChannel()
|
|
if (!channel) return
|
|
const publish = () => {
|
|
if (stateRef.current) {
|
|
channel.postMessage({
|
|
type: 'setup-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: 'setup-ended' } satisfies DualScreenMessage)
|
|
channel.close()
|
|
}
|
|
}, [enabled, state])
|
|
|
|
useEffect(() => {
|
|
const channel = createChannel()
|
|
if (!enabled || !channel) return
|
|
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
|
|
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
|
|
channel.close()
|
|
}, [enabled, state])
|
|
}
|
|
|
|
export function DualScreenBottomDisplay() {
|
|
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
|
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
|
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
|
|
|
|
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)
|
|
setWorkshopState(null)
|
|
setSetupState(null)
|
|
}
|
|
if (event.data.type === 'workshop-state') {
|
|
setWorkshopState(event.data.state)
|
|
setState(null)
|
|
setSetupState(null)
|
|
}
|
|
if (event.data.type === 'setup-state') {
|
|
setSetupState(event.data.state)
|
|
setState(null)
|
|
setWorkshopState(null)
|
|
}
|
|
if (event.data.type === 'combat-ended') setState(null)
|
|
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
|
if (event.data.type === 'setup-ended') setSetupState(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()
|
|
}
|
|
|
|
useGameAction((action) => {
|
|
if (
|
|
!shouldRelayBottomAction(action, state)
|
|
&& !shouldRelaySetupAction(action, setupState)
|
|
) return
|
|
sendAction(action)
|
|
})
|
|
|
|
if (!state && workshopState) {
|
|
return (
|
|
<main className="dual-bottom-display workshop-bottom-display">
|
|
<header className="dual-controls-header">
|
|
<div>
|
|
<p className="eyebrow">{workshopState.mode}</p>
|
|
<h1>{workshopState.title}</h1>
|
|
</div>
|
|
<div className="dual-controls-progress">
|
|
<span>{workshopState.subtitle}</span>
|
|
</div>
|
|
</header>
|
|
{workshopState.summary && (
|
|
<section className="workshop-bottom-summary">
|
|
{workshopState.summary}
|
|
</section>
|
|
)}
|
|
<section className="workshop-bottom-grid">
|
|
{workshopState.items.map((item, index) => (
|
|
<article key={`${item.title}-${index}`}>
|
|
{item.glyph && <span>{item.glyph}</span>}
|
|
<div>
|
|
<strong>{item.title}</strong>
|
|
{item.meta && <small>{item.meta}</small>}
|
|
{item.detail && <p>{item.detail}</p>}
|
|
</div>
|
|
{item.status && <i>{item.status}</i>}
|
|
</article>
|
|
))}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!state && setupState) {
|
|
return (
|
|
<main className="dual-bottom-display setup-bottom-display">
|
|
<header className="dual-controls-header">
|
|
<div>
|
|
<p className="eyebrow">{setupState.contentType}</p>
|
|
<h1>{setupState.title}</h1>
|
|
<small>{setupState.subtitle}</small>
|
|
</div>
|
|
<div className="dual-controls-progress">
|
|
<span>{setupState.difficultyName}</span>
|
|
<span>iLvl {setupState.itemLevel}</span>
|
|
<span>{setupState.experience} XP</span>
|
|
</div>
|
|
</header>
|
|
<section className="setup-bottom-summary">
|
|
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
|
|
{setupState.initials}
|
|
</span>
|
|
<div>
|
|
<p className="eyebrow">Selected Run</p>
|
|
<h2>{setupState.title}</h2>
|
|
<p>{setupState.description}</p>
|
|
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
|
|
</div>
|
|
</section>
|
|
<dl className="setup-bottom-stats">
|
|
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
|
|
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
|
|
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
|
|
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
|
|
</dl>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
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 ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
|
|
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
|
|
>
|
|
<header className="dual-controls-header">
|
|
<div>
|
|
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
|
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
|
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
|
</div>
|
|
<div className="dual-controls-progress">
|
|
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
|
</div>
|
|
</header>
|
|
|
|
{state.opponentParty ? (
|
|
<>
|
|
{state.stadium ? (
|
|
<section className="dual-opponent-progress stadium">
|
|
<div>
|
|
<p className="eyebrow">Dampening</p>
|
|
<strong>{state.stadium.dampeningPercent}%</strong>
|
|
</div>
|
|
<div>
|
|
<p className="eyebrow">Survival</p>
|
|
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="dual-opponent-progress">
|
|
<div>
|
|
<p className="eyebrow">Opponent Clear</p>
|
|
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
|
</div>
|
|
<div className="bar enemy-health boss-bar">
|
|
<span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
|
|
</div>
|
|
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
|
|
<>
|
|
<div>
|
|
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
|
|
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
|
|
</div>
|
|
<div className="bar mana-bar">
|
|
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</section>
|
|
)}
|
|
|
|
{state.opponentArena && (
|
|
<section className="dual-opponent-arena" aria-label="Opponent arena">
|
|
<div
|
|
className="dual-opponent-arena-field"
|
|
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
|
|
>
|
|
{state.opponentArena.bosses.map((boss) => (
|
|
<div
|
|
className="dual-opponent-arena-entity boss"
|
|
key={boss.id}
|
|
style={{
|
|
backgroundColor: boss.color,
|
|
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
|
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
|
|
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
|
|
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
|
}}
|
|
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
|
|
>
|
|
{boss.icon}
|
|
</div>
|
|
))}
|
|
{state.opponentArena.party.map((member) => (
|
|
<div
|
|
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
|
|
key={member.id}
|
|
style={{
|
|
backgroundColor: member.color,
|
|
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
|
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
|
|
opacity: member.health > 0 ? 1 : 0.35,
|
|
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
|
|
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
|
}}
|
|
>
|
|
{member.icon}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
|
{state.opponentParty.map((member) => (
|
|
<PartyMemberFrame
|
|
as="article"
|
|
baseClassName="dual-opponent-member"
|
|
effectMode="compact"
|
|
key={member.id}
|
|
member={member}
|
|
/>
|
|
))}
|
|
</section>
|
|
|
|
<section className="dual-opponent-effects">
|
|
<span>Buffs: {state.opponentBuffSummary || 'none'}</span>
|
|
<span>Debuffs: {state.opponentDebuffSummary || 'none'}</span>
|
|
</section>
|
|
</>
|
|
) : (
|
|
<>
|
|
|
|
<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>
|
|
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
|
<div className="bar mana-bar">
|
|
<span style={barFillStyle((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,
|
|
onCastSpell,
|
|
}: {
|
|
state: DualScreenCombatState
|
|
onSelectTarget: (id: string) => void
|
|
onCastSpell?: (spell: Spell) => void
|
|
}) {
|
|
const enemyPercent = Math.max(
|
|
0,
|
|
(state.encounterHealth / state.encounterMaxHealth) * 100,
|
|
)
|
|
const floatingTextsByMember = useMemo(
|
|
() => groupFloatingTextsByMember(state.floatingTexts),
|
|
[state.floatingTexts],
|
|
)
|
|
|
|
const spellButtons = state.spells.map((spell, slotIndex) => {
|
|
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
|
const percent = spell.remaining > 0
|
|
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
|
: 0
|
|
return (
|
|
<button
|
|
className="dual-top-spell"
|
|
data-controller-nav={state.opponentParty ? 'skip' : undefined}
|
|
disabled={
|
|
!state.playerIsAlive
|
|
|| state.resource < spell.cost
|
|
|| spell.remaining > 0
|
|
|| state.status !== 'playing'
|
|
|| state.paused
|
|
}
|
|
key={spell.id}
|
|
onClick={() => onCastSpell?.(spell)}
|
|
type="button"
|
|
>
|
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
|
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
|
</button>
|
|
)
|
|
})
|
|
|
|
return (
|
|
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
|
|
<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={barFillStyle(enemyPercent)} />
|
|
</div>
|
|
{state.opponentParty && (
|
|
<div className="dual-top-resource">
|
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
|
<div className="bar mana-bar">
|
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!state.opponentParty && <p>{state.encounterDescription}</p>}
|
|
</div>
|
|
{state.opponentParty && (
|
|
<div className="dual-top-side-status">
|
|
<div className="dual-top-cooldowns" aria-label="Cooldowns">
|
|
{spellButtons}
|
|
</div>
|
|
</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 (
|
|
<PartyMemberFrame
|
|
baseClassName="dual-top-member"
|
|
controllerIconStyle={state.controllerIconStyle}
|
|
effectMode="compact"
|
|
floatingTexts={floatingTextsByMember.get(member.id) ?? []}
|
|
key={member.id}
|
|
member={member}
|
|
onSelect={onSelectTarget}
|
|
selected={state.selectedId === member.id}
|
|
targetBinding={targetBinding}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{!state.opponentParty && (
|
|
<section className="dual-top-spell-strip">
|
|
{spellButtons}
|
|
<div className="dual-top-resource">
|
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
|
<div className="bar mana-bar">
|
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|