Android build v1.0.53
This commit is contained in:
@@ -23,14 +23,24 @@ import {
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
loadPvpRoguelikeCheckpoint,
|
||||
cancelPvpQueue,
|
||||
checkPvpQueue,
|
||||
joinPvpQueue,
|
||||
loadPvpMatch,
|
||||
publishPvpMatchState,
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
recordPvpRoguelikeCheckpoint,
|
||||
submitPvpUpgradeChoice,
|
||||
type CpuDifficulty,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
type PvpContentType,
|
||||
type PvpUpgradeChoicePayload,
|
||||
} from '../pvpRoguelike'
|
||||
|
||||
const TICK_MS = 700
|
||||
const UPGRADE_CHOICE_SECONDS = 10
|
||||
|
||||
type BossMechanic =
|
||||
| 'party-pulse'
|
||||
@@ -99,6 +109,14 @@ type PvpRunSummary = {
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
type LivePvpMatch = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
const BOSS_MECHANICS: BossMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -449,6 +467,8 @@ export function PvPRoguelikeScreen({
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
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)
|
||||
@@ -460,6 +480,7 @@ export function PvPRoguelikeScreen({
|
||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
@@ -471,6 +492,15 @@ export function PvPRoguelikeScreen({
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const queuedMatchRef = useRef(false)
|
||||
const upgradeChoiceEndsAtRef = useRef(0)
|
||||
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)
|
||||
@@ -484,6 +514,7 @@ export function PvPRoguelikeScreen({
|
||||
? 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
|
||||
@@ -632,6 +663,9 @@ export function PvPRoguelikeScreen({
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
upgradeChoiceEndsAtRef.current = 0
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
@@ -641,34 +675,159 @@ export function PvPRoguelikeScreen({
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
setCpuDifficulty(null)
|
||||
setLiveMatch(null)
|
||||
liveMatchRef.current = null
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.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' }])
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
|
||||
let cancelled = false
|
||||
let ticketId = ''
|
||||
let pollTimer: number | undefined
|
||||
setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const beginLiveMatch = (match: PvpMatchSnapshot<SideState>, side: PvpMatchSide) => {
|
||||
if (cancelled) return
|
||||
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSide]
|
||||
const nextLiveMatch = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
liveMatchRef.current = nextLiveMatch
|
||||
setLiveMatch(nextLiveMatch)
|
||||
setCpuDifficulty(null)
|
||||
const opponentBase = starterSide(
|
||||
cpuPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
})),
|
||||
maxResource,
|
||||
)
|
||||
opponentBase.enemyHealth = firstEncounter.maxHealth
|
||||
cpuRef.current = opponentBase
|
||||
setCpuSide(opponentBase)
|
||||
setQueueMessage(`${opponent.characterName} found. Match begins.`)
|
||||
setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
if (cancelled || liveMatchRef.current) return
|
||||
cancelled = true
|
||||
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
|
||||
}, 5000)
|
||||
const pollQueue = () => {
|
||||
if (!ticketId || cancelled) return
|
||||
checkPvpQueue<SideState>(ticketId)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
beginLiveMatch(result.match, result.side)
|
||||
return
|
||||
}
|
||||
pollTimer = window.setTimeout(pollQueue, 500)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
|
||||
})
|
||||
}
|
||||
joinPvpQueue<SideState>(contentType, matchStartStage)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
ticketId = result.ticketId
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
beginLiveMatch(result.match, result.side)
|
||||
return
|
||||
}
|
||||
pollTimer = window.setTimeout(pollQueue, 500)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
window.clearTimeout(fallbackTimer)
|
||||
cancelled = true
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(fallbackTimer)
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
}
|
||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||
|
||||
useEffect(() => startMatch(), [startMatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveMatch || status === 'queueing') return
|
||||
let stopped = false
|
||||
const syncMatch = () => {
|
||||
publishPvpMatchState<SideState>(liveMatch.id, {
|
||||
state: playerRef.current,
|
||||
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
|
||||
stage,
|
||||
encounterIndex,
|
||||
encountersCleared,
|
||||
enemyHealth: playerRef.current.enemyHealth,
|
||||
alive: playerRef.current.party.some((member) => member.health > 0),
|
||||
elapsedTicks,
|
||||
})
|
||||
.then((snapshot) => {
|
||||
if (stopped) return
|
||||
const opponentState = snapshot.states[liveMatch.opponentSide]
|
||||
if (opponentState) {
|
||||
cpuRef.current = opponentState
|
||||
setCpuSide(opponentState)
|
||||
}
|
||||
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
|
||||
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
|
||||
loggedOpponentDoneRef.current = true
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
|
||||
finishRoguelikeRun()
|
||||
setStatus('lost')
|
||||
addLog(`${liveMatch.opponentName} finished first.`, 'danger')
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
syncMatch()
|
||||
const timer = window.setInterval(syncMatch, 700)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
||||
@@ -981,6 +1140,9 @@ export function PvPRoguelikeScreen({
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||
setSelectedBuff(null)
|
||||
@@ -992,9 +1154,9 @@ export function PvPRoguelikeScreen({
|
||||
if (status !== 'playing' || paused || !encounter) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
cpuTakeTurn()
|
||||
if (!liveMatch) cpuTakeTurn()
|
||||
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
||||
const nextCpu = advanceSide(cpuRef.current, 'cpu', 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)
|
||||
@@ -1024,7 +1186,7 @@ export function PvPRoguelikeScreen({
|
||||
addLog('Your party fell first.', 'danger')
|
||||
return
|
||||
}
|
||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
@@ -1032,7 +1194,7 @@ export function PvPRoguelikeScreen({
|
||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
@@ -1040,7 +1202,7 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
}, TICK_MS / speedMultiplier)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -1066,8 +1228,117 @@ export function PvPRoguelikeScreen({
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
}, [paused])
|
||||
|
||||
const confirmUpgradeChoices = useCallback(() => {
|
||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||
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) => {
|
||||
let nextPlayer = {
|
||||
...playerRef.current,
|
||||
buffs: [...playerRef.current.buffs, submittedBuff.id],
|
||||
}
|
||||
if (opponentChoice.debuffId === 'opp-purge-random-buff') {
|
||||
nextPlayer = removeRandomBuff(nextPlayer)
|
||||
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
|
||||
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
|
||||
}
|
||||
|
||||
const clearedBoss = encounter.isBoss
|
||||
if (clearedBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
setLiveUpgradePending(false)
|
||||
addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
setLiveUpgradePending(false)
|
||||
addLog('No further encounters remain.', 'loot')
|
||||
return
|
||||
}
|
||||
nextPlayer = {
|
||||
...nextPlayer,
|
||||
party: nextPlayer.party.map((member) => ({
|
||||
...member,
|
||||
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
})),
|
||||
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
cooldowns: {},
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
}
|
||||
if (clearedBoss) {
|
||||
setStage(nextStage)
|
||||
setEncounters((current) => [...current, ...nextSegment])
|
||||
}
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setPlayerSide(nextPlayer)
|
||||
playerRef.current = nextPlayer
|
||||
setElapsedTicks(0)
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
setStatus('playing')
|
||||
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))
|
||||
@@ -1075,17 +1346,17 @@ export function PvPRoguelikeScreen({
|
||||
|
||||
let nextPlayer = {
|
||||
...playerRef.current,
|
||||
buffs: [...playerRef.current.buffs, selectedBuff.id],
|
||||
buffs: [...playerRef.current.buffs, chosenBuff.id],
|
||||
}
|
||||
let nextCpu = {
|
||||
...cpuRef.current,
|
||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
||||
}
|
||||
|
||||
if (selectedDebuff.id === 'opp-purge-random-buff') {
|
||||
if (chosenDebuff.id === 'opp-purge-random-buff') {
|
||||
nextCpu = removeRandomBuff(nextCpu)
|
||||
} else {
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
||||
}
|
||||
|
||||
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
||||
@@ -1153,8 +1424,41 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = nextCpu
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'upgrade-choice' || liveUpgradePending) return
|
||||
if (upgradeChoiceEndsAtRef.current <= 0) {
|
||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||
}
|
||||
const updateTimer = () => {
|
||||
const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
|
||||
setUpgradeTimeLeft(remaining)
|
||||
if (remaining > 0 || autoSubmittedUpgradeRef.current) return
|
||||
autoSubmittedUpgradeRef.current = true
|
||||
const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
|
||||
const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
|
||||
if (autoBuff) setSelectedBuff(autoBuff)
|
||||
if (autoDebuff) setSelectedDebuff(autoDebuff)
|
||||
if (autoBuff && autoDebuff) {
|
||||
addLog('Upgrade timer expired. Random choices selected.', 'system')
|
||||
confirmUpgradeChoices(autoBuff, autoDebuff)
|
||||
}
|
||||
}
|
||||
updateTimer()
|
||||
const timer = window.setInterval(updateTimer, 100)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [
|
||||
addLog,
|
||||
confirmUpgradeChoices,
|
||||
liveUpgradePending,
|
||||
playerBuffChoices,
|
||||
playerDebuffChoices,
|
||||
selectedBuff,
|
||||
selectedDebuff,
|
||||
status,
|
||||
])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
@@ -1212,6 +1516,13 @@ export function PvPRoguelikeScreen({
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
party: playerSide.party,
|
||||
opponentName: opponentLabel,
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||
opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||
opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
|
||||
floatingTexts: floatingTexts
|
||||
.filter((entry) => entry.side === 'player')
|
||||
.map(({ id, memberId, value }) => ({ id, memberId, value })),
|
||||
@@ -1239,6 +1550,12 @@ export function PvPRoguelikeScreen({
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuSide.buffs,
|
||||
cpuSide.debuffs,
|
||||
cpuSide.enemyHealth,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
directPartyTargeting,
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
@@ -1249,8 +1566,12 @@ export function PvPRoguelikeScreen({
|
||||
floatingTexts,
|
||||
gameClass.resourceName,
|
||||
lastDevice,
|
||||
liveMatch?.opponentClassName,
|
||||
log,
|
||||
maxResource,
|
||||
opponentDebuffChoicesCatalog,
|
||||
opponentLabel,
|
||||
selfBuffChoicesCatalog,
|
||||
paused,
|
||||
playerAlive,
|
||||
playerSide.buffs,
|
||||
@@ -1285,6 +1606,7 @@ export function PvPRoguelikeScreen({
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onCastSpell={castPlayerSpell}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
@@ -1321,7 +1643,6 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1361,7 +1682,7 @@ export function PvPRoguelikeScreen({
|
||||
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>CPU clear</strong>
|
||||
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
@@ -1395,14 +1716,16 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="roguelike-upgrade-list">CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}</p>
|
||||
<p className="roguelike-upgrade-list">
|
||||
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="combat-panel pvp-side">
|
||||
<div className="encounter-header">
|
||||
<div>
|
||||
<p className="eyebrow">Opponent</p>
|
||||
<h2>CPU {cpuDifficulty}</h2>
|
||||
<h2>{opponentLabel}</h2>
|
||||
</div>
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
@@ -1422,7 +1745,6 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1450,6 +1772,13 @@ export function PvPRoguelikeScreen({
|
||||
{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>Self Buff</strong>
|
||||
@@ -1484,8 +1813,9 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button">
|
||||
Continue
|
||||
{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>
|
||||
@@ -1506,7 +1836,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<h2>{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
|
||||
Reference in New Issue
Block a user