1621 lines
63 KiB
TypeScript
1621 lines
63 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
INITIAL_PARTY,
|
|
RAID_PARTY,
|
|
DEFAULT_GROUP_HEAL_TARGETS,
|
|
partyDamageOutput,
|
|
tankPressureTargets,
|
|
type CombatLogEntry,
|
|
type PartyMember,
|
|
type Spell,
|
|
} from '../game'
|
|
import { completeRoguelike, type DungeonReward } from '../profile'
|
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
|
import type { GameMode } from '../gameRepository'
|
|
import { PartyMemberFrame } from './PartyFrames'
|
|
import { SpellBar, type SpellSlot } from './SpellBars'
|
|
import { focusFirstControl, useGameAction, useInput } from '../input'
|
|
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
|
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
|
import {
|
|
chooseRandom,
|
|
createLogEntry,
|
|
healMember,
|
|
toCombatSpell,
|
|
} from '../combat/rules'
|
|
import { buildRoguelikeSegment } from '../combat/encounters'
|
|
import { chooseCpuHealActions, type CpuTurnBehavior } from '../combat/cpuAi'
|
|
import {
|
|
buildOpponentSlotDebuffChoices,
|
|
buildSelfSlotUpgradeChoices,
|
|
summarizeChoiceStacks,
|
|
} from '../combat/roguelikeUpgrades'
|
|
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
|
|
import {
|
|
spellCooldownMultiplier,
|
|
spellExtraTargets,
|
|
spellResourceCost as modifiedSpellResourceCost,
|
|
} from '../combat/spellModifiers'
|
|
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
|
import { advanceMemberTick } from '../combat/combatEngine'
|
|
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
|
|
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
|
import {
|
|
nextPvpRoguelikeStage,
|
|
resolvePvpRoguelikeCombatOutcome,
|
|
resolvePvpRoguelikeUpgradeCompletion,
|
|
resolvePvpRoguelikeLiveSnapshot,
|
|
type PvpRoguelikeStatus,
|
|
} from '../combat/pvpRoguelikeLifecycle'
|
|
import {
|
|
applyPvpRoguelikeUpgradeChoice,
|
|
preparePvpRoguelikeNextEncounter,
|
|
} from '../combat/pvpRoguelikeUpgrades'
|
|
import {
|
|
createPvpRoguelikeLiveMatchStart,
|
|
createPvpRoguelikeMatchStart,
|
|
createPvpRoguelikeStarterSide,
|
|
} from '../combat/pvpRoguelikeMatchSetup'
|
|
import {
|
|
createEmptyPvpRunSummary,
|
|
mergePvpRunRewardSummary,
|
|
type PvpRunRewardSummary,
|
|
} from '../combat/rewardSummaries'
|
|
import {
|
|
appendCombatLog,
|
|
} from '../combat/combatPresentation'
|
|
import {
|
|
buildSpellTargetPlan,
|
|
type SpellEffectProfile,
|
|
} from '../combat/spellEffects'
|
|
import { BonusItemReward, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
|
import { ResultScreen } from './ResultScreen'
|
|
import {
|
|
DualScreenTopCombat,
|
|
useDualScreen,
|
|
useDualScreenPublisher,
|
|
} from '../dualScreen'
|
|
import {
|
|
loadPvpRoguelikeCheckpoint,
|
|
loadPvpMatch,
|
|
recordCpuPvpLeaderboard,
|
|
recordPvpRoguelikeCheckpoint,
|
|
submitPvpUpgradeChoice,
|
|
type CpuDifficulty,
|
|
type PvpMatchSnapshot,
|
|
type PvpMatchSide,
|
|
type PvpContentType,
|
|
type PvpUpgradeChoicePayload,
|
|
} from '../pvpRoguelike'
|
|
import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle'
|
|
import { usePvpLiveMatchSync } from '../pvpLiveLifecycle'
|
|
|
|
const TICK_MS = 700
|
|
const ROUND_START_SECONDS = 3
|
|
const UPGRADE_CHOICE_SECONDS = 15
|
|
|
|
type BossMechanic =
|
|
| 'party-pulse'
|
|
| 'searing-mark'
|
|
| 'max-health-cut'
|
|
| 'healing-reduction'
|
|
| 'ramping-poison'
|
|
|
|
type PvpEncounter = DungeonEncounter & {
|
|
bossMechanics?: BossMechanic[]
|
|
sourceEncounterId?: number
|
|
}
|
|
|
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
|
type DraftSlotKey = Exclude<SlotKey, '6'>
|
|
type AbilityLabelMode = 'ability' | 'slot'
|
|
|
|
type SelfBuffId =
|
|
| 'revive-party-members'
|
|
| `slot${DraftSlotKey}-extra-target`
|
|
| `slot${DraftSlotKey}-cost-down`
|
|
| `slot${DraftSlotKey}-cooldown-down`
|
|
|
|
type OpponentDebuffId =
|
|
| `opp-slot${DraftSlotKey}-cost-up`
|
|
| `opp-slot${DraftSlotKey}-cooldown-up`
|
|
|
|
type Choice<T extends string> = {
|
|
id: T
|
|
name: string
|
|
description: string
|
|
}
|
|
|
|
type SideState = {
|
|
party: PartyMember[]
|
|
resource: number
|
|
cooldowns: Record<string, number>
|
|
enemyHealth: number
|
|
buffs: SelfBuffId[]
|
|
debuffs: OpponentDebuffId[]
|
|
castsTowardFree: number
|
|
freeCastReady: boolean
|
|
}
|
|
|
|
type LivePvpMatch = {
|
|
id: string
|
|
side: PvpMatchSide
|
|
opponentSide: PvpMatchSide
|
|
opponentName: string
|
|
opponentClassName: string
|
|
}
|
|
|
|
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
|
|
id: 'revive-party-members',
|
|
name: 'Revive Party Members',
|
|
description: 'Revive fallen party members before the next fight.',
|
|
}
|
|
|
|
const BOSS_MECHANICS: BossMechanic[] = [
|
|
'party-pulse',
|
|
'searing-mark',
|
|
'max-health-cut',
|
|
'healing-reduction',
|
|
'ramping-poison',
|
|
]
|
|
|
|
const CPU_BEHAVIOR: Record<CpuDifficulty, CpuTurnBehavior> = {
|
|
1: { actionEveryTicks: 3, mistakeChance: 0.28, directHealThreshold: 0.56, groupHealThreshold: 0.52, hotThreshold: 0.62, shieldThreshold: 0.5 },
|
|
2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.58, groupHealThreshold: 0.55, hotThreshold: 0.66, shieldThreshold: 0.5 },
|
|
3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.64, groupHealThreshold: 0.6, hotThreshold: 0.72, shieldThreshold: 0.56 },
|
|
4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.7, groupHealThreshold: 0.66, hotThreshold: 0.78, shieldThreshold: 0.62 },
|
|
5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.76, groupHealThreshold: 0.72, hotThreshold: 0.82, shieldThreshold: 0.68 },
|
|
}
|
|
|
|
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
|
return buildSelfSlotUpgradeChoices<SelfBuffId>({
|
|
slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
|
|
spells,
|
|
labelMode,
|
|
})
|
|
}
|
|
|
|
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
|
return buildOpponentSlotDebuffChoices<OpponentDebuffId>({
|
|
slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
|
|
spells,
|
|
labelMode,
|
|
})
|
|
}
|
|
|
|
function cooldownMultiplier(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>) {
|
|
return spellCooldownMultiplier(
|
|
spell,
|
|
{
|
|
stacks: buffs,
|
|
id: (slot) => `slot${slot as SlotKey}-cooldown-down` as SelfBuffId,
|
|
},
|
|
{
|
|
stacks: debuffs,
|
|
id: (slot) => `opp-slot${slot as SlotKey}-cooldown-up` as OpponentDebuffId,
|
|
},
|
|
)
|
|
}
|
|
|
|
function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>, freeCastReady: boolean) {
|
|
void freeCastReady
|
|
return modifiedSpellResourceCost({
|
|
spell,
|
|
costDown: {
|
|
stacks: buffs,
|
|
id: (slot) => `slot${slot as SlotKey}-cost-down` as SelfBuffId,
|
|
},
|
|
costUp: {
|
|
stacks: debuffs,
|
|
id: (slot) => `opp-slot${slot as SlotKey}-cost-up` as OpponentDebuffId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
|
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
|
return buildRoguelikeSegment<BossMechanic, {
|
|
bossMechanics: BossMechanic[]
|
|
sourceEncounterId?: number
|
|
}>({
|
|
pool,
|
|
stage,
|
|
mechanics,
|
|
fallbackBossFirst: true,
|
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
|
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
|
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
|
damageScale: 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14),
|
|
partyDamageScale: 0.85 + stage * 0.04,
|
|
idBase: 910000,
|
|
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
|
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
|
sourceEncounterId: encounter.id,
|
|
bossMechanics: isBoss ? selectedMechanics : [],
|
|
}),
|
|
}) as PvpEncounter[]
|
|
}
|
|
|
|
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
|
if (buff.id === 'revive-party-members') return 10
|
|
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
|
const spell = spells.find((candidate) => candidate.key === slot)
|
|
if (!spell) return 5
|
|
if (buff.id.endsWith('extra-target')) {
|
|
if (spell.kind === 'group') return 2
|
|
if (spell.kind === 'cleanse') return 7
|
|
return spell.kind === 'shield' ? 6 : 8
|
|
}
|
|
if (buff.id.endsWith('cost-down')) return spell.cost >= 10 ? 8 : 6
|
|
return spell.cooldown >= 5 ? 8 : 6
|
|
}
|
|
|
|
function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
|
|
void opponentBuffCount
|
|
if (debuff.id.endsWith('cost-up')) return 7
|
|
return 6
|
|
}
|
|
|
|
function selectCpuChoice<T extends string>(
|
|
choices: Array<Choice<T>>,
|
|
skill: CpuDifficulty,
|
|
score: (choice: Choice<T>) => number,
|
|
) {
|
|
const ranked = [...choices].sort((left, right) => score(right) - score(left))
|
|
if (skill <= 2) return ranked[Math.floor(Math.random() * ranked.length)]
|
|
if (skill === 3) return ranked[Math.floor(Math.random() * Math.min(2, ranked.length))]
|
|
if (skill === 4) return ranked[Math.floor(Math.random() * Math.min(1, ranked.length))]
|
|
return ranked[0]
|
|
}
|
|
|
|
function summarizeStacks<T extends string>(items: T[], catalog: Array<Choice<T>>) {
|
|
return summarizeChoiceStacks(items, catalog)
|
|
}
|
|
|
|
export function PvPRoguelikeScreen({
|
|
profile,
|
|
gameMode,
|
|
contentType,
|
|
encounterPool,
|
|
onExit,
|
|
onProfileUpdated,
|
|
}: {
|
|
profile: CharacterProfile
|
|
gameMode: GameMode
|
|
contentType: PvpContentType
|
|
encounterPool: DungeonEncounter[]
|
|
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, 6)
|
|
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
|
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
|
const selfBuffChoicesCatalog = useMemo(
|
|
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
|
|
[abilityLabelMode, starterSpells],
|
|
)
|
|
const opponentDebuffChoicesCatalog = useMemo(
|
|
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
|
[abilityLabelMode, starterSpells],
|
|
)
|
|
const [checkpointStage, setCheckpointStage] = useState(() =>
|
|
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
|
|
)
|
|
const [startStage, setStartStage] = useState(checkpointStage)
|
|
const maxResource = gameClass.maxResource
|
|
const partyTemplate = useMemo(
|
|
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
|
...member,
|
|
name: member.id === 'mira' ? profile.character.name : member.name,
|
|
})),
|
|
[contentType, profile.character.name],
|
|
)
|
|
const cpuPartyTemplate = useMemo(
|
|
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
|
...member,
|
|
name: member.id === 'mira' ? 'CPU Healer' : member.name,
|
|
})),
|
|
[contentType],
|
|
)
|
|
const [status, setStatus] = useState<PvpRoguelikeStatus>('queueing')
|
|
const [stage, setStage] = useState(startStage)
|
|
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
|
const [encounterIndex, setEncounterIndex] = useState(0)
|
|
const [playerSide, setPlayerSide] = useState<SideState>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(partyTemplate, maxResource))
|
|
const [cpuSide, setCpuSide] = useState<SideState>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(cpuPartyTemplate, maxResource))
|
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
|
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
|
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
|
const [queueMessage, setQueueMessage] = useState('')
|
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
|
const [runSummary, setRunSummary] = useState<PvpRunRewardSummary>(() => createEmptyPvpRunSummary())
|
|
const [rewardError, setRewardError] = useState('')
|
|
const [showEndLog, setShowEndLog] = useState(false)
|
|
const {
|
|
playerFloatingTextsByMember,
|
|
cpuFloatingTextsByMember,
|
|
dualScreenFloatingTexts,
|
|
addFloatingText,
|
|
clearFloatingTexts,
|
|
} = useSidedFloatingCombatText()
|
|
const [playerBuffChoices, setPlayerBuffChoices] = useState<Array<Choice<SelfBuffId>>>([])
|
|
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
|
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
|
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
|
const [encountersCleared, setEncountersCleared] = useState(0)
|
|
const [paused, setPaused] = useState(false)
|
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
|
const nextLogId = useRef(2)
|
|
const elapsedTicksRef = useRef(0)
|
|
const recordedRunRef = useRef(false)
|
|
const rewardClaimedRef = useRef(false)
|
|
const matchWinRewardClaimedRef = useRef(false)
|
|
const bossRewardClaimedRef = useRef(new Set<number>())
|
|
const cpuDefeatedRef = useRef(false)
|
|
const playerClearedEncounterRef = useRef(-1)
|
|
const queuedMatchRef = useRef(false)
|
|
const autoSubmittedUpgradeRef = useRef(false)
|
|
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
|
const loggedOpponentDoneRef = useRef(false)
|
|
const pendingLiveUpgradeRef = useRef<{
|
|
encounterIndex: number
|
|
buff: Choice<SelfBuffId>
|
|
debuff: Choice<OpponentDebuffId>
|
|
} | null>(null)
|
|
const encounterPoolRef = useRef(encounterPool)
|
|
const playerRef = useRef(playerSide)
|
|
const cpuRef = useRef(cpuSide)
|
|
const encounter = encounters[encounterIndex]
|
|
const rewardDungeon = useMemo(
|
|
() => profile.dungeons.find((candidate) => candidate.contentType === contentType) ?? profile.dungeons[0],
|
|
[contentType, profile.dungeons],
|
|
)
|
|
const rewardDifficulty = rewardDungeon.difficulties[0]
|
|
const finalEncountersCleared = status === 'won'
|
|
? Math.max(encountersCleared, encounterIndex + 1)
|
|
: encountersCleared
|
|
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
|
const activeSpellEffects = useMemo(
|
|
() => new Set(
|
|
gameClass.talents
|
|
.filter((talent) => talent.rank > 0)
|
|
.map((talent) => talent.effectType),
|
|
),
|
|
[gameClass.talents],
|
|
)
|
|
const playerDone = playerSide.enemyHealth <= 0
|
|
const cpuDone = cpuSide.enemyHealth <= 0
|
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
|
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
|
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
|
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
|
...spell,
|
|
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
|
slotIndex,
|
|
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
|
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
|
const opponentBuffSummary = useMemo(
|
|
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
|
[cpuSide.buffs, selfBuffChoicesCatalog],
|
|
)
|
|
const opponentDebuffSummary = useMemo(
|
|
() => cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
|
|
[cpuSide.debuffs, opponentDebuffChoicesCatalog],
|
|
)
|
|
const playerBuffSummary = useMemo(
|
|
() => playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none',
|
|
[playerSide.buffs, selfBuffChoicesCatalog],
|
|
)
|
|
const playerDebuffSummary = useMemo(
|
|
() => playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
|
|
[opponentDebuffChoicesCatalog, playerSide.debuffs],
|
|
)
|
|
const partyColumns = contentType === 'raid' ? 6 : 3
|
|
const {
|
|
bindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
lastDevice,
|
|
} = useInput()
|
|
const {
|
|
enabled: dualScreenEnabled,
|
|
} = useDualScreen()
|
|
const activeBindings = bindings[lastDevice]
|
|
|
|
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)))
|
|
}, [])
|
|
|
|
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: upgradeTimeLeft,
|
|
start: startUpgradeTimer,
|
|
reset: resetUpgradeTimer,
|
|
} = useDeadlineTimer({
|
|
initialSeconds: UPGRADE_CHOICE_SECONDS,
|
|
active: status === 'upgrade-choice' && !liveUpgradePending,
|
|
onExpire: () => {
|
|
if (autoSubmittedUpgradeRef.current) return
|
|
autoSubmittedUpgradeRef.current = true
|
|
const autoBuff = selectedBuff ?? chooseRandom(playerBuffChoices, 1)[0]
|
|
const autoDebuff = selectedDebuff ?? chooseRandom(playerDebuffChoices, 1)[0]
|
|
if (autoBuff) setSelectedBuff(autoBuff)
|
|
if (autoDebuff) setSelectedDebuff(autoDebuff)
|
|
if (autoBuff && autoDebuff) {
|
|
addLog('Upgrade timer expired. Random choices selected.', 'system')
|
|
confirmUpgradeChoices(autoBuff, autoDebuff)
|
|
}
|
|
},
|
|
})
|
|
|
|
const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
|
|
addFloatingText({ side, memberId, value })
|
|
}, [addFloatingText])
|
|
|
|
const beginRoundCountdown = useCallback((message?: string) => {
|
|
clearRoundCountdown()
|
|
startRoundCountdown()
|
|
setStatus('round-countdown')
|
|
if (message) addLog(message, 'system')
|
|
}, [addLog, clearRoundCountdown, startRoundCountdown])
|
|
|
|
useEffect(() => {
|
|
if (queuedMatchRef.current) return
|
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
|
setCheckpointStage(loadedCheckpoint)
|
|
setStartStage(loadedCheckpoint)
|
|
}, [contentType, profile.character.id])
|
|
|
|
useEffect(() => {
|
|
encounterPoolRef.current = encounterPool
|
|
}, [encounterPool])
|
|
|
|
const awardEncounterReward = useCallback((encounterIndexValue: number) => {
|
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
|
const rewardEncounter = encounters[encounterIndexValue]
|
|
const isBossReward = Boolean(rewardEncounter?.isBoss)
|
|
completeRoguelike(
|
|
rewardDungeon.id,
|
|
rewardDifficulty.id,
|
|
0,
|
|
0,
|
|
Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
|
|
{
|
|
bossesCleared: isBossReward ? 1 : 0,
|
|
fightsCleared: 1,
|
|
experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
|
|
lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
|
|
roguelikeStage: isBossReward ? stage : undefined,
|
|
},
|
|
)
|
|
.then((result) => {
|
|
setReward(result)
|
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
|
|
onProfileUpdated(result.profile)
|
|
if (result.experienceGained > 0) {
|
|
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
|
}
|
|
if (result.bonusItem) {
|
|
addLog(
|
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
|
'loot',
|
|
)
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setRewardError(
|
|
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
|
)
|
|
})
|
|
}, [addLog, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
|
|
|
|
const awardMatchWinReward = useCallback(() => {
|
|
if (matchWinRewardClaimedRef.current) return
|
|
matchWinRewardClaimedRef.current = true
|
|
completeRoguelike(
|
|
rewardDungeon.id,
|
|
rewardDifficulty.id,
|
|
0,
|
|
0,
|
|
Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
|
|
{
|
|
bossesCleared: 0,
|
|
fightsCleared: 0,
|
|
experienceMode: 'pvp-match-win-half-level',
|
|
},
|
|
)
|
|
.then((result) => {
|
|
setReward(result)
|
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
|
|
onProfileUpdated(result.profile)
|
|
if (result.experienceGained > 0) {
|
|
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setRewardError(
|
|
reason instanceof Error ? reason.message : 'Unable to award match win experience.',
|
|
)
|
|
})
|
|
}, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
|
|
|
const finishRoguelikeRun = useCallback((result: 'won' | 'lost') => {
|
|
if (rewardClaimedRef.current) return
|
|
rewardClaimedRef.current = true
|
|
if (result === 'won') awardMatchWinReward()
|
|
}, [awardMatchWinReward])
|
|
|
|
useEffect(() => {
|
|
const frame = window.requestAnimationFrame(() => {
|
|
setPlayerBuffChoices((current) => current
|
|
.map((choice) => choice.id === REVIVE_PARTY_CHOICE.id
|
|
? REVIVE_PARTY_CHOICE
|
|
: selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
|
|
.filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
|
|
setPlayerDebuffChoices((current) => current
|
|
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
|
|
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
|
|
setSelectedBuff((current) => current
|
|
? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current)
|
|
: null)
|
|
setSelectedDebuff((current) => current
|
|
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
|
|
: null)
|
|
})
|
|
return () => window.cancelAnimationFrame(frame)
|
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
|
|
|
const startLiveMatch = useCallback((
|
|
match: PvpMatchSnapshot<SideState>,
|
|
side: PvpMatchSide,
|
|
message?: string,
|
|
) => {
|
|
const setup = createPvpRoguelikeLiveMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
|
|
match,
|
|
side,
|
|
encounterPool: encounterPoolRef.current,
|
|
buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
|
|
partyTemplate,
|
|
opponentPartyTemplate: cpuPartyTemplate,
|
|
maxResource,
|
|
upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
|
|
message,
|
|
})
|
|
playerRef.current = setup.playerSide
|
|
cpuRef.current = setup.opponentSide
|
|
liveMatchRef.current = setup.liveMatch
|
|
nextLogId.current = 2
|
|
playerClearedEncounterRef.current = -1
|
|
queuedMatchRef.current = true
|
|
bossRewardClaimedRef.current = new Set()
|
|
elapsedTicksRef.current = setup.defaults.elapsedTicks
|
|
setEncounters(setup.firstSegment)
|
|
setEncounterIndex(setup.defaults.encounterIndex)
|
|
setCheckpointStage(setup.startStage)
|
|
setStartStage(setup.startStage)
|
|
setStage(setup.startStage)
|
|
setPlayerSide(setup.playerSide)
|
|
setCpuSide(setup.opponentSide)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setPlayerBuffChoices([])
|
|
setPlayerDebuffChoices([])
|
|
setSelectedBuff(null)
|
|
setSelectedDebuff(null)
|
|
resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
|
|
autoSubmittedUpgradeRef.current = false
|
|
setEncountersCleared(setup.defaults.encountersCleared)
|
|
setPaused(setup.defaults.paused)
|
|
setTargetGroup(setup.defaults.targetGroup)
|
|
setReward(null)
|
|
setRunSummary(createEmptyPvpRunSummary())
|
|
setRewardError(setup.defaults.rewardError)
|
|
setShowEndLog(setup.defaults.showEndLog)
|
|
clearFloatingTexts()
|
|
setCpuDifficulty(null)
|
|
setLiveMatch(setup.liveMatch)
|
|
setLiveUpgradePending(setup.defaults.liveUpgradePending)
|
|
pendingLiveUpgradeRef.current = null
|
|
loggedOpponentDoneRef.current = false
|
|
recordedRunRef.current = false
|
|
rewardClaimedRef.current = false
|
|
matchWinRewardClaimedRef.current = false
|
|
cpuDefeatedRef.current = false
|
|
setQueueMessage(setup.logText)
|
|
setLog([{ id: 1, text: setup.logText, tone: 'system' }])
|
|
beginRoundCountdown()
|
|
}, [beginRoundCountdown, clearFloatingTexts, contentType, cpuPartyTemplate, maxResource, partyTemplate, resetUpgradeTimer, setSelectedTargetId])
|
|
|
|
const startMatch = useCallback((nextStartStage?: number) => {
|
|
clearRoundCountdown()
|
|
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
|
const setup = createPvpRoguelikeMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
|
|
startStage: matchStartStage,
|
|
encounterPool: encounterPoolRef.current,
|
|
buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
|
|
partyTemplate,
|
|
opponentPartyTemplate: cpuPartyTemplate,
|
|
maxResource,
|
|
upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
|
|
})
|
|
playerRef.current = setup.playerSide
|
|
cpuRef.current = setup.opponentSide
|
|
nextLogId.current = 2
|
|
playerClearedEncounterRef.current = -1
|
|
queuedMatchRef.current = true
|
|
bossRewardClaimedRef.current = new Set()
|
|
elapsedTicksRef.current = setup.defaults.elapsedTicks
|
|
setEncounters(setup.firstSegment)
|
|
setEncounterIndex(setup.defaults.encounterIndex)
|
|
setCheckpointStage(setup.startStage)
|
|
setStartStage(setup.startStage)
|
|
setStage(setup.startStage)
|
|
setStatus('queueing')
|
|
setPlayerSide(setup.playerSide)
|
|
setCpuSide(setup.opponentSide)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setPlayerBuffChoices([])
|
|
setPlayerDebuffChoices([])
|
|
setSelectedBuff(null)
|
|
setSelectedDebuff(null)
|
|
resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
|
|
autoSubmittedUpgradeRef.current = false
|
|
setEncountersCleared(setup.defaults.encountersCleared)
|
|
setPaused(setup.defaults.paused)
|
|
setTargetGroup(setup.defaults.targetGroup)
|
|
setReward(null)
|
|
setRunSummary(createEmptyPvpRunSummary())
|
|
setRewardError(setup.defaults.rewardError)
|
|
setShowEndLog(setup.defaults.showEndLog)
|
|
clearFloatingTexts()
|
|
setCpuDifficulty(null)
|
|
setLiveMatch(null)
|
|
liveMatchRef.current = null
|
|
setLiveUpgradePending(setup.defaults.liveUpgradePending)
|
|
pendingLiveUpgradeRef.current = null
|
|
loggedOpponentDoneRef.current = false
|
|
recordedRunRef.current = false
|
|
rewardClaimedRef.current = false
|
|
matchWinRewardClaimedRef.current = false
|
|
cpuDefeatedRef.current = false
|
|
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
|
liveMatchRef.current = null
|
|
setLiveMatch(null)
|
|
setCpuDifficulty(randomCpu)
|
|
setQueueMessage(message)
|
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
|
beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
|
|
}
|
|
return startPvpQueueWithCpuFallback<SideState>({
|
|
contentType,
|
|
startStage: matchStartStage,
|
|
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 at stage ${matchStartStage}.`,
|
|
searching: `Searching queue for 5s. Stage ${matchStartStage} start ready.`,
|
|
notFound: (difficulty) => `No queued 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. Stage ${match.startStage} begins.`
|
|
},
|
|
},
|
|
})
|
|
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, resetUpgradeTimer, setSelectedTargetId, startLiveMatch])
|
|
|
|
useEffect(() => startMatch(), [startMatch])
|
|
|
|
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<SideState, LivePvpMatch>({
|
|
liveMatch,
|
|
syncEnabled: status !== 'queueing',
|
|
startLiveMatch,
|
|
getPayload: () => ({
|
|
state: playerRef.current,
|
|
status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'won' || status === 'lost' ? status : 'playing',
|
|
stage,
|
|
encounterIndex,
|
|
encountersCleared,
|
|
enemyHealth: playerRef.current.enemyHealth,
|
|
alive: playerRef.current.party.some((member) => member.health > 0),
|
|
elapsedTicks: elapsedTicksRef.current,
|
|
}),
|
|
onSnapshot: (snapshot, currentLiveMatch) => {
|
|
const opponentState = snapshot.states[currentLiveMatch.opponentSide]
|
|
if (opponentState) {
|
|
cpuRef.current = opponentState
|
|
setCpuSide(opponentState)
|
|
}
|
|
const opponentStatus = snapshot.statuses[currentLiveMatch.opponentSide]
|
|
const opponentAlive = snapshot.progress[currentLiveMatch.opponentSide]?.alive
|
|
const outcome = resolvePvpRoguelikeLiveSnapshot({
|
|
currentStatus: status,
|
|
opponentStatus,
|
|
opponentAlive,
|
|
alreadyLoggedOpponentDone: loggedOpponentDoneRef.current,
|
|
opponentName: currentLiveMatch.opponentName,
|
|
})
|
|
if (outcome.type === 'won') {
|
|
loggedOpponentDoneRef.current = true
|
|
cpuDefeatedRef.current = true
|
|
finishRoguelikeRun('won')
|
|
setStatus(outcome.status)
|
|
addLog(outcome.log.text, outcome.log.tone)
|
|
}
|
|
if (outcome.type === 'lost') {
|
|
finishRoguelikeRun('lost')
|
|
setStatus(outcome.status)
|
|
addLog(outcome.log.text, outcome.log.tone)
|
|
}
|
|
},
|
|
})
|
|
|
|
const applySpell = useCallback((
|
|
current: SideState,
|
|
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
|
sideName: 'player' | 'cpu',
|
|
buffs: SelfBuffId[],
|
|
debuffs: OpponentDebuffId[],
|
|
spell: Spell,
|
|
targetId: string,
|
|
) => {
|
|
const buffCounts = createStackCounts(buffs)
|
|
const debuffCounts = createStackCounts(debuffs)
|
|
const effectiveCost = spellResourceCost(spell, buffCounts, debuffCounts, current.freeCastReady)
|
|
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
|
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
|
|
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
|
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
|
|
const healingMultiplier = (member: PartyMember) =>
|
|
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
|
const extraTargets = spellExtraTargets(spell, {
|
|
stacks: buffCounts,
|
|
id: (slot) => `slot${slot as SlotKey}-extra-target` as SelfBuffId,
|
|
})
|
|
const {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
groupTargets,
|
|
} = buildSpellTargetPlan({
|
|
party: current.party,
|
|
spell,
|
|
targetId,
|
|
extraTargets,
|
|
directTarget: true,
|
|
hotTarget: spell.kind === 'hot',
|
|
shieldTarget: spell.kind === 'shield',
|
|
groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
|
|
extraTargetMode: {
|
|
hot: 'hot',
|
|
shield: 'shield',
|
|
},
|
|
})
|
|
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
|
|
directTargets.forEach((id) => hotTargets.add(id))
|
|
}
|
|
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
|
|
directTargets.forEach((id) => shieldTargets.add(id))
|
|
}
|
|
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
|
|
shieldTargets.forEach((id) => hotTargets.add(id))
|
|
}
|
|
const spellEffectProfile: SpellEffectProfile = {
|
|
modeName: 'pvp-roguelike',
|
|
heal: healMember,
|
|
healingMultiplier,
|
|
power: {
|
|
direct: (source) => source.power,
|
|
cleanse: (source) => source.power,
|
|
groupHeal: (source) => source.power,
|
|
groupAbsorb: (source) => source.power,
|
|
shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength),
|
|
},
|
|
hot: {
|
|
mode: 'ticks',
|
|
defaultTicks: 5,
|
|
groupTicks: 5,
|
|
radianceTicks: 3,
|
|
merge: 'replace',
|
|
groupMerge: 'max',
|
|
},
|
|
effects: {
|
|
renewSpell: renewEffect,
|
|
shieldSpell: shieldEffect,
|
|
groupAbsorbOnly: () => false,
|
|
groupHotOnly: () => false,
|
|
groupAppliesShield: () => hasSpellEffect('radiance_applies_shield'),
|
|
groupAppliesHot: () => hasSpellEffect('radiance_applies_renew') && Boolean(renewEffect),
|
|
shieldAppliesHot: () => false,
|
|
hotSpellForDirect: (source) => source,
|
|
},
|
|
ratios: {
|
|
groupShield: 0.3,
|
|
directShield: 0.5,
|
|
},
|
|
damageReductionTicks: 12,
|
|
floatingHeals: {
|
|
group: true,
|
|
direct: true,
|
|
cleanse: true,
|
|
},
|
|
bounceHeals: false,
|
|
}
|
|
const targetPlan = {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
damageReductionTargets: new Set<string>(),
|
|
groupTargets,
|
|
}
|
|
const nextCooldowns = { ...current.cooldowns }
|
|
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
|
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
|
}
|
|
return applyPvpSpellCast({
|
|
current,
|
|
spell,
|
|
targetId,
|
|
resourceCost: effectiveCost,
|
|
targetPlan,
|
|
profile: spellEffectProfile,
|
|
setCurrent,
|
|
emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value),
|
|
cooldowns: nextCooldowns,
|
|
cooldownMultiplier: cooldownMultiplier(spell, buffCounts, debuffCounts),
|
|
freeCast: {
|
|
enabled: false,
|
|
wasReady: false,
|
|
},
|
|
})
|
|
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
|
|
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
|
if (status !== 'playing' || playerDone || !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', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
|
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
|
}, [addLog, applySpell, playerAlive, playerDone, status])
|
|
|
|
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
|
const {
|
|
selectRelativeTarget,
|
|
selectDirectionalTarget,
|
|
selectDirectTarget,
|
|
} = usePartyTargeting({
|
|
getParty: getTargetParty,
|
|
selectedIdRef,
|
|
setSelectedTargetId,
|
|
columns: partyColumns,
|
|
livingOnly: true,
|
|
directTargetGroup: contentType === 'raid' ? targetGroup : 0,
|
|
})
|
|
|
|
const cpuTakeTurn = useCallback(() => {
|
|
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
|
const elapsedTicks = elapsedTicksRef.current
|
|
if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return
|
|
if (Math.random() < cpuBehavior.mistakeChance) return
|
|
const side = cpuRef.current
|
|
const actions = chooseCpuHealActions({ party: side.party, spells: starterSpells, behavior: cpuBehavior })
|
|
for (const action of actions) {
|
|
const cast = applySpell(cpuRef.current, (value) => {
|
|
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
|
cpuRef.current = next
|
|
setCpuSide(next)
|
|
}, 'cpu', cpuRef.current.buffs, cpuRef.current.debuffs, action.spell, action.targetId)
|
|
if (cast) return
|
|
}
|
|
}, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, starterSpells, status])
|
|
|
|
const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => {
|
|
if (side.enemyHealth <= 0) return side
|
|
const party = side.party
|
|
const living = party.filter((member) => member.health > 0)
|
|
if (living.length === 0) return side
|
|
const primaryTarget = living[Math.floor(Math.random() * living.length)]
|
|
const mechanics = encounterValue.bossMechanics ?? []
|
|
const elapsedTicks = elapsedTicksRef.current
|
|
const bossPulse = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0 && mechanics.includes('party-pulse')
|
|
const appliesDebuff = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0 && mechanics.includes('searing-mark')
|
|
const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut')
|
|
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
|
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
|
const playerEffectsActive = sideName === 'player'
|
|
const hasShieldedHealingBonus = playerEffectsActive && activeSpellEffects.has('shielded_healing_bonus')
|
|
const shieldedDamageMultiplier = playerEffectsActive && activeSpellEffects.has('shielded_damage_reduction') ? 0.8 : undefined
|
|
const tankPressure = tankPressureTargets(party)
|
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
|
const nextParty = party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
|
if (tankPressureIds.has(member.id)) {
|
|
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
|
|
}
|
|
if (bossPulse) damage += 10
|
|
if (member.debuff) damage += 6
|
|
const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
|
|
const result = advanceMemberTick({
|
|
member,
|
|
party,
|
|
damage,
|
|
hotHealing: 6,
|
|
hotTicks: 'ticks',
|
|
healingMultiplier,
|
|
shieldedDamageMultiplier,
|
|
applyDebuff: appliesDebuff && member.id === primaryTarget.id
|
|
? { label: 'Searing Mark', ticks: 8 }
|
|
: undefined,
|
|
applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
|
|
poisonDamage: (stacks) => 3 + stacks * 3,
|
|
applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 14 : undefined,
|
|
applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 14 : undefined,
|
|
})
|
|
if (result.floatingHeal > 0) addFloatingHeal(sideName, member.id, result.floatingHeal)
|
|
return result.member
|
|
})
|
|
return {
|
|
...side,
|
|
party: nextParty,
|
|
resource: regenerateResource(side.resource, 2.4, maxResource),
|
|
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
|
}
|
|
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
|
|
|
const beginUpgradePhase = useCallback(() => {
|
|
autoSubmittedUpgradeRef.current = false
|
|
startUpgradeTimer()
|
|
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
|
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
|
setSelectedBuff(null)
|
|
setSelectedDebuff(null)
|
|
setStatus('upgrade-choice')
|
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'playing' || paused || !encounter) return
|
|
const timer = window.setInterval(() => {
|
|
elapsedTicksRef.current += 1
|
|
if (!liveMatch) cpuTakeTurn()
|
|
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
|
const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
|
|
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
|
playerClearedEncounterRef.current = encounterIndex
|
|
setEncountersCleared((value) => value + 1)
|
|
awardEncounterReward(encounterIndex)
|
|
if (encounter.isBoss) {
|
|
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
|
profile.character.id,
|
|
contentType,
|
|
stage,
|
|
)
|
|
if (nextCheckpoint > checkpointStage) {
|
|
setCheckpointStage(nextCheckpoint)
|
|
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
const outcome = resolvePvpRoguelikeCombatOutcome({
|
|
playerAlive: nextPlayerAlive,
|
|
opponentAlive: nextCpuAlive,
|
|
playerCleared: nextPlayer.enemyHealth <= 0,
|
|
encounterIsBoss: encounter.isBoss,
|
|
encounterName: encounter.enemyName,
|
|
opponentDefeated: cpuDefeatedRef.current,
|
|
liveMatchActive: Boolean(liveMatch),
|
|
opponentLabel,
|
|
})
|
|
if (outcome.type === 'lost') {
|
|
finishRoguelikeRun('lost')
|
|
setStatus(outcome.status)
|
|
addLog(outcome.log.text, outcome.log.tone)
|
|
return
|
|
}
|
|
if (outcome.type === 'won') {
|
|
if (outcome.markOpponentDefeated) {
|
|
cpuDefeatedRef.current = true
|
|
}
|
|
finishRoguelikeRun('won')
|
|
setStatus(outcome.status)
|
|
addLog(outcome.log.text, outcome.log.tone)
|
|
return
|
|
}
|
|
if (outcome.type === 'upgrade-choice') {
|
|
addLog(outcome.log.text, outcome.log.tone)
|
|
beginUpgradePhase()
|
|
}
|
|
}, TICK_MS / speedMultiplier)
|
|
return () => window.clearInterval(timer)
|
|
}, [addLog, advanceSide, awardEncounterReward, beginUpgradePhase, checkpointStage, contentType, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, opponentLabel, paused, profile.character.id, speedMultiplier, stage, status])
|
|
|
|
useEffect(() => {
|
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
|
recordedRunRef.current = true
|
|
recordCpuPvpLeaderboard({
|
|
characterName: profile.character.name,
|
|
className: profile.character.className,
|
|
contentType,
|
|
encountersCleared: finalEncountersCleared,
|
|
cpuDifficulty,
|
|
result: status === 'won' ? 'victory' : 'defeat',
|
|
completedAt: new Date().toISOString(),
|
|
})
|
|
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'upgrade-choice') return
|
|
window.requestAnimationFrame(() => focusFirstControl())
|
|
}, [status])
|
|
|
|
useEffect(() => {
|
|
if (!paused) return
|
|
window.requestAnimationFrame(() => focusFirstControl())
|
|
}, [paused])
|
|
|
|
const confirmUpgradeChoices = useCallback((
|
|
forcedBuff?: Choice<SelfBuffId>,
|
|
forcedDebuff?: Choice<OpponentDebuffId>,
|
|
) => {
|
|
const chosenBuff = forcedBuff ?? selectedBuff
|
|
const chosenDebuff = forcedDebuff ?? selectedDebuff
|
|
if (!chosenBuff || !chosenDebuff) return
|
|
if (liveMatch) {
|
|
const submittedBuff = chosenBuff
|
|
const submittedDebuff = chosenDebuff
|
|
const clearedEncounterIndex = encounterIndex
|
|
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
|
|
const incomingDebuffId = opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)
|
|
? opponentChoice.debuffId as OpponentDebuffId
|
|
: undefined
|
|
let nextPlayer = applyPvpRoguelikeUpgradeChoice({
|
|
side: playerRef.current,
|
|
buffId: submittedBuff.id,
|
|
incomingDebuffId,
|
|
reviveBuffId: REVIVE_PARTY_CHOICE.id,
|
|
})
|
|
|
|
const clearedBoss = encounter.isBoss
|
|
const completion = resolvePvpRoguelikeUpgradeCompletion({
|
|
clearedBoss,
|
|
opponentDefeated: cpuDefeatedRef.current,
|
|
opponentLabel: liveMatch.opponentName,
|
|
})
|
|
if (completion.type === 'won') {
|
|
finishRoguelikeRun('won')
|
|
setStatus(completion.status)
|
|
setLiveUpgradePending(false)
|
|
addLog(completion.log.text, completion.log.tone)
|
|
return
|
|
}
|
|
const nextStage = clearedBoss ? stage + 1 : stage
|
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
|
const progression = nextPvpRoguelikeStage({
|
|
clearedBoss,
|
|
stage,
|
|
encounterIndex,
|
|
encounters,
|
|
nextSegment,
|
|
})
|
|
if (progression.type === 'complete') {
|
|
finishRoguelikeRun('won')
|
|
setStatus(progression.status)
|
|
setLiveUpgradePending(false)
|
|
addLog(progression.log.text, progression.log.tone)
|
|
return
|
|
}
|
|
nextPlayer = preparePvpRoguelikeNextEncounter({
|
|
side: nextPlayer,
|
|
maxResource,
|
|
enemyMaxHealth: progression.nextEncounter.maxHealth,
|
|
})
|
|
if (clearedBoss) {
|
|
setStage(progression.nextStage)
|
|
setEncounters((current) => [...current, ...nextSegment])
|
|
}
|
|
setEncounterIndex(progression.nextEncounterIndex)
|
|
setPlayerSide(nextPlayer)
|
|
playerRef.current = nextPlayer
|
|
elapsedTicksRef.current = 0
|
|
setLiveUpgradePending(false)
|
|
pendingLiveUpgradeRef.current = null
|
|
beginRoundCountdown()
|
|
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
|
|
addLog(
|
|
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
|
|
'system',
|
|
)
|
|
}
|
|
setLiveUpgradePending(true)
|
|
pendingLiveUpgradeRef.current = {
|
|
encounterIndex: clearedEncounterIndex,
|
|
buff: submittedBuff,
|
|
debuff: submittedDebuff,
|
|
}
|
|
addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
|
|
submitPvpUpgradeChoice(liveMatch.id, {
|
|
encounterIndex: clearedEncounterIndex,
|
|
buffId: submittedBuff.id,
|
|
debuffId: submittedDebuff.id,
|
|
}).catch((reason: unknown) => {
|
|
setLiveUpgradePending(false)
|
|
addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
|
|
})
|
|
let attempts = 0
|
|
const waitForOpponent = () => {
|
|
attempts += 1
|
|
loadPvpMatch<SideState>(liveMatch.id)
|
|
.then((snapshot) => {
|
|
const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
|
|
if (opponentChoice) {
|
|
applyLiveUpgrade(opponentChoice)
|
|
return
|
|
}
|
|
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
|
window.setTimeout(waitForOpponent, 500)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
|
window.setTimeout(waitForOpponent, 700)
|
|
}
|
|
})
|
|
}
|
|
window.setTimeout(waitForOpponent, 250)
|
|
return
|
|
}
|
|
if (!cpuDifficulty) return
|
|
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
|
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
|
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
|
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
|
|
|
|
let nextPlayer = applyPvpRoguelikeUpgradeChoice({
|
|
side: playerRef.current,
|
|
buffId: chosenBuff.id,
|
|
incomingDebuffId: cpuDebuff.id,
|
|
reviveBuffId: REVIVE_PARTY_CHOICE.id,
|
|
})
|
|
let nextCpu = applyPvpRoguelikeUpgradeChoice({
|
|
side: cpuRef.current,
|
|
buffId: cpuBuff.id,
|
|
incomingDebuffId: chosenDebuff.id,
|
|
reviveBuffId: REVIVE_PARTY_CHOICE.id,
|
|
})
|
|
|
|
const clearedBoss = encounter.isBoss
|
|
const completion = resolvePvpRoguelikeUpgradeCompletion({
|
|
clearedBoss,
|
|
opponentDefeated: cpuDefeatedRef.current,
|
|
opponentLabel: 'CPU',
|
|
})
|
|
if (completion.type === 'won') {
|
|
finishRoguelikeRun('won')
|
|
setStatus(completion.status)
|
|
addLog(completion.log.text, completion.log.tone)
|
|
return
|
|
}
|
|
const nextStage = clearedBoss ? stage + 1 : stage
|
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
|
const progression = nextPvpRoguelikeStage({
|
|
clearedBoss,
|
|
stage,
|
|
encounterIndex,
|
|
encounters,
|
|
nextSegment,
|
|
})
|
|
if (progression.type === 'complete') {
|
|
finishRoguelikeRun('won')
|
|
setStatus(progression.status)
|
|
addLog(progression.log.text, progression.log.tone)
|
|
return
|
|
}
|
|
|
|
nextPlayer = preparePvpRoguelikeNextEncounter({
|
|
side: nextPlayer,
|
|
maxResource,
|
|
enemyMaxHealth: progression.nextEncounter.maxHealth,
|
|
})
|
|
nextCpu = preparePvpRoguelikeNextEncounter({
|
|
side: nextCpu,
|
|
maxResource,
|
|
enemyMaxHealth: progression.nextEncounter.maxHealth,
|
|
})
|
|
|
|
if (clearedBoss) {
|
|
setStage(progression.nextStage)
|
|
setEncounters((current) => [...current, ...nextSegment])
|
|
}
|
|
setEncounterIndex(progression.nextEncounterIndex)
|
|
setPlayerSide(nextPlayer)
|
|
setCpuSide(nextCpu)
|
|
playerRef.current = nextPlayer
|
|
cpuRef.current = nextCpu
|
|
elapsedTicksRef.current = 0
|
|
beginRoundCountdown()
|
|
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
|
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
|
|
|
useGameAction((action) => {
|
|
if (action === 'toggleSpeed') {
|
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
|
return
|
|
}
|
|
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 === 'toggleTargetGroup') {
|
|
if (contentType !== 'raid') return
|
|
setTargetGroup((current) => {
|
|
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
|
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
|
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
|
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
if (action.startsWith('ability')) {
|
|
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
|
if (spell) castPlayerSpell(spell)
|
|
}
|
|
})
|
|
|
|
const dualScreenState = useMemo(() => buildPvpRoguelikeDualScreenState({
|
|
difficultyName: `Stage ${stage}`,
|
|
dungeonName: encounter.enemyName,
|
|
contentName: 'PvP Roguelike',
|
|
encounterName: encounter.enemyName,
|
|
encounterDescription: encounter.description,
|
|
encounterHealth: playerSide.enemyHealth,
|
|
encounterMaxHealth: encounter.maxHealth,
|
|
encounterIsBoss: encounter.isBoss,
|
|
encounterIndex,
|
|
encounterCount: encounters.length,
|
|
party: playerSide.party,
|
|
opponentName: opponentLabel,
|
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
|
opponentParty: cpuSide.party,
|
|
opponentEnemyHealth: cpuSide.enemyHealth,
|
|
opponentResource: cpuSide.resource,
|
|
opponentMaxResource: maxResource,
|
|
opponentResourceName: gameClass.resourceName,
|
|
opponentBuffSummary,
|
|
opponentDebuffSummary,
|
|
floatingTexts: dualScreenFloatingTexts,
|
|
partySize: playerSide.party.length,
|
|
selectedId,
|
|
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
|
|
resource: playerSide.resource,
|
|
maxResource,
|
|
resourceName: gameClass.resourceName,
|
|
playerIsAlive: playerAlive,
|
|
spells: playerSpellSlots,
|
|
bindings: activeBindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
paused,
|
|
targetGroup,
|
|
speedMultiplier,
|
|
}), [
|
|
activeBindings,
|
|
controllerIconStyle,
|
|
cpuDifficulty,
|
|
cpuSide.enemyHealth,
|
|
cpuSide.party,
|
|
cpuSide.resource,
|
|
directPartyTargeting,
|
|
encounter.description,
|
|
encounter.enemyName,
|
|
encounter.isBoss,
|
|
encounter.maxHealth,
|
|
encounterIndex,
|
|
encounters.length,
|
|
dualScreenFloatingTexts,
|
|
gameClass.resourceName,
|
|
liveMatch?.opponentClassName,
|
|
maxResource,
|
|
opponentBuffSummary,
|
|
opponentDebuffSummary,
|
|
opponentLabel,
|
|
paused,
|
|
playerAlive,
|
|
playerSide.enemyHealth,
|
|
playerSide.party,
|
|
playerSide.resource,
|
|
selectedId,
|
|
speedMultiplier,
|
|
stage,
|
|
playerSpellSlots,
|
|
status,
|
|
targetGroup,
|
|
])
|
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
|
|
|
return (
|
|
<main
|
|
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
|
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
|
>
|
|
<section className="content-screen pvp-match-screen">
|
|
{status === 'queueing' && (
|
|
<div className="placeholder-panel">
|
|
<div className="placeholder-runes">P V P</div>
|
|
<p>{queueMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{dualScreenEnabled && status !== 'queueing' && (
|
|
<DualScreenTopCombat
|
|
state={dualScreenState}
|
|
onCastSpell={castPlayerSpell}
|
|
onSelectTarget={setSelectedTargetId}
|
|
/>
|
|
)}
|
|
|
|
{!dualScreenEnabled && status !== 'queueing' && (
|
|
<div className="pvp-board">
|
|
<section className="combat-panel pvp-side">
|
|
<div className="encounter-header">
|
|
<div>
|
|
<p className="eyebrow">You</p>
|
|
<h2>{profile.character.name}</h2>
|
|
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
|
</div>
|
|
<div className="pvp-side-bars">
|
|
<div className="pvp-clear-wrap">
|
|
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
|
<div className="bar enemy-health boss-bar">
|
|
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
<div className="pvp-resource-wrap">
|
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
|
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
|
{playerSide.party.map((member) => (
|
|
<PartyMemberFrame
|
|
deadClassName="down"
|
|
effectMode="timed-basic"
|
|
floatingTexts={playerFloatingTextsByMember.get(member.id) ?? []}
|
|
key={`player-${member.id}`}
|
|
member={member}
|
|
onSelect={setSelectedTargetId}
|
|
selected={selectedId === member.id}
|
|
showHealthText
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">
|
|
Buffs: {playerBuffSummary} | Debuffs: {playerDebuffSummary}
|
|
</p>
|
|
</section>
|
|
|
|
<section className="combat-panel pvp-side">
|
|
<div className="encounter-header">
|
|
<div>
|
|
<p className="eyebrow">Opponent</p>
|
|
<h2>{opponentLabel}</h2>
|
|
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
|
|
</div>
|
|
<div className="pvp-side-bars">
|
|
<div className="pvp-clear-wrap">
|
|
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
|
<div className="bar enemy-health boss-bar">
|
|
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
<div className="pvp-resource-wrap">
|
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
|
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
|
{cpuSide.party.map((member) => (
|
|
<PartyMemberFrame
|
|
as="div"
|
|
deadClassName="down"
|
|
effectMode="timed-basic"
|
|
floatingTexts={cpuFloatingTextsByMember.get(member.id) ?? []}
|
|
key={`cpu-${member.id}`}
|
|
member={member}
|
|
showHealthText
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">
|
|
Buffs: {opponentBuffSummary} | Debuffs: {opponentDebuffSummary}
|
|
</p>
|
|
</section>
|
|
|
|
<SpellBar
|
|
bindings={activeBindings}
|
|
canCast={status === 'playing' && !playerDone && playerAlive}
|
|
className="pvp-bottom-spell-bar"
|
|
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 === 'upgrade-choice' && (
|
|
<div className="result-screen">
|
|
<div className="pvp-upgrade-dialog">
|
|
<div className="pvp-upgrade-header">
|
|
<div>
|
|
<p className="eyebrow">Choose Edge</p>
|
|
<h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
|
|
</div>
|
|
<strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
|
|
</div>
|
|
<div className="pvp-choice-columns">
|
|
<div>
|
|
<strong>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
|
|
<div className="upgrade-choice-grid">
|
|
{playerBuffChoices.map((choice) => (
|
|
<button
|
|
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
|
|
key={choice.id}
|
|
onClick={() => setSelectedBuff(choice)}
|
|
type="button"
|
|
>
|
|
<strong>{choice.name}</strong>
|
|
<small>{choice.description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<strong>Opponent Debuff</strong>
|
|
<div className="upgrade-choice-grid">
|
|
{playerDebuffChoices.map((choice) => (
|
|
<button
|
|
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
|
|
key={choice.id}
|
|
onClick={() => setSelectedDebuff(choice)}
|
|
type="button"
|
|
>
|
|
<strong>{choice.name}</strong>
|
|
<small>{choice.description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
|
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
|
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{paused && (
|
|
<div className="pause-screen">
|
|
<div>
|
|
<p className="eyebrow">Paused</p>
|
|
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</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' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}
|
|
>
|
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
|
<div className="reward-summary">
|
|
<p>{runSummary.bossesKilled} bosses killed.</p>
|
|
<RewardXpSummary reward={runSummary} />
|
|
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
<PvpRunLootList loot={runSummary.loot} />
|
|
{reward && runSummary.bossesKilled === 0 && (
|
|
<>
|
|
<RewardXpSummary reward={reward} />
|
|
<BonusItemReward item={reward.bonusItem} compact />
|
|
</>
|
|
)}
|
|
</div>
|
|
</ResultScreen>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|