1414 lines
52 KiB
TypeScript
1414 lines
52 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
|
import {
|
|
DEFAULT_GROUP_HEAL_TARGETS,
|
|
INITIAL_PARTY,
|
|
tankPressureTargets,
|
|
type CombatLogEntry,
|
|
type PartyMember,
|
|
type Spell,
|
|
} from '../game'
|
|
import { completeRoguelike, recordPvpMatch } from '../profile'
|
|
import type { CharacterProfile } from '../profile'
|
|
import type { GameMode } from '../gameRepository'
|
|
import { PartyMemberFrame } from './PartyFrames'
|
|
import { SpellBar } from './SpellBars'
|
|
import { barFillStyle } from './barStyles'
|
|
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
|
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
|
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
|
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
|
import {
|
|
clamp,
|
|
createLogEntry,
|
|
effectiveMaxHealth,
|
|
toCombatSpell,
|
|
} from '../combat/rules'
|
|
import { createStackCounts, summarizeStackCounts, type StackCounts } from '../combat/stackCounts'
|
|
import {
|
|
hasSpellStack,
|
|
spellCooldownMultiplier,
|
|
spellExtraTargets,
|
|
spellPowerMultiplier,
|
|
spellResourceCost as modifiedSpellResourceCost,
|
|
} from '../combat/spellModifiers'
|
|
import { regenerateResource } from '../combat/combatTick'
|
|
import { advanceMemberTick } from '../combat/combatEngine'
|
|
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
|
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
|
import {
|
|
pruneExpiredCooldowns,
|
|
} from '../combat/spellCasting'
|
|
import {
|
|
appendCombatLog,
|
|
STADIUM_COMBAT_LOG_LIMIT,
|
|
} from '../combat/combatPresentation'
|
|
import {
|
|
buildSpellTargetPlan,
|
|
type SpellEffectProfile,
|
|
} from '../combat/spellEffects'
|
|
import { runCpuHealTurn, type CpuTurnBehavior } from '../combat/cpuAi'
|
|
import {
|
|
chooseStadiumCpuPurchases,
|
|
resolveStadiumRound,
|
|
stadiumShopPointsForOutcome,
|
|
type StadiumExperienceMode,
|
|
type StadiumRoundOutcome,
|
|
} from '../combat/stadiumLifecycle'
|
|
import {
|
|
createStadiumLiveMatchStart,
|
|
createStadiumMatchStart,
|
|
createStadiumStarterSide,
|
|
} from '../combat/stadiumMatchSetup'
|
|
import {
|
|
createEmptyRewardSummary,
|
|
mergeDungeonRewardSummary,
|
|
type RewardSummaryBase,
|
|
} from '../combat/rewardSummaries'
|
|
import { RewardXpSummary } from './RewardPanels'
|
|
import { ResultScreen } from './ResultScreen'
|
|
import {
|
|
publishPvpMatchState,
|
|
submitPvpUpgradeChoice,
|
|
type CpuDifficulty,
|
|
type PvpMatchSide,
|
|
type PvpMatchSnapshot,
|
|
} from '../pvpRoguelike'
|
|
import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle'
|
|
import { usePvpLiveMatchSync } from '../pvpLiveLifecycle'
|
|
|
|
const TICK_MS = 700
|
|
const ROUND_START_SECONDS = 3
|
|
const SHOP_SECONDS = 60
|
|
const MAX_RESOURCE = 100
|
|
const RESOURCE_REGEN_PER_TICK = 0.8
|
|
|
|
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
|
type StadiumBuffId =
|
|
| `slot${SlotKey}-extra-target`
|
|
| `slot${SlotKey}-cost-down`
|
|
| `slot${SlotKey}-cooldown-down`
|
|
| 'slot1-applies-renew'
|
|
| 'slot1-applies-shield'
|
|
| 'slot2-applies-shield'
|
|
| 'slot2-double-duration'
|
|
| 'slot3-applies-shield'
|
|
| 'slot3-applies-renew'
|
|
| 'slot4-applies-renew'
|
|
| 'slot5-applies-renew'
|
|
| 'slot5-applies-shield'
|
|
| 'fifth-cast-free'
|
|
| 'group-heal-boost'
|
|
| 'shield-boost'
|
|
|
|
type StadiumBuff = {
|
|
id: StadiumBuffId
|
|
name: string
|
|
description: string
|
|
category: SlotKey | 'misc'
|
|
cost: 1 | 2
|
|
}
|
|
|
|
type StadiumSideState = {
|
|
party: PartyMember[]
|
|
resource: number
|
|
cooldowns: Record<string, number>
|
|
buffs: StadiumBuffId[]
|
|
castsTowardFree: number
|
|
freeCastReady: boolean
|
|
survivalSeconds: number
|
|
dampeningPercent: number
|
|
roundIndex: number
|
|
roundWins: number
|
|
roundStatus: 'playing' | 'shop' | 'won' | 'lost'
|
|
lastRoundOutcome?: StadiumRoundOutcome
|
|
shopReady: boolean
|
|
}
|
|
|
|
type LivePvpMatch = {
|
|
id: string
|
|
side: PvpMatchSide
|
|
opponentSide: PvpMatchSide
|
|
opponentName: string
|
|
opponentClassName: string
|
|
}
|
|
|
|
const CPU_BEHAVIOR: Record<CpuDifficulty, CpuTurnBehavior> = {
|
|
1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.54, groupHealThreshold: 0.5, hotThreshold: 0.6, shieldThreshold: 0.48 },
|
|
2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.6, groupHealThreshold: 0.56, hotThreshold: 0.66, shieldThreshold: 0.54 },
|
|
3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.66, groupHealThreshold: 0.62, hotThreshold: 0.72, shieldThreshold: 0.6 },
|
|
4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.72, groupHealThreshold: 0.68, hotThreshold: 0.78, shieldThreshold: 0.66 },
|
|
5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.78, groupHealThreshold: 0.74, hotThreshold: 0.84, shieldThreshold: 0.72 },
|
|
}
|
|
|
|
function formatTime(seconds: number) {
|
|
const total = Math.max(0, Math.floor(seconds))
|
|
const minutes = Math.floor(total / 60)
|
|
const remaining = total % 60
|
|
return `${minutes}:${String(remaining).padStart(2, '0')}`
|
|
}
|
|
|
|
function slotLabel(slot: SlotKey, spells: Spell[]) {
|
|
const spell = spells.find((candidate) => candidate.key === slot)
|
|
return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}`
|
|
}
|
|
|
|
function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
|
|
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
|
}
|
|
|
|
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
|
const directName = slotSpellName('1', spells, 'Mend')
|
|
const sustainName = slotSpellName('2', spells, 'Renew')
|
|
const groupName = slotSpellName('3', spells, 'Radiance')
|
|
const shieldName = slotSpellName('4', spells, 'Sun Ward')
|
|
const cleanseName = slotSpellName('5', spells, 'Purify')
|
|
const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
|
const label = slotLabel(slot, spells)
|
|
const baseBuffs: StadiumBuff[] = [
|
|
{
|
|
id: `slot${slot}-extra-target` as StadiumBuffId,
|
|
name: '+1 target',
|
|
description: `${label} affects 1 additional ally when possible.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
{
|
|
id: `slot${slot}-cost-down` as StadiumBuffId,
|
|
name: '-25% cost',
|
|
description: `${label} costs 25% less mana.`,
|
|
category: slot,
|
|
cost: 1,
|
|
},
|
|
{
|
|
id: `slot${slot}-cooldown-down` as StadiumBuffId,
|
|
name: '-25% cooldown',
|
|
description: `${label} recharges 25% faster.`,
|
|
category: slot,
|
|
cost: 1,
|
|
},
|
|
]
|
|
const specialBuffs: Partial<Record<SlotKey, StadiumBuff[]>> = {
|
|
1: [
|
|
{
|
|
id: 'slot1-applies-renew',
|
|
name: `Applies ${sustainName}`,
|
|
description: `${directName} also applies ${sustainName} to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
{
|
|
id: 'slot1-applies-shield',
|
|
name: `Applies ${shieldName}`,
|
|
description: `${directName} also applies a ${shieldName} barrier to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
],
|
|
2: [
|
|
{
|
|
id: 'slot2-applies-shield',
|
|
name: `Applies ${shieldName}`,
|
|
description: `${sustainName} also applies a ${shieldName} barrier to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
{
|
|
id: 'slot2-double-duration',
|
|
name: 'Double Duration',
|
|
description: `${sustainName} lasts twice as long or gains extra charges.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
],
|
|
3: [
|
|
{
|
|
id: 'slot3-applies-shield',
|
|
name: `Applies 50% ${shieldName}`,
|
|
description: `${groupName} applies a ${shieldName} barrier at 50% strength to affected targets.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
{
|
|
id: 'slot3-applies-renew',
|
|
name: `Applies ${sustainName}`,
|
|
description: `${groupName} applies ${sustainName} to affected targets.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
],
|
|
4: [
|
|
{
|
|
id: 'slot4-applies-renew',
|
|
name: `Applies ${sustainName}`,
|
|
description: `${shieldName} also applies ${sustainName} to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
],
|
|
5: [
|
|
{
|
|
id: 'slot5-applies-renew',
|
|
name: `Applies ${sustainName}`,
|
|
description: `${cleanseName} also applies ${sustainName} to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
{
|
|
id: 'slot5-applies-shield',
|
|
name: `Applies ${shieldName}`,
|
|
description: `${cleanseName} also applies a ${shieldName} barrier to the target.`,
|
|
category: slot,
|
|
cost: 2,
|
|
},
|
|
],
|
|
}
|
|
return [...baseBuffs, ...(specialBuffs[slot] ?? [])]
|
|
})
|
|
return [
|
|
...slotBuffs,
|
|
{
|
|
id: 'fifth-cast-free',
|
|
name: 'Stored Momentum',
|
|
description: 'After 5 spell casts, your next cast is free.',
|
|
category: 'misc',
|
|
cost: 1,
|
|
},
|
|
{
|
|
id: 'group-heal-boost',
|
|
name: 'Wide Radiance',
|
|
description: 'Party healing is 25% stronger.',
|
|
category: 'misc',
|
|
cost: 1,
|
|
},
|
|
{
|
|
id: 'shield-boost',
|
|
name: 'Dense Shields',
|
|
description: 'Shield absorbs are 25% stronger.',
|
|
category: 'misc',
|
|
cost: 1,
|
|
},
|
|
]
|
|
}
|
|
|
|
function summarizeStacks(items: StadiumBuffId[], catalog: StadiumBuff[]) {
|
|
return summarizeStackCounts(createStackCounts(items), catalog, 'none')
|
|
}
|
|
|
|
function cooldownMultiplier(spell: Spell, buffs: StackCounts<StadiumBuffId>) {
|
|
return spellCooldownMultiplier(spell, {
|
|
stacks: buffs,
|
|
id: (slot) => `slot${slot as SlotKey}-cooldown-down` as StadiumBuffId,
|
|
})
|
|
}
|
|
|
|
function spellResourceCost(spell: Spell, buffs: StackCounts<StadiumBuffId>, freeCastReady: boolean) {
|
|
return modifiedSpellResourceCost({
|
|
spell,
|
|
costDown: {
|
|
stacks: buffs,
|
|
id: (slot) => `slot${slot as SlotKey}-cost-down` as StadiumBuffId,
|
|
},
|
|
freeCastReady,
|
|
freeCast: {
|
|
stacks: buffs,
|
|
id: 'fifth-cast-free',
|
|
},
|
|
})
|
|
}
|
|
|
|
export function PvpStadiumScreen({
|
|
profile,
|
|
gameMode,
|
|
onExit,
|
|
onProfileUpdated,
|
|
}: {
|
|
profile: CharacterProfile
|
|
gameMode: GameMode
|
|
onExit: () => void
|
|
onProfileUpdated: (profile: CharacterProfile) => void
|
|
}) {
|
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
|
const starterSpells = useMemo(() => gameClass.spells
|
|
.filter((spell) => spell.unlockLevel === 1)
|
|
.slice(0, 5)
|
|
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
|
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
|
const partyTemplate = useMemo(
|
|
() => INITIAL_PARTY.map((member) => ({
|
|
...member,
|
|
name: member.id === 'mira' ? profile.character.name : member.name,
|
|
})),
|
|
[profile.character.name],
|
|
)
|
|
const cpuPartyTemplate = useMemo(
|
|
() => INITIAL_PARTY.map((member) => ({
|
|
...member,
|
|
name: member.id === 'mira' ? 'CPU Healer' : member.name,
|
|
})),
|
|
[],
|
|
)
|
|
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
|
const rewardDifficulty = rewardDungeon.difficulties[0]
|
|
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
|
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
|
partyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
roundIndex: 1,
|
|
}))
|
|
const [cpuSide, setCpuSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
|
partyTemplate: cpuPartyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
roundIndex: 1,
|
|
}))
|
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
|
const [roundIndex, setRoundIndex] = useState(1)
|
|
const [roundWins, setRoundWins] = useState({ player: 0, opponent: 0 })
|
|
const [shopPoints, setShopPoints] = useState(0)
|
|
const [shopReady, setShopReady] = useState(false)
|
|
const [shopCategory, setShopCategory] = useState<SlotKey | 'misc'>('1')
|
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
|
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
|
const [queueMessage, setQueueMessage] = useState('Searching Stadium queue...')
|
|
const [paused, setPaused] = useState(false)
|
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }])
|
|
const {
|
|
playerFloatingTextsByMember,
|
|
cpuFloatingTextsByMember,
|
|
dualScreenFloatingTexts,
|
|
addFloatingText,
|
|
clearFloatingTexts,
|
|
} = useSidedFloatingCombatText()
|
|
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
|
const [rewardError, setRewardError] = useState('')
|
|
const [showEndLog, setShowEndLog] = useState(false)
|
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
|
const playerRef = useRef(playerSide)
|
|
const cpuRef = useRef(cpuSide)
|
|
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
|
const nextLogId = useRef(2)
|
|
const submittedShopRef = useRef(false)
|
|
const awardedXpRef = useRef(new Set<string>())
|
|
const matchStatsRecordedRef = useRef(false)
|
|
const queuedMatchRef = useRef(false)
|
|
const roundResolvedRef = useRef(false)
|
|
const loggedOpponentRoundRef = useRef('')
|
|
const {
|
|
bindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
lastDevice,
|
|
} = useInput()
|
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
|
const activeBindings = bindings[lastDevice]
|
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
|
const playerSpellSlotCost = useCallback(
|
|
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
|
[playerBuffCounts, playerSide.freeCastReady],
|
|
)
|
|
const playerSpellSlots = useSpellSlots({
|
|
spells: starterSpells,
|
|
cooldowns: playerSide.cooldowns,
|
|
active: status === 'playing' && !paused,
|
|
cost: playerSpellSlotCost,
|
|
})
|
|
const opponentBuffSummary = useMemo(
|
|
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
|
[buffCatalog, cpuSide.buffs],
|
|
)
|
|
const playerBuffSummary = useMemo(
|
|
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
|
[buffCatalog, playerSide.buffs],
|
|
)
|
|
const opponentDebuffSummary = useMemo(
|
|
() => `Dampening ${playerSide.dampeningPercent}%`,
|
|
[playerSide.dampeningPercent],
|
|
)
|
|
const partyColumns = 3
|
|
|
|
const setSelectedTargetId = useCallback((id: string) => {
|
|
selectedIdRef.current = id
|
|
setSelectedId(id)
|
|
}, [])
|
|
|
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
|
setLog((current) => appendCombatLog(current, createLogEntry(nextLogId, text, tone), STADIUM_COMBAT_LOG_LIMIT))
|
|
}, [])
|
|
|
|
const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
|
|
addFloatingText({ side, memberId, value })
|
|
}, [addFloatingText])
|
|
|
|
const {
|
|
timeLeft: roundCountdown,
|
|
start: startRoundCountdown,
|
|
clear: clearRoundCountdown,
|
|
} = useRoundCountdown({
|
|
seconds: ROUND_START_SECONDS,
|
|
active: status === 'round-countdown',
|
|
onComplete: () => {
|
|
setStatus((current) => current === 'round-countdown' ? 'playing' : current)
|
|
},
|
|
})
|
|
|
|
const {
|
|
timeLeft: shopTimeLeft,
|
|
start: startShopTimer,
|
|
reset: resetShopTimer,
|
|
} = useDeadlineTimer({
|
|
initialSeconds: SHOP_SECONDS,
|
|
active: status === 'shop' && !shopReady,
|
|
intervalMs: 200,
|
|
onExpire: () => finishShop(),
|
|
})
|
|
|
|
const beginRoundCountdown = useCallback(() => {
|
|
clearRoundCountdown()
|
|
roundResolvedRef.current = false
|
|
startRoundCountdown()
|
|
setStatus('round-countdown')
|
|
}, [clearRoundCountdown, startRoundCountdown])
|
|
|
|
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
|
|
if (awardedXpRef.current.has(key)) return
|
|
awardedXpRef.current.add(key)
|
|
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
|
bossesCleared: 0,
|
|
fightsCleared: 1,
|
|
experienceMode: mode,
|
|
})
|
|
.then((result) => {
|
|
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
|
onProfileUpdated(result.profile)
|
|
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
|
})
|
|
}, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
|
|
|
const startLiveMatch = useCallback((match: PvpMatchSnapshot<StadiumSideState>, side: PvpMatchSide, message?: string) => {
|
|
const setup = createStadiumLiveMatchStart<StadiumBuffId>({
|
|
match,
|
|
side,
|
|
partyTemplate,
|
|
opponentPartyTemplate: cpuPartyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
message,
|
|
})
|
|
playerRef.current = setup.playerSide
|
|
cpuRef.current = setup.opponentSide
|
|
liveMatchRef.current = setup.liveMatch
|
|
queuedMatchRef.current = true
|
|
nextLogId.current = 2
|
|
awardedXpRef.current = new Set()
|
|
matchStatsRecordedRef.current = false
|
|
roundResolvedRef.current = false
|
|
setPlayerSide(setup.playerSide)
|
|
setCpuSide(setup.opponentSide)
|
|
setRoundIndex(setup.defaults.roundIndex)
|
|
setRoundWins(setup.defaults.roundWins)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setElapsedTicks(setup.defaults.elapsedTicks)
|
|
setShopPoints(setup.defaults.shopPoints)
|
|
setShopReady(setup.defaults.shopReady)
|
|
resetShopTimer(SHOP_SECONDS)
|
|
setCpuDifficulty(null)
|
|
setLiveMatch(setup.liveMatch)
|
|
setPaused(setup.defaults.paused)
|
|
setRewardSummary(createEmptyRewardSummary())
|
|
setRewardError(setup.defaults.rewardError)
|
|
setShowEndLog(setup.defaults.showEndLog)
|
|
clearFloatingTexts()
|
|
loggedOpponentRoundRef.current = ''
|
|
setQueueMessage(setup.logText)
|
|
setLog([{ id: 1, text: setup.logText, tone: 'system' }])
|
|
beginRoundCountdown()
|
|
}, [beginRoundCountdown, clearFloatingTexts, cpuPartyTemplate, partyTemplate, resetShopTimer, setSelectedTargetId])
|
|
|
|
const startMatch = useCallback(() => {
|
|
clearRoundCountdown()
|
|
const setup = createStadiumMatchStart<StadiumBuffId>({
|
|
partyTemplate,
|
|
opponentPartyTemplate: cpuPartyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
})
|
|
playerRef.current = setup.playerSide
|
|
cpuRef.current = setup.opponentSide
|
|
liveMatchRef.current = null
|
|
queuedMatchRef.current = true
|
|
nextLogId.current = 2
|
|
awardedXpRef.current = new Set()
|
|
matchStatsRecordedRef.current = false
|
|
roundResolvedRef.current = false
|
|
setPlayerSide(setup.playerSide)
|
|
setCpuSide(setup.opponentSide)
|
|
setRoundIndex(setup.defaults.roundIndex)
|
|
setRoundWins(setup.defaults.roundWins)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setElapsedTicks(setup.defaults.elapsedTicks)
|
|
setStatus('queueing')
|
|
setShopPoints(setup.defaults.shopPoints)
|
|
setShopReady(setup.defaults.shopReady)
|
|
resetShopTimer(SHOP_SECONDS)
|
|
setCpuDifficulty(null)
|
|
setLiveMatch(null)
|
|
setPaused(setup.defaults.paused)
|
|
setRewardSummary(createEmptyRewardSummary())
|
|
setRewardError(setup.defaults.rewardError)
|
|
setShowEndLog(setup.defaults.showEndLog)
|
|
clearFloatingTexts()
|
|
loggedOpponentRoundRef.current = ''
|
|
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
|
setCpuDifficulty(randomCpu)
|
|
setQueueMessage(message)
|
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
|
beginRoundCountdown()
|
|
}
|
|
return startPvpQueueWithCpuFallback<StadiumSideState>({
|
|
contentType: 'stadium',
|
|
startStage: 1,
|
|
gameMode,
|
|
liveMatchActive: () => Boolean(liveMatchRef.current),
|
|
onSearching: (message) => {
|
|
setQueueMessage(message)
|
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
|
},
|
|
onCpuMatch: beginCpuMatch,
|
|
onLiveMatch: (match, side, message) => startLiveMatch(match, side, message),
|
|
messages: {
|
|
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters Stadium.`,
|
|
searching: 'Searching Stadium queue for 5s.',
|
|
notFound: (difficulty) => `No Stadium player found after 5s. CPU ${difficulty} steps in.`,
|
|
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
|
liveFound: (match, side) => {
|
|
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
|
return `${match.players[opponentSide].characterName} found. Stadium begins.`
|
|
},
|
|
},
|
|
})
|
|
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
|
|
|
useEffect(() => {
|
|
const frame = window.requestAnimationFrame(() => startMatch())
|
|
return () => window.cancelAnimationFrame(frame)
|
|
}, [startMatch])
|
|
|
|
const applySpell = useCallback((
|
|
current: StadiumSideState,
|
|
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
|
sideName: 'player' | 'cpu',
|
|
spell: Spell,
|
|
targetId: string,
|
|
) => {
|
|
const buffCounts = createStackCounts(current.buffs)
|
|
const effectiveCost = spellResourceCost(spell, buffCounts, current.freeCastReady)
|
|
const dampenMultiplier = Math.max(0, 1 - current.dampeningPercent / 100)
|
|
const hasBuff = (id: StadiumBuffId) => hasSpellStack({ stacks: buffCounts, id })
|
|
const extraTargets = spellExtraTargets(spell, {
|
|
stacks: buffCounts,
|
|
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
|
})
|
|
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
|
|
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
|
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
|
sourcePower
|
|
* strength
|
|
* spellPowerMultiplier({ stacks: buffCounts, id: 'shield-boost' })
|
|
* dampenMultiplier,
|
|
)
|
|
const appliesRenew = spell.effectType === 'direct_hot'
|
|
|| (spell.key === '1' && hasBuff('slot1-applies-renew'))
|
|
|| (spell.key === '4' && hasBuff('slot4-applies-renew'))
|
|
|| (spell.key === '5' && hasBuff('slot5-applies-renew'))
|
|
const appliesShield = (spell.key === '1' && hasBuff('slot1-applies-shield'))
|
|
|| (spell.key === '2' && hasBuff('slot2-applies-shield'))
|
|
|| (spell.key === '5' && hasBuff('slot5-applies-shield'))
|
|
const {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
damageReductionTargets,
|
|
groupTargets,
|
|
} = buildSpellTargetPlan({
|
|
party: current.party,
|
|
spell,
|
|
targetId,
|
|
extraTargets,
|
|
directTarget: spell.kind === 'direct' || spell.kind === 'cleanse',
|
|
hotTarget: spell.kind === 'hot' || spell.kind === 'bounce_heal' || appliesRenew,
|
|
shieldTarget: spell.kind === 'shield' || appliesShield,
|
|
damageReductionTarget: spell.kind === 'damage_reduction',
|
|
groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
|
|
extraTargetMode: {
|
|
hot: 'hot',
|
|
bounce_heal: 'hot',
|
|
shield: 'shield',
|
|
damage_reduction: 'damageReduction',
|
|
},
|
|
})
|
|
if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id))
|
|
const groupHealBoost = spellPowerMultiplier({ stacks: buffCounts, id: 'group-heal-boost' })
|
|
const spellEffectProfile: SpellEffectProfile = {
|
|
modeName: 'stadium',
|
|
heal: (member, power) => clamp(member.health + power, 0, effectiveMaxHealth(member)),
|
|
healingMultiplier: () => 1,
|
|
power: {
|
|
direct: (source) => Math.round(source.power * dampenMultiplier),
|
|
cleanse: (source) => Math.round(source.power * dampenMultiplier),
|
|
groupHeal: (source) => Math.round(source.power * groupHealBoost * dampenMultiplier),
|
|
groupAbsorb: (source) => shieldPower(source.power),
|
|
shield: shieldPower,
|
|
},
|
|
hot: {
|
|
mode: 'ticks',
|
|
defaultTicks: renewDuration,
|
|
groupTicks: 5,
|
|
radianceTicks: 5,
|
|
merge: 'max',
|
|
groupMerge: 'max',
|
|
},
|
|
effects: {
|
|
renewSpell: undefined,
|
|
shieldSpell: shieldEffect,
|
|
groupAbsorbOnly: (source) => source.effectType === 'party_absorb',
|
|
groupHotOnly: (source) => source.effectType === 'party_hot',
|
|
groupAppliesShield: (source) => source.effectType === 'party_absorb'
|
|
|| hasBuff('slot3-applies-shield'),
|
|
groupAppliesHot: (source) => source.effectType === 'party_hot'
|
|
|| hasBuff('slot3-applies-renew'),
|
|
shieldAppliesHot: () => false,
|
|
hotSpellForDirect: (source) => source,
|
|
},
|
|
ratios: {
|
|
groupShield: 0.5,
|
|
directShield: 1,
|
|
},
|
|
damageReductionTicks: 12,
|
|
floatingHeals: {
|
|
group: true,
|
|
direct: true,
|
|
cleanse: true,
|
|
},
|
|
bounceHeals: false,
|
|
}
|
|
return applyPvpSpellCast({
|
|
current,
|
|
spell,
|
|
targetId,
|
|
resourceCost: effectiveCost,
|
|
targetPlan: {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
damageReductionTargets,
|
|
groupTargets,
|
|
},
|
|
profile: spellEffectProfile,
|
|
setCurrent,
|
|
emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value),
|
|
cooldownMultiplier: cooldownMultiplier(spell, buffCounts),
|
|
freeCast: {
|
|
enabled: hasBuff('fifth-cast-free'),
|
|
wasReady: effectiveCost === 0 && current.freeCastReady,
|
|
},
|
|
})
|
|
}, [addFloatingHeal, starterSpells])
|
|
|
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
|
if (status !== 'playing' || !playerAlive) return
|
|
const targetId = selectedIdRef.current
|
|
const succeeded = applySpell(playerRef.current, (value) => {
|
|
const next = typeof value === 'function' ? value(playerRef.current) : value
|
|
playerRef.current = next
|
|
setPlayerSide(next)
|
|
}, 'player', spell, targetId)
|
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
|
}, [addLog, applySpell, playerAlive, status])
|
|
|
|
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
|
const {
|
|
selectRelativeTarget,
|
|
selectDirectionalTarget,
|
|
selectDirectTarget,
|
|
} = usePartyTargeting({
|
|
getParty: getTargetParty,
|
|
selectedIdRef,
|
|
setSelectedTargetId,
|
|
columns: partyColumns,
|
|
livingOnly: true,
|
|
})
|
|
|
|
const cpuTakeTurn = useCallback(() => {
|
|
if (!cpuDifficulty || status !== 'playing') return
|
|
const behavior = CPU_BEHAVIOR[cpuDifficulty]
|
|
runCpuHealTurn({
|
|
side: cpuRef.current,
|
|
elapsedTicks,
|
|
spells: starterSpells,
|
|
behavior,
|
|
preferSlots: true,
|
|
applySpell: (side, spell, targetId) => {
|
|
applySpell(side, (value) => {
|
|
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
|
cpuRef.current = next
|
|
setCpuSide(next)
|
|
}, 'cpu', spell, targetId)
|
|
},
|
|
})
|
|
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
|
|
|
|
const advanceBoss = useCallback((side: StadiumSideState) => {
|
|
if (side.roundStatus !== 'playing') return side
|
|
const party = side.party
|
|
const nextSurvival = side.survivalSeconds + TICK_MS / 1000
|
|
const dampeningPercent = Math.floor(nextSurvival / 5)
|
|
const dampenMultiplier = Math.max(0, 1 - dampeningPercent / 100)
|
|
const hotHealing = Math.round(6 * dampenMultiplier)
|
|
const living = party.filter((member) => member.health > 0)
|
|
if (living.length === 0) return side
|
|
const spikeTarget = living[Math.floor(Math.random() * living.length)]
|
|
const tankIds = new Set(tankPressureTargets(party).targets.map((member) => member.id))
|
|
const pulse = elapsedTicks > 0 && elapsedTicks % 5 === 0
|
|
const spike = elapsedTicks > 0 && elapsedTicks % 8 === 0
|
|
const nextParty = party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
let damage = tankIds.has(member.id) ? 8 : 0
|
|
if (pulse) damage += 9
|
|
if (spike && member.id === spikeTarget.id) damage += 22
|
|
const result = advanceMemberTick({
|
|
member,
|
|
party,
|
|
damage,
|
|
hotHealing,
|
|
hotTicks: 'ticks',
|
|
applyDebuff: spike && member.id === spikeTarget.id
|
|
? { label: 'Marked', ticks: 4 }
|
|
: undefined,
|
|
decrementDamageReduction: true,
|
|
damageReductionRounding: 'ceil',
|
|
})
|
|
return {
|
|
...result.member,
|
|
damageReductionTicks: result.member.damageReductionTicks || undefined,
|
|
debuffTicks: result.member.debuffTicks || undefined,
|
|
}
|
|
})
|
|
return {
|
|
...side,
|
|
party: nextParty,
|
|
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
|
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
|
survivalSeconds: nextSurvival,
|
|
dampeningPercent,
|
|
}
|
|
}, [elapsedTicks])
|
|
|
|
const beginShop = useCallback((outcome: StadiumRoundOutcome, nextWins: { player: number; opponent: number }) => {
|
|
const points = stadiumShopPointsForOutcome(outcome, 'player')
|
|
submittedShopRef.current = false
|
|
setShopPoints(points)
|
|
setShopReady(false)
|
|
startShopTimer()
|
|
setRoundWins(nextWins)
|
|
setStatus('shop')
|
|
setPlayerSide((current) => {
|
|
const next = { ...current, roundStatus: 'shop' as const, lastRoundOutcome: outcome, shopReady: false, roundWins: nextWins.player }
|
|
playerRef.current = next
|
|
return next
|
|
})
|
|
if (!liveMatchRef.current) {
|
|
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
|
buffCatalog,
|
|
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
|
)
|
|
setCpuSide((current) => {
|
|
const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent }
|
|
cpuRef.current = next
|
|
return next
|
|
})
|
|
}
|
|
}, [buffCatalog, startShopTimer])
|
|
|
|
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
|
if (status !== 'playing') return
|
|
if (roundResolvedRef.current) return
|
|
roundResolvedRef.current = true
|
|
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
|
|
awardXp(result.roundExperience.key, result.roundExperience.mode)
|
|
addLog(result.log.text, result.log.tone)
|
|
if (result.status === 'won') {
|
|
setRoundWins(result.nextWins)
|
|
setStatus(result.status)
|
|
setPlayerSide((current) => {
|
|
const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player }
|
|
playerRef.current = next
|
|
return next
|
|
})
|
|
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
|
|
return
|
|
}
|
|
if (result.status === 'lost') {
|
|
setRoundWins(result.nextWins)
|
|
setStatus(result.status)
|
|
setPlayerSide((current) => {
|
|
const next = { ...current, roundStatus: result.playerRoundStatus, lastRoundOutcome: outcome, roundWins: result.nextWins.player }
|
|
playerRef.current = next
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
beginShop(outcome, result.nextWins)
|
|
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'playing' || paused) return
|
|
const timer = window.setInterval(() => {
|
|
setElapsedTicks((value) => value + 1)
|
|
if (!liveMatchRef.current) cpuTakeTurn()
|
|
const nextPlayer = advanceBoss(playerRef.current)
|
|
const nextCpu = liveMatchRef.current ? cpuRef.current : advanceBoss(cpuRef.current)
|
|
playerRef.current = nextPlayer
|
|
cpuRef.current = nextCpu
|
|
setPlayerSide(nextPlayer)
|
|
setCpuSide(nextCpu)
|
|
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
|
|
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
|
|
if (!nextPlayerAlive && (!liveMatchRef.current && !nextCpuAlive)) finishRound('tie')
|
|
else if (!nextPlayerAlive) finishRound('loss')
|
|
else if (!liveMatchRef.current && !nextCpuAlive) finishRound('win')
|
|
}, TICK_MS)
|
|
return () => window.clearInterval(timer)
|
|
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'won' && status !== 'lost') return
|
|
if (matchStatsRecordedRef.current) return
|
|
matchStatsRecordedRef.current = true
|
|
recordPvpMatch(status === 'won')
|
|
.then(onProfileUpdated)
|
|
.catch((reason: unknown) => {
|
|
addLog(
|
|
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
|
|
'danger',
|
|
)
|
|
})
|
|
}, [addLog, onProfileUpdated, status])
|
|
|
|
const startNextRound = useCallback(() => {
|
|
const nextRound = roundIndex + 1
|
|
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
|
|
partyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
roundIndex: nextRound,
|
|
buffs: playerRef.current.buffs,
|
|
roundWins: roundWins.player,
|
|
})
|
|
const nextCpu = createStadiumStarterSide<StadiumBuffId>({
|
|
partyTemplate: cpuPartyTemplate,
|
|
maxResource: MAX_RESOURCE,
|
|
roundIndex: nextRound,
|
|
buffs: cpuRef.current.buffs,
|
|
roundWins: roundWins.opponent,
|
|
})
|
|
playerRef.current = nextPlayer
|
|
cpuRef.current = nextCpu
|
|
roundResolvedRef.current = false
|
|
loggedOpponentRoundRef.current = ''
|
|
setRoundIndex(nextRound)
|
|
setPlayerSide(nextPlayer)
|
|
setCpuSide(nextCpu)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setElapsedTicks(0)
|
|
setShopReady(false)
|
|
setShopPoints(0)
|
|
addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system')
|
|
if (liveMatchRef.current) {
|
|
publishPvpMatchState<StadiumSideState>(liveMatchRef.current.id, {
|
|
state: nextPlayer,
|
|
status: 'playing',
|
|
stage: nextRound,
|
|
encounterIndex: nextRound,
|
|
encountersCleared: roundWins.player,
|
|
enemyHealth: 0,
|
|
alive: true,
|
|
elapsedTicks: 0,
|
|
}).catch(() => undefined)
|
|
}
|
|
beginRoundCountdown()
|
|
}, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId])
|
|
|
|
const finishShop = useCallback(() => {
|
|
if (shopReady || status !== 'shop') return
|
|
setShopReady(true)
|
|
submittedShopRef.current = true
|
|
setPlayerSide((current) => {
|
|
const next = { ...current, shopReady: true }
|
|
playerRef.current = next
|
|
return next
|
|
})
|
|
if (liveMatchRef.current) {
|
|
submitPvpUpgradeChoice(liveMatchRef.current.id, {
|
|
encounterIndex: roundIndex,
|
|
buffId: 'stadium-shop',
|
|
debuffId: '',
|
|
purchases: playerRef.current.buffs,
|
|
shopReady: true,
|
|
}).catch(() => undefined)
|
|
return
|
|
}
|
|
startNextRound()
|
|
}, [roundIndex, shopReady, startNextRound, status])
|
|
|
|
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
|
|
liveMatch,
|
|
syncEnabled: status !== 'queueing',
|
|
startLiveMatch,
|
|
getPayload: () => ({
|
|
state: playerRef.current,
|
|
status: status === 'shop' || status === 'won' || status === 'lost' ? status : 'playing',
|
|
stage: roundIndex,
|
|
encounterIndex: roundIndex,
|
|
encountersCleared: roundWins.player,
|
|
enemyHealth: 0,
|
|
alive: playerRef.current.party.some((member) => member.health > 0),
|
|
elapsedTicks,
|
|
}),
|
|
onSnapshot: (snapshot, currentLiveMatch) => {
|
|
const opponentState = snapshot.states[currentLiveMatch.opponentSide]
|
|
if (opponentState && opponentState.roundIndex >= roundIndex) {
|
|
cpuRef.current = opponentState
|
|
setCpuSide(opponentState)
|
|
}
|
|
const opponentStatus = snapshot.statuses[currentLiveMatch.opponentSide]
|
|
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost')
|
|
if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won')
|
|
if (!opponentState) return
|
|
if (opponentState.roundIndex < roundIndex) return
|
|
if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) {
|
|
const key = `${roundIndex}-${opponentState.lastRoundOutcome}`
|
|
if (loggedOpponentRoundRef.current === key) return
|
|
loggedOpponentRoundRef.current = key
|
|
if (opponentState.lastRoundOutcome === 'loss') finishRound('win')
|
|
else if (opponentState.lastRoundOutcome === 'win') finishRound('loss')
|
|
else finishRound('tie')
|
|
}
|
|
if (
|
|
status === 'shop'
|
|
&& shopReady
|
|
&& (
|
|
(opponentState.roundIndex === roundIndex && opponentState.shopReady)
|
|
|| opponentState.roundIndex > roundIndex
|
|
)
|
|
) {
|
|
startNextRound()
|
|
}
|
|
},
|
|
})
|
|
|
|
const buyBuff = useCallback((buff: StadiumBuff) => {
|
|
if (status !== 'shop' || shopReady || shopPoints < buff.cost) return
|
|
let purchased = false
|
|
setShopPoints((points) => {
|
|
if (points < buff.cost) return points
|
|
purchased = true
|
|
return points - buff.cost
|
|
})
|
|
if (!purchased) return
|
|
setPlayerSide((current) => {
|
|
const next = { ...current, buffs: [...current.buffs, buff.id] }
|
|
playerRef.current = next
|
|
return next
|
|
})
|
|
addLog(`${buff.name} purchased.`, 'loot')
|
|
}, [addLog, shopPoints, shopReady, status])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'shop') return
|
|
window.requestAnimationFrame(() => focusFirstControl())
|
|
}, [status])
|
|
|
|
useEffect(() => {
|
|
if (!paused) return
|
|
window.requestAnimationFrame(() => focusFirstControl())
|
|
}, [paused])
|
|
|
|
useGameAction((action) => {
|
|
if (action === 'pause' || action === 'back') {
|
|
if (status === 'playing') setPaused((value) => !value)
|
|
return
|
|
}
|
|
if (paused || status !== 'playing') return
|
|
if (action.startsWith('navigate')) {
|
|
selectDirectionalTarget(action)
|
|
return
|
|
}
|
|
if (action === 'previousTarget') {
|
|
selectRelativeTarget(-1)
|
|
return
|
|
}
|
|
if (action === 'nextTarget') {
|
|
selectRelativeTarget(1)
|
|
return
|
|
}
|
|
if (action.startsWith('targetParty')) {
|
|
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
|
return
|
|
}
|
|
if (action.startsWith('ability')) {
|
|
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
|
if (spell) castPlayerSpell(spell)
|
|
}
|
|
})
|
|
|
|
const dualScreenState = useMemo(() => buildStadiumDualScreenState({
|
|
difficultyName: 'Equalized iLvl 10',
|
|
dungeonName: 'Stadium',
|
|
contentName: 'Stadium',
|
|
encounterName: 'Iron Arbiter',
|
|
encounterDescription: 'Survive escalating arena pressure.',
|
|
encounterHealth: 0,
|
|
encounterMaxHealth: 1,
|
|
encounterIsBoss: true,
|
|
encounterIndex: roundIndex - 1,
|
|
encounterCount: 5,
|
|
party: playerSide.party,
|
|
opponentName: opponentLabel,
|
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
|
opponentParty: cpuSide.party,
|
|
opponentEnemyHealth: 0,
|
|
opponentResource: cpuSide.resource,
|
|
opponentMaxResource: MAX_RESOURCE,
|
|
opponentResourceName: gameClass.resourceName,
|
|
opponentBuffSummary,
|
|
opponentDebuffSummary,
|
|
floatingTexts: dualScreenFloatingTexts,
|
|
partySize: playerSide.party.length,
|
|
selectedId,
|
|
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status === 'shop' ? 'upgrade-choice' : status,
|
|
resource: playerSide.resource,
|
|
maxResource: MAX_RESOURCE,
|
|
resourceName: gameClass.resourceName,
|
|
playerIsAlive: playerAlive,
|
|
spells: playerSpellSlots,
|
|
bindings: activeBindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
paused,
|
|
stadium: {
|
|
dampeningPercent: playerSide.dampeningPercent,
|
|
roundIndex,
|
|
playerWins: roundWins.player,
|
|
opponentWins: roundWins.opponent,
|
|
survivalSeconds: playerSide.survivalSeconds,
|
|
opponentSurvivalSeconds: cpuSide.survivalSeconds,
|
|
},
|
|
}), [
|
|
activeBindings,
|
|
controllerIconStyle,
|
|
cpuDifficulty,
|
|
cpuSide.party,
|
|
cpuSide.resource,
|
|
cpuSide.survivalSeconds,
|
|
directPartyTargeting,
|
|
dualScreenFloatingTexts,
|
|
gameClass.resourceName,
|
|
liveMatch?.opponentClassName,
|
|
opponentBuffSummary,
|
|
opponentDebuffSummary,
|
|
opponentLabel,
|
|
paused,
|
|
playerAlive,
|
|
playerSide.dampeningPercent,
|
|
playerSide.party,
|
|
playerSide.resource,
|
|
playerSide.survivalSeconds,
|
|
roundIndex,
|
|
roundWins.opponent,
|
|
roundWins.player,
|
|
selectedId,
|
|
playerSpellSlots,
|
|
status,
|
|
])
|
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
|
|
|
const visibleBuffs = useMemo(
|
|
() => buffCatalog.filter((buff) => buff.category === shopCategory),
|
|
[buffCatalog, shopCategory],
|
|
)
|
|
const categoryLabel = shopCategory === 'misc' ? 'Miscellaneous' : slotLabel(shopCategory, starterSpells)
|
|
|
|
return (
|
|
<main className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`} data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}>
|
|
<section className="content-screen stadium-screen">
|
|
{status === 'queueing' && (
|
|
<div className="placeholder-panel">
|
|
<div className="placeholder-runes">P V P</div>
|
|
<p>{queueMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{dualScreenEnabled && status !== 'queueing' && (
|
|
<div className="dual-top-main stadium-dual-top">
|
|
<header className="stadium-header">
|
|
<div>
|
|
<p className="eyebrow">Stadium</p>
|
|
<h2>Round {roundIndex} / Best of 5</h2>
|
|
</div>
|
|
<strong>{roundWins.player} - {roundWins.opponent}</strong>
|
|
<div>
|
|
<p>Dampening {playerSide.dampeningPercent}%</p>
|
|
<small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="stadium-pressure-panel">
|
|
<strong>Iron Arbiter</strong>
|
|
<span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
|
|
</section>
|
|
|
|
<section className="dual-top-party">
|
|
<div className="dual-top-party-grid">
|
|
{playerSide.party.map((member, index) => {
|
|
const action = `targetParty${index + 1}` as InputAction
|
|
const targetBinding = directPartyTargeting ? activeBindings[action] : null
|
|
return (
|
|
<PartyMemberFrame
|
|
baseClassName="dual-top-member"
|
|
controllerIconStyle={controllerIconStyle}
|
|
effectMode="compact"
|
|
floatingTexts={playerFloatingTextsByMember.get(member.id) ?? []}
|
|
key={member.id}
|
|
member={member}
|
|
onSelect={setSelectedTargetId}
|
|
selected={selectedId === member.id}
|
|
targetBinding={targetBinding}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="dual-top-spell-strip">
|
|
{playerSpellSlots.map((spell, slotIndex) => {
|
|
if (!spell) return null
|
|
const remaining = spell.remaining
|
|
const percent = remaining > 0
|
|
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
|
: 0
|
|
return (
|
|
<button
|
|
className="dual-top-spell"
|
|
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
|
|
key={spell.id}
|
|
onClick={() => castPlayerSpell(spell)}
|
|
type="button"
|
|
>
|
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
{remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
|
{remaining > 0 && <small>{remaining.toFixed(0)}</small>}
|
|
<span className="sr-only">{slotIndex + 1}. {spell.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
<div className="dual-top-resource">
|
|
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
|
<div className="bar mana-bar">
|
|
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{!dualScreenEnabled && status !== 'queueing' && (
|
|
<div className="stadium-board">
|
|
<header className="stadium-header">
|
|
<div>
|
|
<p className="eyebrow">Stadium</p>
|
|
<h2>Round {roundIndex} / Best of 5</h2>
|
|
</div>
|
|
<strong>{roundWins.player} - {roundWins.opponent}</strong>
|
|
<div>
|
|
<p>Dampening {playerSide.dampeningPercent}%</p>
|
|
<small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="stadium-pressure-panel">
|
|
<strong>Iron Arbiter</strong>
|
|
<span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
|
|
</section>
|
|
|
|
<section className="combat-panel stadium-side">
|
|
<div className="encounter-header">
|
|
<div>
|
|
<p className="eyebrow">You</p>
|
|
<h2>{profile.character.name}</h2>
|
|
</div>
|
|
<div className="pvp-resource-wrap">
|
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
|
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
|
|
</div>
|
|
</div>
|
|
<div className="party-grid pvp-party-grid">
|
|
{playerSide.party.map((member) => (
|
|
<PartyMemberFrame
|
|
deadClassName="down"
|
|
effectMode="compact"
|
|
floatingTexts={playerFloatingTextsByMember.get(member.id) ?? []}
|
|
key={member.id}
|
|
member={member}
|
|
onSelect={setSelectedTargetId}
|
|
selected={selectedId === member.id}
|
|
showHeaderHealth={false}
|
|
showHealthText
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">Buffs: {playerBuffSummary}</p>
|
|
</section>
|
|
|
|
<section className="combat-panel stadium-side">
|
|
<div className="encounter-header">
|
|
<div>
|
|
<p className="eyebrow">Opponent</p>
|
|
<h2>{opponentLabel}</h2>
|
|
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
|
</div>
|
|
<div className="pvp-resource-wrap">
|
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
|
</div>
|
|
</div>
|
|
<div className="party-grid pvp-party-grid">
|
|
{cpuSide.party.map((member) => (
|
|
<PartyMemberFrame
|
|
as="div"
|
|
deadClassName="down"
|
|
effectMode="compact"
|
|
floatingTexts={cpuFloatingTextsByMember.get(member.id) ?? []}
|
|
key={member.id}
|
|
member={member}
|
|
showHeaderHealth={false}
|
|
showHealthText
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">Buffs: {opponentBuffSummary}</p>
|
|
</section>
|
|
|
|
<SpellBar
|
|
bindings={activeBindings}
|
|
canCast={status === 'playing' && playerAlive}
|
|
className="pvp-bottom-spell-bar"
|
|
focusable={false}
|
|
iconStyle={controllerIconStyle}
|
|
onCast={castPlayerSpell}
|
|
resource={playerSide.resource}
|
|
resourceName={gameClass.resourceName}
|
|
spells={playerSpellSlots}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'round-countdown' && (
|
|
<div className="pvp-round-countdown">
|
|
<div>
|
|
<p className="eyebrow">Round Starts</p>
|
|
<h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'shop' && (
|
|
<div className="result-screen">
|
|
<div className="stadium-shop-dialog">
|
|
<div className="pvp-upgrade-header">
|
|
<div>
|
|
<p className="eyebrow">Stadium Buy Round</p>
|
|
<h2>Round {roundIndex} Complete</h2>
|
|
</div>
|
|
<strong>{shopTimeLeft.toFixed(0)}s</strong>
|
|
</div>
|
|
<div className="stadium-shop-summary">
|
|
<span>Points: {shopPoints}</span>
|
|
<span>Score: {roundWins.player} - {roundWins.opponent}</span>
|
|
{shopReady && <span>Waiting for opponent...</span>}
|
|
</div>
|
|
<div className="stadium-shop-layout">
|
|
<nav className="stadium-shop-tabs">
|
|
{(['1', '2', '3', '4', '5'] as SlotKey[]).map((slot) => (
|
|
<button className={shopCategory === slot ? 'active' : ''} key={slot} onClick={() => setShopCategory(slot)} type="button">
|
|
{slotLabel(slot, starterSpells)}
|
|
</button>
|
|
))}
|
|
<button className={shopCategory === 'misc' ? 'active' : ''} onClick={() => setShopCategory('misc')} type="button">
|
|
Miscellaneous
|
|
</button>
|
|
</nav>
|
|
<section>
|
|
<h3>{categoryLabel} Buffs</h3>
|
|
<div className="stadium-shop-grid">
|
|
{visibleBuffs.map((buff) => (
|
|
<button disabled={shopReady || shopPoints < buff.cost} key={buff.id} onClick={() => buyBuff(buff)} type="button">
|
|
<strong>[{buff.cost}] {buff.name}</strong>
|
|
<small>{buff.description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p>Active: {playerBuffSummary}</p>
|
|
</section>
|
|
</div>
|
|
<button className="secondary-result-button" disabled={shopReady} onClick={finishShop} type="button">
|
|
{shopReady ? 'Finished' : 'Finished Buying'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{paused && (
|
|
<div className="pause-screen">
|
|
<div>
|
|
<p className="eyebrow">Paused</p>
|
|
<h2>Stadium</h2>
|
|
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
|
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(status === 'won' || status === 'lost') && (
|
|
<ResultScreen
|
|
actions={[
|
|
{ label: 'Queue Next Match', onClick: () => startMatch() },
|
|
{ label: 'Back to Roguelike', onClick: onExit, className: 'secondary-result-button' },
|
|
]}
|
|
eyebrow={status === 'won' ? 'Victory' : 'Defeat'}
|
|
log={log}
|
|
onToggleLog={() => setShowEndLog((value) => !value)}
|
|
rematch={{
|
|
visible: Boolean(liveMatch),
|
|
requested: rematchRequested,
|
|
message: rematchMessage,
|
|
onRematch: handleRematch,
|
|
}}
|
|
showLog={showEndLog}
|
|
title={status === 'won' ? 'Stadium Won' : `${opponentLabel} Wins`}
|
|
>
|
|
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
|
<div className="reward-summary">
|
|
<RewardXpSummary reward={rewardSummary} />
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
</div>
|
|
</ResultScreen>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|