made some changes to the UI, removed leaderboards. updated gamesaves

This commit is contained in:
Warren H
2026-06-18 13:00:29 -04:00
parent 3c90998a61
commit a604569a2f
44 changed files with 2301 additions and 435 deletions
+201 -60
View File
@@ -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 && (
<>