made some changes to the UI, removed leaderboards. updated gamesaves
This commit is contained in:
@@ -4,7 +4,13 @@ import { completeRoguelike, type DungeonReward } from '../profile'
|
||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { useGameAction, useInput, type InputAction } from '../input'
|
||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||
import {
|
||||
DualScreenTopCombat,
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
@@ -238,7 +244,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
encounter.maxHealth
|
||||
+ encounter.damage * 18
|
||||
+ encounter.tankDamage * 10
|
||||
+ encounter.partyDamage * 12
|
||||
+ encounter.partyDamage * 18
|
||||
)
|
||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
@@ -366,7 +372,7 @@ export function PvPRoguelikeScreen({
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const selfBuffChoicesCatalog = useMemo(
|
||||
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
|
||||
[abilityLabelMode, starterSpells],
|
||||
@@ -410,10 +416,14 @@ export function PvPRoguelikeScreen({
|
||||
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 nextFloatingTextId = useRef(1)
|
||||
const recordedRunRef = useRef(false)
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
@@ -431,11 +441,16 @@ export function PvPRoguelikeScreen({
|
||||
const cpuDone = cpuSide.enemyHealth <= 0
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||
const partyColumns = contentType === 'raid' ? 6 : 3
|
||||
const {
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
} = useDualScreen()
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||
}, [])
|
||||
@@ -449,18 +464,17 @@ export function PvPRoguelikeScreen({
|
||||
}, 900)
|
||||
}, [])
|
||||
|
||||
const finishRoguelikeRun = useCallback((cleared: number) => {
|
||||
if (rewardClaimedRef.current) return
|
||||
rewardClaimedRef.current = true
|
||||
const bossesCleared = Math.floor(cleared / 3)
|
||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||
completeRoguelike(
|
||||
rewardDungeon.id,
|
||||
rewardDifficulty.id,
|
||||
cleared,
|
||||
0,
|
||||
0,
|
||||
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
||||
{
|
||||
bossesCleared,
|
||||
bossesCleared: 1,
|
||||
experienceMode: 'pvp-boss-quarter-level',
|
||||
},
|
||||
)
|
||||
@@ -475,6 +489,11 @@ export function PvPRoguelikeScreen({
|
||||
})
|
||||
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
||||
|
||||
const finishRoguelikeRun = useCallback(() => {
|
||||
if (rewardClaimedRef.current) return
|
||||
rewardClaimedRef.current = true
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPlayerBuffChoices((current) => current
|
||||
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
|
||||
@@ -501,6 +520,7 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = baseCpu
|
||||
nextLogId.current = 2
|
||||
playerClearedEncounterRef.current = -1
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setStage(1)
|
||||
@@ -514,6 +534,8 @@ export function PvPRoguelikeScreen({
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
setReward(null)
|
||||
setRewardError('')
|
||||
setShowEndLog(false)
|
||||
@@ -521,6 +543,7 @@ export function PvPRoguelikeScreen({
|
||||
setCpuDifficulty(null)
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
|
||||
@@ -659,10 +682,45 @@ export function PvPRoguelikeScreen({
|
||||
setSelectedId(living[nextIndex].id)
|
||||
}, [selectedId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
if (currentIndex < 0) {
|
||||
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||
if (firstLiving) setSelectedId(firstLiving.id)
|
||||
return
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||
const currentColumn = currentIndex % partyColumns
|
||||
const candidates = playerRef.current.party
|
||||
.map((member, index) => ({
|
||||
member,
|
||||
index,
|
||||
row: Math.floor(index / partyColumns),
|
||||
column: index % partyColumns,
|
||||
}))
|
||||
.filter(({ member, index, row, column }) => {
|
||||
if (member.health <= 0 || index === currentIndex) return false
|
||||
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||
if (action === 'navigateUp') return row < currentRow
|
||||
return row > currentRow
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||
const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
|
||||
const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
|
||||
const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
|
||||
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||
})
|
||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
||||
}, [partyColumns, selectedId])
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const member = playerRef.current.party[slot]
|
||||
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||
const member = playerRef.current.party[index]
|
||||
if (member?.health > 0) setSelectedId(member.id)
|
||||
}, [])
|
||||
}, [contentType, targetGroup])
|
||||
|
||||
const cpuTakeTurn = useCallback(() => {
|
||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||
@@ -774,7 +832,7 @@ export function PvPRoguelikeScreen({
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || !encounter) return
|
||||
if (status !== 'playing' || paused || !encounter) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
cpuTakeTurn()
|
||||
@@ -783,6 +841,7 @@ export function PvPRoguelikeScreen({
|
||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||
playerClearedEncounterRef.current = encounterIndex
|
||||
setEncountersCleared((value) => value + 1)
|
||||
if (encounter.isBoss) awardBossReward(encounterIndex)
|
||||
}
|
||||
playerRef.current = nextPlayer
|
||||
cpuRef.current = nextCpu
|
||||
@@ -791,28 +850,23 @@ export function PvPRoguelikeScreen({
|
||||
|
||||
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
|
||||
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
|
||||
const clearedCount = nextPlayer.enemyHealth <= 0
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
if (!nextPlayerAlive) {
|
||||
finishRoguelikeRun(clearedCount)
|
||||
finishRoguelikeRun()
|
||||
setStatus('lost')
|
||||
addLog('Your party fell first.', 'danger')
|
||||
return
|
||||
}
|
||||
if (!nextCpuAlive) {
|
||||
finishRoguelikeRun(clearedCount)
|
||||
setStatus('won')
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
|
||||
return
|
||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) {
|
||||
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot')
|
||||
if (nextPlayer.enemyHealth <= 0) {
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
beginUpgradePhase()
|
||||
}
|
||||
}, TICK_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -828,6 +882,16 @@ export function PvPRoguelikeScreen({
|
||||
})
|
||||
}, [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(() => {
|
||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
||||
@@ -912,7 +976,15 @@ export function PvPRoguelikeScreen({
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (status !== 'playing') 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
|
||||
@@ -925,41 +997,93 @@ export function PvPRoguelikeScreen({
|
||||
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 === selectedId)
|
||||
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember?.health > 0) setSelectedId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (action.startsWith('ability')) {
|
||||
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
||||
if (spell) castPlayerSpell(spell)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}>
|
||||
<section className="content-screen pvp-match-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">PvP Roguelike</p>
|
||||
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1>
|
||||
</div>
|
||||
<div className="pvp-screen-tools">
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`}
|
||||
onClick={() => setAbilityLabelMode('ability')}
|
||||
type="button"
|
||||
>
|
||||
Ability Names
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`}
|
||||
onClick={() => setAbilityLabelMode('slot')}
|
||||
type="button"
|
||||
>
|
||||
Slot Names
|
||||
</button>
|
||||
</div>
|
||||
<button className="back-button" onClick={onExit} type="button">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||
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,
|
||||
partySize: playerSide.party.length,
|
||||
selectedId,
|
||||
log,
|
||||
status: status === 'queueing' ? 'playing' : status,
|
||||
resource: playerSide.resource,
|
||||
maxResource,
|
||||
resourceName: gameClass.resourceName,
|
||||
playerIsAlive: playerAlive,
|
||||
spells: starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})),
|
||||
activeDevice: lastDevice,
|
||||
bindings: bindings[lastDevice],
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
encounter.isBoss,
|
||||
encounter.maxHealth,
|
||||
encounterIndex,
|
||||
encounters.length,
|
||||
gameClass.resourceName,
|
||||
lastDevice,
|
||||
log,
|
||||
maxResource,
|
||||
paused,
|
||||
playerAlive,
|
||||
playerSide.buffs,
|
||||
playerSide.cooldowns,
|
||||
playerSide.debuffs,
|
||||
playerSide.enemyHealth,
|
||||
playerSide.freeCastReady,
|
||||
playerSide.party,
|
||||
playerSide.resource,
|
||||
selectedId,
|
||||
stage,
|
||||
starterSpells,
|
||||
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>
|
||||
@@ -967,7 +1091,14 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'queueing' && (
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onSelectTarget={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!dualScreenEnabled && status !== 'queueing' && (
|
||||
<div className="pvp-board">
|
||||
<section className="combat-panel pvp-side">
|
||||
<div className="encounter-header">
|
||||
@@ -982,7 +1113,7 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid">
|
||||
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||
{playerSide.party.map((member) => (
|
||||
<button
|
||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||
@@ -998,6 +1129,7 @@ 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
|
||||
@@ -1087,7 +1219,7 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid">
|
||||
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||
{cpuSide.party.map((member) => (
|
||||
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
|
||||
<div className="member-header">
|
||||
@@ -1098,6 +1230,7 @@ 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
|
||||
@@ -1125,9 +1258,6 @@ export function PvPRoguelikeScreen({
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div className="pvp-upgrade-dialog">
|
||||
<p className="eyebrow">Round Cleared</p>
|
||||
<h2>Choose Your Edge</h2>
|
||||
<p>Take 1 buff for yourself and 1 debuff for the CPU.</p>
|
||||
<div className="pvp-choice-columns">
|
||||
<div>
|
||||
<strong>Self Buff</strong>
|
||||
@@ -1169,6 +1299,17 @@ export function PvPRoguelikeScreen({
|
||||
</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') && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
@@ -1176,7 +1317,7 @@ export function PvPRoguelikeScreen({
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
|
||||
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user