1403 lines
61 KiB
TypeScript
1403 lines
61 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
|
|
import { completeRoguelike, type DungeonReward } from '../profile'
|
|
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
|
import type { GameMode } from '../gameRepository'
|
|
import { ControllerBindingLabel } from './ControllerIcons'
|
|
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
|
import {
|
|
DualScreenTopCombat,
|
|
useDualScreen,
|
|
useDualScreenPublisher,
|
|
type DualScreenCombatState,
|
|
} from '../dualScreen'
|
|
import {
|
|
loadPvpRoguelikeCheckpoint,
|
|
randomCpuDifficulty,
|
|
recordCpuPvpLeaderboard,
|
|
recordPvpRoguelikeCheckpoint,
|
|
type CpuDifficulty,
|
|
type PvpContentType,
|
|
} from '../pvpRoguelike'
|
|
|
|
const TICK_MS = 700
|
|
|
|
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'
|
|
type AbilityLabelMode = 'ability' | 'slot'
|
|
|
|
type SelfBuffId =
|
|
| `slot${SlotKey}-extra-target`
|
|
| `slot${SlotKey}-cost-down`
|
|
| `slot${SlotKey}-cooldown-down`
|
|
| 'fifth-cast-free'
|
|
| 'group-heal-boost'
|
|
| 'shield-boost'
|
|
|
|
type OpponentDebuffId =
|
|
| `opp-slot${SlotKey}-cost-up`
|
|
| `opp-slot${SlotKey}-cooldown-up`
|
|
| 'opp-takes-more-damage'
|
|
| 'opp-healing-reduced'
|
|
| 'opp-resource-regen-down'
|
|
| 'opp-cleanse-cooldown-up'
|
|
| 'opp-purge-random-buff'
|
|
|
|
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 FloatingCombatText = {
|
|
id: number
|
|
memberId: string
|
|
side: 'player' | 'cpu'
|
|
value: number
|
|
}
|
|
|
|
const BOSS_MECHANICS: BossMechanic[] = [
|
|
'party-pulse',
|
|
'searing-mark',
|
|
'max-health-cut',
|
|
'healing-reduction',
|
|
'ramping-poison',
|
|
]
|
|
|
|
const CPU_BEHAVIOR: Record<CpuDifficulty, {
|
|
actionEveryTicks: number
|
|
mistakeChance: number
|
|
directHealThreshold: number
|
|
groupHealThreshold: number
|
|
hotThreshold: number
|
|
shieldThreshold: number
|
|
}> = {
|
|
1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.52, groupHealThreshold: 0.48, hotThreshold: 0.58, shieldThreshold: 0.45 },
|
|
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 clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value))
|
|
}
|
|
|
|
function chooseRandom<T>(items: T[], count: number) {
|
|
const pool = [...items]
|
|
const result: T[] = []
|
|
while (pool.length > 0 && result.length < count) {
|
|
result.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0])
|
|
}
|
|
return result
|
|
}
|
|
|
|
function formatEffectTime(ticks: number) {
|
|
const seconds = (ticks * TICK_MS) / 1000
|
|
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
|
}
|
|
|
|
function buffStacks<T extends string>(items: T[], id: T) {
|
|
return items.filter((item) => item === id).length
|
|
}
|
|
|
|
function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode) {
|
|
const spell = spells.find((candidate) => candidate.key === slot)
|
|
if (labelMode === 'ability' && spell) return spell.name
|
|
return `Slot ${slot}`
|
|
}
|
|
|
|
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
|
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
|
const label = slotLabel(slot, spells, labelMode)
|
|
return [
|
|
{
|
|
id: `slot${slot}-extra-target` as SelfBuffId,
|
|
name: `${label}: +1 target`,
|
|
description: `${label} affects 1 additional ally when possible.`,
|
|
},
|
|
{
|
|
id: `slot${slot}-cost-down` as SelfBuffId,
|
|
name: `${label}: -25% cost`,
|
|
description: `${label} costs 25% less resource.`,
|
|
},
|
|
{
|
|
id: `slot${slot}-cooldown-down` as SelfBuffId,
|
|
name: `${label}: -25% cooldown`,
|
|
description: `${label} recharges 25% faster.`,
|
|
},
|
|
]
|
|
})
|
|
return [
|
|
...slotChoices,
|
|
{ id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the next cast is free.' },
|
|
{ id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.' },
|
|
{ id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.' },
|
|
]
|
|
}
|
|
|
|
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
|
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
|
const label = slotLabel(slot, spells, labelMode)
|
|
return [
|
|
{
|
|
id: `opp-slot${slot}-cost-up` as OpponentDebuffId,
|
|
name: `${label}: +25% cost`,
|
|
description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
|
|
},
|
|
{
|
|
id: `opp-slot${slot}-cooldown-up` as OpponentDebuffId,
|
|
name: `${label}: +25% cooldown`,
|
|
description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
|
|
},
|
|
]
|
|
})
|
|
return [
|
|
...slotChoices,
|
|
{ id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' },
|
|
{ id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' },
|
|
{ id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' },
|
|
{ id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' },
|
|
{ id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' },
|
|
]
|
|
}
|
|
|
|
function toCombatSpell(ability: Ability, key: string): Spell {
|
|
const kinds: Record<string, Spell['kind']> = {
|
|
direct_heal: 'direct',
|
|
heal_over_time: 'hot',
|
|
party_heal: 'group',
|
|
absorb: 'shield',
|
|
cleanse: 'cleanse',
|
|
}
|
|
return {
|
|
id: String(ability.id),
|
|
key,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
cost: ability.cost,
|
|
cooldown: ability.cooldown,
|
|
power: ability.power,
|
|
glyph: ability.glyph,
|
|
kind: kinds[ability.spellType] ?? 'direct',
|
|
}
|
|
}
|
|
|
|
function effectiveMaxHealth(member: PartyMember) {
|
|
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
|
}
|
|
|
|
function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) {
|
|
return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage')
|
|
}
|
|
|
|
function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
|
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
|
}
|
|
|
|
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
|
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
|
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs))
|
|
}
|
|
|
|
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
|
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
|
}
|
|
|
|
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
|
const slot = spell.key as SlotKey
|
|
const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
|
|
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId)
|
|
const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0
|
|
return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty))
|
|
}
|
|
|
|
function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) {
|
|
const slot = spell.key as SlotKey
|
|
const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
|
|
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
|
|
const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks))
|
|
return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost
|
|
}
|
|
|
|
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
|
const encounterThreat = (encounter: DungeonEncounter) => (
|
|
encounter.maxHealth
|
|
+ encounter.damage * 18
|
|
+ encounter.tankDamage * 10
|
|
+ encounter.partyDamage * 18
|
|
)
|
|
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
|
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
|
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
|
const trashCandidateCount = Math.min(trashPool.length, 5 + stage * (kind === 'raid' ? 1 : 2))
|
|
const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2))
|
|
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2)
|
|
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? bossPool[0] ?? trashPool[0] ?? pool[0]
|
|
const healthScale = 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22)
|
|
const damageScale = 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14)
|
|
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
|
return [...selectedTrash, selectedBoss].map((encounter, index) => {
|
|
const isBoss = index === 2
|
|
return {
|
|
...encounter,
|
|
sourceEncounterId: encounter.id,
|
|
id: 910000 + stage * 10 + index,
|
|
sequence: (stage - 1) * 3 + index + 1,
|
|
isBoss,
|
|
encounterType: isBoss ? 'boss' : 'trash',
|
|
enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName,
|
|
description: isBoss
|
|
? `PvP boss with ${mechanics.join(', ')}.`
|
|
: encounter.description,
|
|
maxHealth: Math.round(encounter.maxHealth * healthScale),
|
|
damage: Math.round(encounter.damage * damageScale),
|
|
tankDamage: Math.round(encounter.tankDamage * damageScale),
|
|
partyDamage: Math.round(encounter.partyDamage * (0.85 + stage * 0.04)),
|
|
lootTables: [],
|
|
bossMechanics: isBoss ? mechanics : [],
|
|
}
|
|
})
|
|
}
|
|
|
|
function starterSide(partyTemplate: PartyMember[], maxResource: number): SideState {
|
|
return {
|
|
party: partyTemplate.map((member) => ({ ...member })),
|
|
resource: maxResource,
|
|
cooldowns: {},
|
|
enemyHealth: 0,
|
|
buffs: [],
|
|
debuffs: [],
|
|
castsTowardFree: 0,
|
|
freeCastReady: false,
|
|
}
|
|
}
|
|
|
|
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
|
if (buff.id === 'fifth-cast-free') return 8
|
|
if (buff.id === 'group-heal-boost') return 8
|
|
if (buff.id === 'shield-boost') return 6
|
|
const slot = buff.id.match(/slot([1-5])/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) {
|
|
if (debuff.id === 'opp-takes-more-damage') return 9
|
|
if (debuff.id === 'opp-healing-reduced') return 8
|
|
if (debuff.id === 'opp-resource-regen-down') return 7
|
|
if (debuff.id === 'opp-cleanse-cooldown-up') return 5
|
|
if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2
|
|
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 removeRandomBuff(side: SideState) {
|
|
if (side.buffs.length === 0) return side
|
|
const nextBuffs = [...side.buffs]
|
|
nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1)
|
|
return { ...side, buffs: nextBuffs }
|
|
}
|
|
|
|
function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
|
|
return { id: nextLogId.current++, text, tone }
|
|
}
|
|
|
|
function summarizeStacks<T extends string>(items: T[], catalog: Array<Choice<T>>) {
|
|
const counts = new Map<T, number>()
|
|
items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
|
|
return Array.from(counts.entries())
|
|
.map(([id, count]) => {
|
|
const label = catalog.find((choice) => choice.id === id)?.name ?? id
|
|
return count > 1 ? `${label} x${count}` : label
|
|
})
|
|
.join(', ')
|
|
}
|
|
|
|
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, 5)
|
|
.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<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('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>(() => starterSide(partyTemplate, maxResource))
|
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
|
const [queueMessage, setQueueMessage] = useState('')
|
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
|
const [rewardError, setRewardError] = useState('')
|
|
const [showEndLog, setShowEndLog] = useState(false)
|
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
|
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 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)
|
|
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 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 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))
|
|
}, [])
|
|
|
|
const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
|
|
if (value <= 0) return
|
|
const id = nextFloatingTextId.current++
|
|
setFloatingTexts((current) => [...current, { id, side, memberId, value }])
|
|
window.setTimeout(() => {
|
|
setFloatingTexts((current) => current.filter((entry) => entry.id !== id))
|
|
}, 900)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
|
setCheckpointStage(loadedCheckpoint)
|
|
setStartStage(loadedCheckpoint)
|
|
}, [contentType, profile.character.id])
|
|
|
|
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
|
const rewardEncounter = encounters[encounterIndexValue]
|
|
completeRoguelike(
|
|
rewardDungeon.id,
|
|
rewardDifficulty.id,
|
|
0,
|
|
0,
|
|
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
|
{
|
|
bossesCleared: 1,
|
|
experienceMode: 'pvp-boss-quarter-level',
|
|
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
|
roguelikeStage: stage,
|
|
},
|
|
)
|
|
.then((result) => {
|
|
setReward(result)
|
|
onProfileUpdated(result.profile)
|
|
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, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
|
|
|
|
const finishRoguelikeRun = useCallback(() => {
|
|
if (rewardClaimedRef.current) return
|
|
rewardClaimedRef.current = true
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setPlayerBuffChoices((current) => current
|
|
.map((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
|
|
? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
|
|
: null)
|
|
setSelectedDebuff((current) => current
|
|
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
|
|
: null)
|
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
|
|
|
useEffect(() => {
|
|
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
|
const firstEncounter = firstSegment[0]
|
|
const basePlayer = starterSide(partyTemplate, maxResource)
|
|
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
|
basePlayer.enemyHealth = firstEncounter.maxHealth
|
|
baseCpu.enemyHealth = firstEncounter.maxHealth
|
|
playerRef.current = basePlayer
|
|
cpuRef.current = baseCpu
|
|
nextLogId.current = 2
|
|
playerClearedEncounterRef.current = -1
|
|
bossRewardClaimedRef.current = new Set()
|
|
setEncounters(firstSegment)
|
|
setEncounterIndex(0)
|
|
setStage(startStage)
|
|
setElapsedTicks(0)
|
|
setStatus('queueing')
|
|
setPlayerSide(basePlayer)
|
|
setCpuSide(baseCpu)
|
|
setSelectedId(partyTemplate[0].id)
|
|
setPlayerBuffChoices([])
|
|
setPlayerDebuffChoices([])
|
|
setSelectedBuff(null)
|
|
setSelectedDebuff(null)
|
|
setEncountersCleared(0)
|
|
setPaused(false)
|
|
setTargetGroup(0)
|
|
setReward(null)
|
|
setRewardError('')
|
|
setShowEndLog(false)
|
|
setFloatingTexts([])
|
|
setCpuDifficulty(null)
|
|
recordedRunRef.current = false
|
|
rewardClaimedRef.current = false
|
|
cpuDefeatedRef.current = false
|
|
if (gameMode === 'offline') {
|
|
const randomCpu = randomCpuDifficulty()
|
|
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
|
setCpuDifficulty(randomCpu)
|
|
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
|
|
const timer = window.setTimeout(() => {
|
|
setStatus('playing')
|
|
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
|
}, 500)
|
|
return () => window.clearTimeout(timer)
|
|
}
|
|
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
|
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
|
const timer = window.setTimeout(() => {
|
|
const randomCpu = randomCpuDifficulty()
|
|
setCpuDifficulty(randomCpu)
|
|
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
|
setStatus('playing')
|
|
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
|
}, 1400)
|
|
return () => window.clearTimeout(timer)
|
|
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
|
|
|
const applySpell = useCallback((
|
|
current: SideState,
|
|
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
|
sideName: 'player' | 'cpu',
|
|
buffs: SelfBuffId[],
|
|
debuffs: OpponentDebuffId[],
|
|
spell: Spell,
|
|
targetId: string,
|
|
) => {
|
|
const effectiveCost = spellResourceCost(spell, buffs, debuffs, current.freeCastReady)
|
|
if (current.resource < effectiveCost || (current.cooldowns[spell.id] ?? 0) > 0) return false
|
|
const target = current.party.find((member) => member.id === targetId && member.health > 0)
|
|
if (!target) return false
|
|
const livingTargets = current.party.filter((member) => member.health > 0)
|
|
const extraTarget = (blockedIds: string[]) => livingTargets
|
|
.filter((member) => !blockedIds.includes(member.id))
|
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
|
const directTargets = new Set([targetId])
|
|
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
|
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
|
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
|
for (let index = 0; index < extraTargets; index += 1) {
|
|
if (spell.kind === 'group') break
|
|
if (spell.kind === 'hot') {
|
|
const extra = extraTarget([...hotTargets])
|
|
if (extra) hotTargets.add(extra.id)
|
|
continue
|
|
}
|
|
if (spell.kind === 'shield') {
|
|
const extra = extraTarget([...shieldTargets])
|
|
if (extra) shieldTargets.add(extra.id)
|
|
continue
|
|
}
|
|
const extra = extraTarget([...directTargets])
|
|
if (extra) directTargets.add(extra.id)
|
|
}
|
|
const nextParty = current.party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
if (spell.kind === 'group') {
|
|
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
|
const nextHealth = healMember(member, groupPower, debuffs)
|
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
|
return { ...member, health: nextHealth }
|
|
}
|
|
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
|
if (spell.kind === 'shield') {
|
|
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
|
return { ...member, shield: Math.max(member.shield, shieldPower) }
|
|
}
|
|
if (spell.kind === 'cleanse') {
|
|
const nextHealth = healMember(member, spell.power, debuffs)
|
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
|
return {
|
|
...member,
|
|
health: nextHealth,
|
|
debuff: undefined,
|
|
debuffTicks: undefined,
|
|
poisonStacks: undefined,
|
|
maxHealthPenaltyTicks: undefined,
|
|
healingReductionTicks: undefined,
|
|
}
|
|
}
|
|
const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health
|
|
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
|
|
return {
|
|
...member,
|
|
health: nextHealth,
|
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
|
}
|
|
})
|
|
const freeCastStacks = buffStacks(buffs, 'fifth-cast-free')
|
|
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady
|
|
const nextCastsTowardFree = freeCastStacks > 0
|
|
? current.freeCastReady
|
|
? 0
|
|
: current.castsTowardFree + 1 >= 5
|
|
? 0
|
|
: current.castsTowardFree + 1
|
|
: current.castsTowardFree
|
|
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
|
const nextState: SideState = {
|
|
...current,
|
|
party: nextParty,
|
|
resource: current.resource - effectiveCost,
|
|
cooldowns: {
|
|
...current.cooldowns,
|
|
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
|
},
|
|
castsTowardFree: nextCastsTowardFree,
|
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
|
}
|
|
setCurrent(nextState)
|
|
return true
|
|
}, [addFloatingHeal])
|
|
|
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
|
if (status !== 'playing' || playerDone || !playerAlive) return
|
|
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, selectedId)
|
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
|
|
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
|
|
|
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
|
const living = playerRef.current.party.filter((member) => member.health > 0)
|
|
if (living.length === 0) return
|
|
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
|
const nextIndex = currentIndex < 0
|
|
? 0
|
|
: (currentIndex + direction + living.length) % living.length
|
|
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 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
|
|
if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return
|
|
if (Math.random() < cpuBehavior.mistakeChance) return
|
|
const side = cpuRef.current
|
|
const spells = starterSpells
|
|
const living = side.party.filter((member) => member.health > 0)
|
|
const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
|
const tank = living.find((member) => member.role === 'Tank')
|
|
const wounded = living.filter((member) => member.health / effectiveMaxHealth(member) < cpuBehavior.directHealThreshold)
|
|
const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length)
|
|
const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0)
|
|
const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < cpuBehavior.hotThreshold)
|
|
const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < cpuBehavior.shieldThreshold)
|
|
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
|
|
{ spell: cleanseTarget ? spells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null },
|
|
{ spell: averageHealth < cpuBehavior.groupHealThreshold ? spells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null },
|
|
{ spell: shieldTarget ? spells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null },
|
|
{ spell: wounded.length > 0 ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest?.id ?? null },
|
|
{ spell: renewTarget ? spells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null },
|
|
{ spell: tank ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null },
|
|
]
|
|
for (const action of ordered) {
|
|
if (!action.spell || !action.targetId) continue
|
|
const succeeded = 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 (succeeded) return
|
|
}
|
|
}, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, elapsedTicks, starterSpells, status])
|
|
|
|
const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => {
|
|
if (side.enemyHealth <= 0) return side
|
|
const living = side.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 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 damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
|
const nextParty = side.party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
|
if (member.role === 'Tank') damage += encounterValue.tankDamage
|
|
if (bossPulse) damage += 10
|
|
if (member.debuff) damage += 6
|
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
|
: member.poisonStacks ?? 0
|
|
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
|
damage = Math.round(damage * damageMultiplier)
|
|
const absorbed = Math.min(member.shield, damage)
|
|
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0
|
|
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
|
? 14
|
|
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
|
|
const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id
|
|
? 14
|
|
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
|
|
const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id
|
|
? 8
|
|
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
|
return {
|
|
...member,
|
|
health: clamp(
|
|
member.health - damage + absorbed + healing,
|
|
0,
|
|
Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1))),
|
|
),
|
|
shield: Math.max(0, member.shield - damage),
|
|
hotTicks: Math.max(0, member.hotTicks - 1),
|
|
debuff: nextDebuffTicks > 0
|
|
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
|
: undefined,
|
|
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
|
|
poisonStacks: nextPoisonStacks,
|
|
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
|
|
healingReductionTicks: nextHealingReductionTicks,
|
|
}
|
|
})
|
|
return {
|
|
...side,
|
|
party: nextParty,
|
|
resource: clamp(
|
|
side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')),
|
|
0,
|
|
maxResource,
|
|
),
|
|
cooldowns: Object.fromEntries(
|
|
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
|
),
|
|
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
|
|
}
|
|
}, [addFloatingHeal, elapsedTicks, maxResource])
|
|
|
|
const beginUpgradePhase = useCallback(() => {
|
|
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
|
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
|
setSelectedBuff(null)
|
|
setSelectedDebuff(null)
|
|
setStatus('upgrade-choice')
|
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'playing' || paused || !encounter) return
|
|
const timer = window.setInterval(() => {
|
|
setElapsedTicks((value) => value + 1)
|
|
cpuTakeTurn()
|
|
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
|
const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter)
|
|
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
|
playerClearedEncounterRef.current = encounterIndex
|
|
setEncountersCleared((value) => value + 1)
|
|
if (encounter.isBoss) {
|
|
awardBossReward(encounterIndex)
|
|
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)
|
|
if (!nextPlayerAlive) {
|
|
finishRoguelikeRun()
|
|
setStatus('lost')
|
|
addLog('Your party fell first.', 'danger')
|
|
return
|
|
}
|
|
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
|
cpuDefeatedRef.current = true
|
|
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
|
}
|
|
if (nextPlayer.enemyHealth <= 0) {
|
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
|
beginUpgradePhase()
|
|
}
|
|
}, TICK_MS)
|
|
return () => window.clearInterval(timer)
|
|
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, 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(() => {
|
|
if (!selectedBuff || !selectedDebuff || !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 = {
|
|
...playerRef.current,
|
|
buffs: [...playerRef.current.buffs, selectedBuff.id],
|
|
}
|
|
let nextCpu = {
|
|
...cpuRef.current,
|
|
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
|
}
|
|
|
|
if (selectedDebuff.id === 'opp-purge-random-buff') {
|
|
nextCpu = removeRandomBuff(nextCpu)
|
|
} else {
|
|
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
|
|
}
|
|
|
|
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
|
nextPlayer = removeRandomBuff(nextPlayer)
|
|
} else {
|
|
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] }
|
|
}
|
|
|
|
const clearedBoss = encounter.isBoss
|
|
const nextStage = clearedBoss ? stage + 1 : stage
|
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
|
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
|
if (!nextEncounter) {
|
|
setStatus('won')
|
|
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,
|
|
}
|
|
nextCpu = {
|
|
...nextCpu,
|
|
party: nextCpu.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(nextCpu.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)
|
|
setCpuSide(nextCpu)
|
|
playerRef.current = nextPlayer
|
|
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, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
|
|
|
useGameAction((action) => {
|
|
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 === 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)
|
|
}
|
|
})
|
|
|
|
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>
|
|
<p>{queueMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{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">
|
|
<div>
|
|
<p className="eyebrow">You</p>
|
|
<h2>{profile.character.name}</h2>
|
|
</div>
|
|
<div className="resource-row pvp-resource-row">
|
|
<div className="pvp-resource-wrap">
|
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
|
<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) => (
|
|
<button
|
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
|
key={`player-${member.id}`}
|
|
onClick={() => setSelectedId(member.id)}
|
|
type="button"
|
|
>
|
|
<div className="member-header">
|
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
|
<strong>{member.name}</strong>
|
|
<small>{Math.floor(member.health)} / {effectiveMaxHealth(member)}</small>
|
|
</div>
|
|
<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
|
|
.filter((entry) => entry.side === 'player' && entry.memberId === member.id)
|
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
|
</div>
|
|
<div className="member-effects">
|
|
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
|
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
|
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
|
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">
|
|
Buffs: {playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
|
|
</p>
|
|
</section>
|
|
|
|
<section className="combat-panel pvp-middle-panel">
|
|
<div className="encounter-header">
|
|
<div>
|
|
<p className="eyebrow">Encounter {encounterIndex + 1}</p>
|
|
<h2>{encounter.enemyName}</h2>
|
|
<small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
|
</div>
|
|
</div>
|
|
<div className="pvp-enemy-race">
|
|
<div>
|
|
<strong>Your clear</strong>
|
|
<div className="bar enemy-health boss-bar">
|
|
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
</div>
|
|
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
|
</div>
|
|
<div>
|
|
<strong>CPU clear</strong>
|
|
<div className="bar enemy-health boss-bar">
|
|
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
</div>
|
|
<small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
|
|
</div>
|
|
</div>
|
|
<div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
|
|
{starterSpells.map((spell) => {
|
|
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
|
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
|
|
return (
|
|
<button
|
|
className="spell"
|
|
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
|
|
key={`middle-${spell.id}`}
|
|
onClick={() => castPlayerSpell(spell)}
|
|
type="button"
|
|
>
|
|
<kbd>
|
|
<ControllerBindingLabel
|
|
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
|
|
compact
|
|
iconStyle={controllerIconStyle}
|
|
/>
|
|
</kbd>
|
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
<strong>{spell.name}</strong>
|
|
<small>{cost} {gameClass.resourceName}</small>
|
|
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">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>
|
|
</div>
|
|
<div className="resource-row pvp-resource-row">
|
|
<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) => (
|
|
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
|
|
<div className="member-header">
|
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
|
<strong>{member.name}</strong>
|
|
<small>{Math.floor(member.health)} / {effectiveMaxHealth(member)}</small>
|
|
</div>
|
|
<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
|
|
.filter((entry) => entry.side === 'cpu' && entry.memberId === member.id)
|
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
|
</div>
|
|
<div className="member-effects">
|
|
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
|
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
|
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
|
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="roguelike-upgrade-list">
|
|
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
|
|
</p>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'upgrade-choice' && (
|
|
<div className="result-screen">
|
|
<div className="pvp-upgrade-dialog">
|
|
<div className="pvp-choice-columns">
|
|
<div>
|
|
<strong>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>
|
|
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button">
|
|
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') && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
|
|
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
|
<div className="reward-summary">
|
|
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
{reward && (
|
|
<>
|
|
<p>+{reward.experienceGained} XP</p>
|
|
{reward.levelsGained > 0 && (
|
|
<p className="level-gain">
|
|
Level {reward.previousLevel} to {reward.newLevel}
|
|
<small>+{reward.talentPointsGained} talent point</small>
|
|
</p>
|
|
)}
|
|
{reward.unlockedAbilities.map((ability) => (
|
|
<p className="ability-unlock" key={ability.id}>
|
|
<span>{ability.glyph}</span>
|
|
Ability Unlocked: {ability.name}
|
|
</p>
|
|
))}
|
|
{reward.bonusItem && (
|
|
<p className="ability-unlock">
|
|
<span>{reward.bonusItem.glyph}</span>
|
|
{reward.bonusItem.name} x{reward.bonusItem.quantity}
|
|
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{log.length > 0 && (
|
|
<>
|
|
<button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
|
|
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
|
|
</button>
|
|
{showEndLog && (
|
|
<div className="result-log">
|
|
{log.slice().reverse().map((entry) => (
|
|
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|