Android build v1.1.23
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user