Android build v1.1.23
This commit is contained in:
@@ -3,7 +3,6 @@ import {
|
||||
completeDungeon,
|
||||
completeRoguelike,
|
||||
loadProfile,
|
||||
recordBossKill,
|
||||
type DungeonReward,
|
||||
rollEncounterLoot,
|
||||
type LootRoll,
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
createPveCombatState,
|
||||
createPveRoguelikeRunStart,
|
||||
} from '../combat/pveRoguelikeRunSetup'
|
||||
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||
import {
|
||||
appendCombatLog,
|
||||
type BasicFloatingCombatText,
|
||||
@@ -79,7 +79,7 @@ import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { ResourceBar, SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
||||
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
DualScreenTopCombat,
|
||||
@@ -236,6 +236,7 @@ function makeRoguelikeSegment(
|
||||
mode: RoguelikeMode,
|
||||
): RoguelikeEncounter[] {
|
||||
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
|
||||
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
||||
pool,
|
||||
stage,
|
||||
@@ -243,8 +244,8 @@ function makeRoguelikeSegment(
|
||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
||||
damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
|
||||
partyDamageScale: 0.9 + stage * 0.05,
|
||||
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
|
||||
partyDamageScale: 0.95,
|
||||
idBase: 900000,
|
||||
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
||||
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
||||
@@ -359,6 +360,7 @@ export function CombatScreen({
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
||||
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const {
|
||||
floatingTexts,
|
||||
@@ -540,9 +542,31 @@ export function CombatScreen({
|
||||
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
||||
if (recordedBossKillIdsRef.current.has(key)) return
|
||||
recordedBossKillIdsRef.current.add(key)
|
||||
recordBossKill(encounter.id, { petVariant: 'purple' })
|
||||
completeRoguelike(
|
||||
dungeon.id,
|
||||
difficulty.id,
|
||||
0,
|
||||
0,
|
||||
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
|
||||
{
|
||||
bossesCleared: 0,
|
||||
fightsCleared: 0,
|
||||
lootSourceEncounterId: encounter.id,
|
||||
roguelikeStage,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
setRoguelikeBossCoins((current) => [...current, {
|
||||
...result.bonusItem!,
|
||||
sourceLabel: encounter.enemyName,
|
||||
}])
|
||||
addLog(
|
||||
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
if (result.petAwarded) {
|
||||
addLog(
|
||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||
@@ -552,11 +576,11 @@ export function CombatScreen({
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
|
||||
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
|
||||
'danger',
|
||||
)
|
||||
})
|
||||
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
|
||||
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
|
||||
|
||||
const resetRun = useCallback(() => {
|
||||
const nextRoguelikeEncounters = roguelikeMode
|
||||
@@ -581,6 +605,7 @@ export function CombatScreen({
|
||||
setReward(setup.defaults.reward)
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setLootRolls(setup.defaults.lootRolls)
|
||||
setRoguelikeBossCoins([])
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
||||
@@ -1434,7 +1459,11 @@ export function CombatScreen({
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
<>
|
||||
{isRoguelike && <p>Run totals</p>}
|
||||
<RewardXpSummary reward={reward} />
|
||||
{isRoguelike && (
|
||||
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
)}
|
||||
{!isRoguelike && (
|
||||
<>
|
||||
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
||||
@@ -1459,6 +1488,7 @@ export function CombatScreen({
|
||||
<>
|
||||
<RewardXpSummary reward={reward} />
|
||||
<p>{encounterIndex} encounters cleared.</p>
|
||||
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
<p className="efficiency-result">
|
||||
{reward.resourceSpent} {gameClass.resourceName} spent
|
||||
<small>{reward.durationSeconds}s survived</small>
|
||||
|
||||
@@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs
|
||||
|
||||
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
||||
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
|
||||
return buildRoguelikeSegment<BossMechanic, {
|
||||
bossMechanics: BossMechanic[]
|
||||
sourceEncounterId?: number
|
||||
@@ -240,8 +241,8 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
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,
|
||||
damageScale: stageOneDamageScale,
|
||||
partyDamageScale: 0.89,
|
||||
idBase: 910000,
|
||||
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
||||
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
||||
@@ -537,7 +538,10 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||
bossKilled: isBossReward,
|
||||
sourceLabel: rewardEncounter?.enemyName,
|
||||
}))
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.experienceGained > 0) {
|
||||
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||
@@ -579,7 +583,10 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||
bossKilled: false,
|
||||
sourceLabel: 'Match Win',
|
||||
}))
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.experienceGained > 0) {
|
||||
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
||||
@@ -1808,11 +1815,12 @@ export function PvPRoguelikeScreen({
|
||||
>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
<p>Run totals</p>
|
||||
<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} />
|
||||
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
|
||||
{reward && runSummary.bossesKilled === 0 && (
|
||||
<>
|
||||
<RewardXpSummary reward={reward} />
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||
import type { CharacterProfile } from '../profile'
|
||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
@@ -64,9 +65,10 @@ import {
|
||||
import {
|
||||
createEmptyRewardSummary,
|
||||
mergeDungeonRewardSummary,
|
||||
type RunBossCoinAward,
|
||||
type RewardSummaryBase,
|
||||
} from '../combat/rewardSummaries'
|
||||
import { RewardXpSummary } from './RewardPanels'
|
||||
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
publishPvpMatchState,
|
||||
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
|
||||
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
||||
}
|
||||
|
||||
function starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) {
|
||||
return gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1)))
|
||||
}
|
||||
|
||||
function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) {
|
||||
const pool = classes.length > 0 ? classes : []
|
||||
return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId)
|
||||
}
|
||||
|
||||
function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) {
|
||||
const targetItemLevel = roguelikeCoinItemLevel(stage)
|
||||
const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel))
|
||||
const pool = eligible.length > 0 ? eligible : bosses
|
||||
return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined
|
||||
}
|
||||
|
||||
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
||||
const directName = slotSpellName('1', spells, 'Mend')
|
||||
const sustainName = slotSpellName('2', spells, 'Renew')
|
||||
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
|
||||
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 [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
|
||||
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
|
||||
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
||||
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
|
||||
const partyTemplate = useMemo(
|
||||
() => INITIAL_PARTY.map((member) => ({
|
||||
...member,
|
||||
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
|
||||
)
|
||||
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
||||
const rewardDifficulty = rewardDungeon.difficulties[0]
|
||||
const stadiumCoinBosses = useMemo(
|
||||
() => profile.dungeons
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
.filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0),
|
||||
[profile.dungeons],
|
||||
)
|
||||
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
||||
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
||||
partyTemplate,
|
||||
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
|
||||
clearFloatingTexts,
|
||||
} = useSidedFloatingCombatText()
|
||||
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
||||
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||
[buffCatalog, cpuSide.buffs],
|
||||
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
|
||||
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
|
||||
)
|
||||
const playerBuffSummary = useMemo(
|
||||
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
||||
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
|
||||
setStatus('round-countdown')
|
||||
}, [clearRoundCountdown, startRoundCountdown])
|
||||
|
||||
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
|
||||
const awardXp = useCallback((
|
||||
key: string,
|
||||
mode: StadiumExperienceMode,
|
||||
coinSource?: { encounter: DungeonEncounter; stage: number; label: string },
|
||||
) => {
|
||||
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,
|
||||
lootSourceEncounterId: coinSource?.encounter.id,
|
||||
roguelikeStage: coinSource?.stage,
|
||||
})
|
||||
.then((result) => {
|
||||
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
||||
if (result.bonusItem && coinSource) {
|
||||
setStadiumBossCoins((current) => [...current, {
|
||||
...result.bonusItem!,
|
||||
sourceLabel: coinSource.label,
|
||||
}])
|
||||
}
|
||||
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 Stadium XP.')
|
||||
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
|
||||
setLiveMatch(setup.liveMatch)
|
||||
setPaused(setup.defaults.paused)
|
||||
setRewardSummary(createEmptyRewardSummary())
|
||||
setStadiumBossCoins([])
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
|
||||
setLiveMatch(null)
|
||||
setPaused(setup.defaults.paused)
|
||||
setRewardSummary(createEmptyRewardSummary())
|
||||
setStadiumBossCoins([])
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
loggedOpponentRoundRef.current = ''
|
||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(message)
|
||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||
@@ -591,7 +635,7 @@ export function PvpStadiumScreen({
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
||||
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => startMatch())
|
||||
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
|
||||
current: StadiumSideState,
|
||||
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
||||
sideName: 'player' | 'cpu',
|
||||
spells: Spell[],
|
||||
spell: Spell,
|
||||
targetId: string,
|
||||
) => {
|
||||
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
|
||||
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 shieldEffect = spells.find((candidate) => candidate.kind === 'shield')
|
||||
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
||||
sourcePower
|
||||
* strength
|
||||
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
|
||||
wasReady: effectiveCost === 0 && current.freeCastReady,
|
||||
},
|
||||
})
|
||||
}, [addFloatingHeal, starterSpells])
|
||||
}, [addFloatingHeal])
|
||||
|
||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||
if (status !== 'playing' || !playerAlive) return
|
||||
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
|
||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||
playerRef.current = next
|
||||
setPlayerSide(next)
|
||||
}, 'player', spell, targetId)
|
||||
}, 'player', starterSpells, spell, targetId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, status])
|
||||
}, [addLog, applySpell, playerAlive, starterSpells, status])
|
||||
|
||||
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
||||
const {
|
||||
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
|
||||
runCpuHealTurn({
|
||||
side: cpuRef.current,
|
||||
elapsedTicks,
|
||||
spells: starterSpells,
|
||||
spells: cpuStarterSpells,
|
||||
behavior,
|
||||
preferSlots: true,
|
||||
applySpell: (side, spell, targetId) => {
|
||||
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
|
||||
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
||||
cpuRef.current = next
|
||||
setCpuSide(next)
|
||||
}, 'cpu', spell, targetId)
|
||||
}, 'cpu', cpuStarterSpells, spell, targetId)
|
||||
},
|
||||
})
|
||||
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
|
||||
}, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status])
|
||||
|
||||
const advanceBoss = useCallback((side: StadiumSideState) => {
|
||||
if (side.roundStatus !== 'playing') return side
|
||||
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
|
||||
})
|
||||
if (!liveMatchRef.current) {
|
||||
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
||||
buffCatalog,
|
||||
cpuBuffCatalog,
|
||||
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
||||
)
|
||||
setCpuSide((current) => {
|
||||
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [buffCatalog, startShopTimer])
|
||||
}, [cpuBuffCatalog, 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)
|
||||
const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex)
|
||||
awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource
|
||||
? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` }
|
||||
: undefined)
|
||||
addLog(result.log.text, result.log.tone)
|
||||
if (result.status === 'won') {
|
||||
setRoundWins(result.nextWins)
|
||||
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
|
||||
playerRef.current = next
|
||||
return next
|
||||
})
|
||||
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
|
||||
if (result.matchExperience) {
|
||||
const matchCoinStage = roundIndex + 1
|
||||
const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage)
|
||||
awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource
|
||||
? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` }
|
||||
: undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.status === 'lost') {
|
||||
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
|
||||
return
|
||||
}
|
||||
beginShop(outcome, result.nextWins)
|
||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
|
||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || paused) return
|
||||
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
|
||||
encounterCount: 5,
|
||||
party: playerSide.party,
|
||||
opponentName: opponentLabel,
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentEnemyHealth: 0,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentMaxResource: MAX_RESOURCE,
|
||||
opponentResourceName: gameClass.resourceName,
|
||||
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
floatingTexts: dualScreenFloatingTexts,
|
||||
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
|
||||
activeBindings,
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuGameClass.name,
|
||||
cpuGameClass.resourceName,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
cpuSide.survivalSeconds,
|
||||
directPartyTargeting,
|
||||
dualScreenFloatingTexts,
|
||||
gameClass.resourceName,
|
||||
liveMatch?.opponentClassName,
|
||||
liveMatch,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
opponentLabel,
|
||||
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
|
||||
<div>
|
||||
<p className="eyebrow">Opponent</p>
|
||||
<h2>{opponentLabel}</h2>
|
||||
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
|
||||
>
|
||||
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
||||
<div className="reward-summary">
|
||||
<p>Run totals</p>
|
||||
<RewardXpSummary reward={rewardSummary} />
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
</div>
|
||||
</ResultScreen>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CombatLogEntry } from '../game'
|
||||
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||
import type { DungeonReward, LootRoll } from '../profile'
|
||||
|
||||
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
||||
@@ -119,14 +120,16 @@ export function LootRollList({
|
||||
|
||||
export function PvpRunLootList({
|
||||
loot,
|
||||
emptyLabel = 'No boss coins awarded',
|
||||
}: {
|
||||
loot: BonusItem[]
|
||||
loot: RunBossCoinAward[]
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="run-loot-rolls">
|
||||
{loot.length > 0 ? loot.map((item, index) => (
|
||||
<div className="dropped" key={`${item.id}-${index}`}>
|
||||
<strong>Boss {index + 1}</strong>
|
||||
<strong>{item.sourceLabel}</strong>
|
||||
<span>
|
||||
{item.glyph} {item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
@@ -134,8 +137,8 @@ export function PvpRunLootList({
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Loot</strong>
|
||||
<span>No boss loot awarded</span>
|
||||
<strong>Boss Coins</strong>
|
||||
<span>{emptyLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user