863 lines
33 KiB
TypeScript
863 lines
33 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import './App.css'
|
|
import { CombatScreen } from './components/CombatScreen'
|
|
import { AuthScreen } from './components/AuthScreen'
|
|
import { CustomizeScreen } from './components/CustomizeScreen'
|
|
import { EquipmentScreen } from './components/EquipmentScreen'
|
|
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
|
|
import { TalentScreen } from './components/TalentScreen'
|
|
import { SettingsScreen } from './components/SettingsScreen'
|
|
import {
|
|
loadCpuPvpLeaderboard,
|
|
type CpuPvpLeaderboardEntry,
|
|
type PvpContentType,
|
|
} from './pvpRoguelike'
|
|
import {
|
|
loadAuthSession,
|
|
logoutAccount,
|
|
type Account,
|
|
type AuthSession,
|
|
type CharacterProfile,
|
|
} from './profile'
|
|
import {
|
|
getCloudSyncStatus,
|
|
getGameMode,
|
|
syncCloudSave,
|
|
type GameMode,
|
|
} from './gameRepository'
|
|
import { focusFirstControl } from './input.tsx'
|
|
|
|
type Screen =
|
|
| 'menu'
|
|
| 'dungeons'
|
|
| 'combat'
|
|
| 'raids'
|
|
| 'roguelike'
|
|
| 'pvp'
|
|
| 'customize'
|
|
| 'equipment'
|
|
| 'talents'
|
|
| 'settings'
|
|
|
|
const MENU_ITEMS: Array<{
|
|
screen: Screen
|
|
label: string
|
|
glyph: string
|
|
description: string
|
|
}> = [
|
|
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
|
|
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
|
|
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
|
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
|
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
|
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
|
|
]
|
|
|
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
|
const SHOW_LEADERBOARDS = false
|
|
|
|
function activityInitials(name: string) {
|
|
return name
|
|
.split(/\s+/)
|
|
.filter((word) => /^[A-Za-z0-9]/.test(word))
|
|
.slice(0, 2)
|
|
.map((word) => word[0].toUpperCase())
|
|
.join('')
|
|
}
|
|
|
|
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
|
type RoguelikeVariant = 'pve' | 'pvp'
|
|
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
|
|
|
function App() {
|
|
const [screen, setScreen] = useState<Screen>('menu')
|
|
const [account, setAccount] = useState<Account | null>(null)
|
|
const [profile, setProfile] = useState<CharacterProfile | null>(null)
|
|
const [authChecked, setAuthChecked] = useState(false)
|
|
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
|
const [serverMessage, setServerMessage] = useState('')
|
|
const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => {
|
|
const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY))
|
|
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
|
})
|
|
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
|
const [selectedRaidId, setSelectedRaidId] = useState(2)
|
|
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
|
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
|
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
|
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
|
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
|
const [selectedPart, setSelectedPart] = useState(1)
|
|
const [combatContentId, setCombatContentId] = useState(1)
|
|
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
|
const [showLoot, setShowLoot] = useState(false)
|
|
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
|
|
const [showLeaderboard, setShowLeaderboard] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
|
const [syncMessage, setSyncMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
loadAuthSession()
|
|
.then((session) => {
|
|
setAccount(session.account)
|
|
setProfile(session.profile)
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setServerMessage(
|
|
reason instanceof Error
|
|
? `${reason.message} Offline play is still available.`
|
|
: 'Unable to reach the server. Offline play is still available.',
|
|
)
|
|
})
|
|
.finally(() => setAuthChecked(true))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleModeChange = (event: Event) => {
|
|
const nextMode = (event as CustomEvent<GameMode>).detail
|
|
setGameMode(nextMode)
|
|
}
|
|
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
|
|
return () => {
|
|
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (screen === 'combat') return
|
|
window.requestAnimationFrame(() => {
|
|
focusFirstControl()
|
|
})
|
|
}, [screen])
|
|
|
|
useEffect(() => {
|
|
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
|
}, [selectedDifficultyId])
|
|
|
|
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
|
|
|
|
useEffect(() => {
|
|
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
|
}, [pvpContentType, screen, roguelikeVariant])
|
|
|
|
function acceptSession(session: AuthSession) {
|
|
setAccount(session.account)
|
|
setProfile(session.profile)
|
|
setGameMode(getGameMode())
|
|
setScreen('menu')
|
|
setError('')
|
|
setServerMessage('')
|
|
}
|
|
|
|
async function signOut() {
|
|
try {
|
|
await logoutAccount()
|
|
setAccount(null)
|
|
setProfile(null)
|
|
setGameMode(getGameMode())
|
|
setScreen('menu')
|
|
setSyncMessage('')
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
|
}
|
|
}
|
|
|
|
async function syncSaveNow() {
|
|
setSyncingCloud(true)
|
|
setSyncMessage('')
|
|
try {
|
|
const updated = await syncCloudSave()
|
|
setProfile(updated)
|
|
setGameMode(getGameMode())
|
|
setSyncMessage('Cloud save updated.')
|
|
} catch (reason) {
|
|
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
|
|
} finally {
|
|
setSyncingCloud(false)
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<main className="game-shell">
|
|
<section className="message-panel">
|
|
<p className="eyebrow">Database Error</p>
|
|
<h1>Character Unavailable</h1>
|
|
<p>{error}</p>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!authChecked) {
|
|
return (
|
|
<main className="game-shell">
|
|
<section className="message-panel">
|
|
<p className="eyebrow">Opening Chronicle</p>
|
|
<h1>Loading...</h1>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!account || !profile) {
|
|
return (
|
|
<AuthScreen
|
|
onAuthenticated={acceptSession}
|
|
serverMessage={serverMessage}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (screen === 'combat') {
|
|
const dungeon = combatContentId < 0
|
|
? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0]
|
|
: profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0]
|
|
const difficulty = dungeon.difficulties.find(
|
|
(candidate) => candidate.id === selectedDifficultyId,
|
|
) ?? dungeon.difficulties[0]
|
|
const roguelikePool = profile.dungeons
|
|
.filter((candidate) => candidate.contentType === roguelikeKind)
|
|
.flatMap((candidate) => candidate.encounters)
|
|
const startPart = selectedPart
|
|
return (
|
|
<CombatScreen
|
|
difficulty={difficulty}
|
|
dungeon={dungeon}
|
|
profile={profile}
|
|
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
|
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
|
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
|
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
|
startPart={startPart}
|
|
onExit={() => {
|
|
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
|
}}
|
|
onProfileUpdated={setProfile}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (screen === 'pvp') {
|
|
const pvpPool = profile.dungeons
|
|
.filter((candidate) => candidate.contentType === pvpContentType)
|
|
.flatMap((candidate) => candidate.encounters)
|
|
return (
|
|
<PvPRoguelikeScreen
|
|
contentType={pvpContentType}
|
|
encounterPool={pvpPool}
|
|
gameMode={gameMode}
|
|
onExit={() => {
|
|
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
|
setRoguelikeVariant('pvp')
|
|
setScreen('roguelike')
|
|
}}
|
|
onProfileUpdated={setProfile}
|
|
profile={profile}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const levelStart = profile.character.currentLevelExperience
|
|
const levelEnd = profile.character.nextLevelExperience
|
|
const experienceIntoLevel = profile.character.experience - levelStart
|
|
const experienceForLevel = Math.max(1, levelEnd - levelStart)
|
|
const experiencePercent = profile.character.level >= profile.maxLevel
|
|
? 100
|
|
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
|
|
const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon')
|
|
const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid')
|
|
const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId)
|
|
?? dungeonOptions[0]!
|
|
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
|
?? raidOptions[0]
|
|
const activity = screen === 'raids' && raid ? raid : dungeon
|
|
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
|
const selectedDifficulty = activity.difficulties.find(
|
|
(candidate) => candidate.id === selectedDifficultyId,
|
|
) ?? activity.difficulties[0]
|
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
|
const completedSections = activity.contentType === 'raid'
|
|
? profile.completedRaidPhases
|
|
: profile.completedDungeonParts
|
|
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
|
const parts = [
|
|
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
|
|
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
|
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
|
]
|
|
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
|
|
const cloudSync = getCloudSyncStatus()
|
|
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
|
const lootPreviewEncounters = [...activity.encounters]
|
|
.filter((encounter) => encounter.isBoss)
|
|
.sort((a, b) => lootSort === 'boss'
|
|
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
|
|
: a.sequence - b.sequence)
|
|
|
|
return (
|
|
<main className="game-shell">
|
|
<header className="topbar app-header">
|
|
<button
|
|
className="brand-button"
|
|
onClick={() => setScreen('menu')}
|
|
type="button"
|
|
>
|
|
<strong>Menu</strong>
|
|
</button>
|
|
<div className="character-summary">
|
|
<strong>{profile.character.name}</strong>
|
|
<small>Level {profile.character.level}</small>
|
|
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
|
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
|
<span style={{ width: `${experiencePercent}%` }} />
|
|
</div>
|
|
<button className="logout-button" onClick={signOut} type="button">
|
|
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{screen === 'menu' && (
|
|
<section className="menu-screen">
|
|
<div className="main-menu-grid">
|
|
{canShowCloudSync && (
|
|
<div className="menu-card cloud-sync-card">
|
|
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
|
<div>
|
|
<strong>Cloud Save</strong>
|
|
<small>
|
|
{cloudSync.dirty
|
|
? 'Local progress waiting. Upload when you want to refresh the server copy.'
|
|
: 'Server copy matches this device.'}
|
|
</small>
|
|
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
|
|
</div>
|
|
<button
|
|
className="text-button"
|
|
disabled={syncingCloud || !cloudSync.dirty}
|
|
onClick={syncSaveNow}
|
|
type="button"
|
|
>
|
|
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{MENU_ITEMS.map((item) => (
|
|
<button
|
|
className="menu-card"
|
|
key={item.screen}
|
|
onClick={() => {
|
|
if (item.screen === 'pvp') {
|
|
setRoguelikeVariant('pvp')
|
|
setScreen('roguelike')
|
|
return
|
|
}
|
|
setScreen(item.screen)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>{item.glyph}</span>
|
|
<div>
|
|
<strong>{item.label}</strong>
|
|
<small>{item.description}</small>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{screen === 'roguelike' && (
|
|
<section className="content-screen">
|
|
<ScreenHeading
|
|
eyebrow="Endless Draft"
|
|
title="Roguelike"
|
|
onBack={() => setScreen('menu')}
|
|
/>
|
|
<div className="roguelike-variant-row">
|
|
<button
|
|
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeVariant('pve')}
|
|
type="button"
|
|
>
|
|
PvE
|
|
</button>
|
|
<button
|
|
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeVariant('pvp')}
|
|
type="button"
|
|
>
|
|
PvP
|
|
</button>
|
|
</div>
|
|
{roguelikeVariant === 'pve' && (
|
|
<>
|
|
<div className="roguelike-option-panel">
|
|
<div>
|
|
<p className="eyebrow">Upgrade Timing</p>
|
|
<h2>Buff Drafts</h2>
|
|
</div>
|
|
<div className="roguelike-timing-row">
|
|
<button
|
|
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeUpgradeTiming('encounter')}
|
|
type="button"
|
|
>
|
|
Every Encounter
|
|
</button>
|
|
<button
|
|
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeUpgradeTiming('boss')}
|
|
type="button"
|
|
>
|
|
Boss Only
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="roguelike-option-panel">
|
|
<div>
|
|
<p className="eyebrow">Upgrade Labels</p>
|
|
<h2>Display Mode</h2>
|
|
</div>
|
|
<div className="roguelike-timing-row">
|
|
<button
|
|
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeAbilityLabelMode('ability')}
|
|
type="button"
|
|
>
|
|
Ability Names
|
|
</button>
|
|
<button
|
|
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
|
|
onClick={() => setRoguelikeAbilityLabelMode('slot')}
|
|
type="button"
|
|
>
|
|
Slot Names
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="roguelike-mode-grid">
|
|
<button
|
|
className="menu-card"
|
|
onClick={() => {
|
|
const baseDungeon = dungeonOptions[0]
|
|
setRoguelikeKind('dungeon')
|
|
setCombatContentId(-1)
|
|
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
|
setSelectedPart(1)
|
|
setScreen('combat')
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>D</span>
|
|
<strong>Dungeon Roguelike</strong>
|
|
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
|
</button>
|
|
<button
|
|
className="menu-card"
|
|
onClick={() => {
|
|
const baseRaid = raidOptions[0]
|
|
setRoguelikeKind('raid')
|
|
setCombatContentId(-2)
|
|
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
|
setSelectedPart(1)
|
|
setScreen('combat')
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>R</span>
|
|
<strong>Raid Roguelike</strong>
|
|
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{roguelikeVariant === 'pvp' && (
|
|
<>
|
|
<div className="roguelike-option-panel">
|
|
<div>
|
|
<p className="eyebrow">Match Type</p>
|
|
<h2>PvP Roguelike</h2>
|
|
</div>
|
|
<div className="roguelike-timing-row">
|
|
<button
|
|
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''}`}
|
|
onClick={() => setPvpContentType('dungeon')}
|
|
type="button"
|
|
>
|
|
Dungeon
|
|
</button>
|
|
<button
|
|
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''}`}
|
|
onClick={() => setPvpContentType('raid')}
|
|
type="button"
|
|
>
|
|
Raid
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="menu-card pvp-queue-panel">
|
|
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
|
|
<div>
|
|
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
|
<small>
|
|
{gameMode === 'offline'
|
|
? 'Offline mode always places you against a random CPU 1-5.'
|
|
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
|
</small>
|
|
</div>
|
|
<button
|
|
className="text-button"
|
|
onClick={() => setScreen('pvp')}
|
|
type="button"
|
|
>
|
|
Start Match
|
|
</button>
|
|
</div>
|
|
{SHOW_LEADERBOARDS && (
|
|
<div className="leaderboard-section">
|
|
<div className="equipment-heading toggle-heading">
|
|
<div>
|
|
<p className="eyebrow">CPU Leaderboard</p>
|
|
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
|
</div>
|
|
</div>
|
|
<div className="leaderboard-table">
|
|
<div className="leaderboard-header pvp-leaderboard-row">
|
|
<strong>Rank</strong>
|
|
<strong>Player</strong>
|
|
<strong>CPU</strong>
|
|
<strong>Clears</strong>
|
|
<strong>Result</strong>
|
|
</div>
|
|
{cpuLeaderboard.map((entry, index) => (
|
|
<div className="leaderboard-row pvp-leaderboard-row" key={`${entry.characterName}-${entry.completedAt}`}>
|
|
<strong>#{index + 1}</strong>
|
|
<span>{entry.characterName}</span>
|
|
<span>CPU {entry.cpuDifficulty}</span>
|
|
<span>{entry.encountersCleared}</span>
|
|
<span>{entry.result}</span>
|
|
</div>
|
|
))}
|
|
{cpuLeaderboard.length === 0 && (
|
|
<div className="leaderboard-empty">
|
|
No CPU runs recorded yet for this mode.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{(screen === 'dungeons' || screen === 'raids') && (
|
|
<section className="content-screen">
|
|
<ScreenHeading
|
|
eyebrow="Adventure"
|
|
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
|
onBack={() => setScreen('menu')}
|
|
/>
|
|
<article className="dungeon-card">
|
|
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
|
{activityInitials(activity.name)}
|
|
</div>
|
|
<div>
|
|
<p className="eyebrow">{activity.locationName}</p>
|
|
<h2>{activity.name}</h2>
|
|
<p>{activity.description}</p>
|
|
<div className="tag-row">
|
|
<span>Level {activity.recommendedLevel}</span>
|
|
<span>{activity.partySize} Players</span>
|
|
<span>{selectedDifficulty.name}</span>
|
|
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
|
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
|
</div>
|
|
</div>
|
|
{activityOptions.length > 1 && (
|
|
<label className="activity-select">
|
|
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
|
<select
|
|
value={activity.id}
|
|
onChange={(event) => {
|
|
const nextActivityId = Number(event.target.value)
|
|
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
|
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
|
else setSelectedDungeonId(nextActivityId)
|
|
if (nextActivity?.difficulties[0]) {
|
|
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
|
}
|
|
}}
|
|
>
|
|
{activityOptions.map((candidate) => (
|
|
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
<div className="part-buttons">
|
|
{parts.map((p) => (
|
|
<button
|
|
key={p.part}
|
|
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
|
disabled={difficultyLocked || !p.unlocked}
|
|
onClick={() => {
|
|
setSelectedPart(p.part)
|
|
setCombatContentId(activity.id)
|
|
setScreen('combat')
|
|
}}
|
|
type="button"
|
|
>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
<div className="difficulty-section compact-difficulty-section">
|
|
<div className="difficulty-select-row">
|
|
<div>
|
|
<p className="eyebrow">Challenge Tier</p>
|
|
<h2>Difficulty</h2>
|
|
</div>
|
|
<label>
|
|
<span>Select</span>
|
|
<select
|
|
value={selectedDifficulty.id}
|
|
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
|
|
>
|
|
{activity.difficulties.map((difficulty, index) => (
|
|
<option
|
|
disabled={profile.character.level < difficulty.unlockLevel}
|
|
key={difficulty.id}
|
|
value={difficulty.id}
|
|
>
|
|
{index + 1}. {difficulty.name}
|
|
{profile.character.level < difficulty.unlockLevel
|
|
? ` - Level ${difficulty.unlockLevel}`
|
|
: ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
|
<div>
|
|
<strong>{selectedDifficulty.name}</strong>
|
|
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
|
|
</div>
|
|
<dl>
|
|
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
|
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
|
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
|
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<div className="loot-preview-section">
|
|
<div className="equipment-heading toggle-heading">
|
|
<div>
|
|
<p className="eyebrow">Encounter Rewards</p>
|
|
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
|
</div>
|
|
<button
|
|
className="text-button"
|
|
onClick={() => setShowLoot((current) => !current)}
|
|
type="button"
|
|
>
|
|
{showLoot ? 'Hide Loot' : 'View Loot'}
|
|
</button>
|
|
</div>
|
|
{showLoot && (
|
|
<>
|
|
<div className="loot-toolbar">
|
|
<label>
|
|
<span>Sort</span>
|
|
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
|
|
<option value="sequence">Encounter order</option>
|
|
<option value="boss">Boss name</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p className="section-note">
|
|
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
|
|
{activity.completionItemLevel
|
|
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
|
: ''}
|
|
</p>
|
|
<div className="loot-preview-grid">
|
|
{lootPreviewEncounters.map((encounter) => {
|
|
const loot = encounter.lootTables.filter(
|
|
(entry) => entry.difficultyId === selectedDifficulty.id,
|
|
)
|
|
return (
|
|
<article key={encounter.id}>
|
|
<div className="loot-encounter-title">
|
|
{encounter.isBoss ? (
|
|
<img className="loot-boss-icon" src={encounter.imageUrl} alt={`${encounter.enemyName} icon`} />
|
|
) : (
|
|
<span>{encounter.sequence}</span>
|
|
)}
|
|
<div>
|
|
<strong>{encounter.enemyName}</strong>
|
|
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="loot-items">
|
|
{loot.map((item) => (
|
|
<div className={`rarity-${item.rarity}`} key={item.id}>
|
|
<span>{item.glyph}</span>
|
|
<div>
|
|
<strong>{item.name}</strong>
|
|
<small>{item.slot} - iLvl {item.itemLevel}</small>
|
|
</div>
|
|
<i>{item.dropWeight}% weight</i>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{SHOW_LEADERBOARDS && (
|
|
<div className="leaderboard-section">
|
|
<div className="equipment-heading toggle-heading">
|
|
<div>
|
|
<p className="eyebrow">Efficiency Rankings</p>
|
|
<h2>{selectedDifficulty.name} Leaderboard</h2>
|
|
</div>
|
|
<button
|
|
className="text-button"
|
|
onClick={() => setShowLeaderboard((current) => !current)}
|
|
type="button"
|
|
>
|
|
{showLeaderboard ? 'Hide Leaderboard' : 'View Leaderboard'}
|
|
</button>
|
|
</div>
|
|
{showLeaderboard && (
|
|
<>
|
|
<p className="section-note">
|
|
{gameMode === 'offline'
|
|
? 'Offline runs are not submitted'
|
|
: canShowCloudSync
|
|
? 'Manual save sync updates your cloud profile.'
|
|
: 'Lowest resource spent ranks first'}
|
|
</p>
|
|
<div className="leaderboard-tabs">
|
|
{([
|
|
{ key: 'part_1', label: `${sectionName} 1` },
|
|
{ key: 'part_2', label: `${sectionName} 2` },
|
|
{ key: 'part_3', label: `${sectionName} 3` },
|
|
{ key: 'full_run', label: 'Full Run' },
|
|
] as const).map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
className={`leaderboard-tab ${leaderboardCategory === tab.key ? 'active' : ''}`}
|
|
onClick={() => setLeaderboardCategory(tab.key)}
|
|
type="button"
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="leaderboard-table">
|
|
<div className="leaderboard-header">
|
|
<span>Rank</span>
|
|
<span>Healer</span>
|
|
<span>Class</span>
|
|
<span>Level</span>
|
|
<span>Item Level</span>
|
|
<span>Resource</span>
|
|
<span>Time</span>
|
|
</div>
|
|
{activity.leaderboards[leaderboardCategory]
|
|
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
|
|
.map((entry) => (
|
|
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
|
|
<strong>#{entry.rank}</strong>
|
|
<span>{entry.characterName}</span>
|
|
<span>{entry.className}</span>
|
|
<span>{entry.characterLevel}</span>
|
|
<span>{entry.averageItemLevel.toFixed(1)}</span>
|
|
<strong>{entry.resourceSpent}</strong>
|
|
<span>{entry.durationSeconds}s</span>
|
|
</div>
|
|
))}
|
|
{activity.leaderboards[leaderboardCategory].filter(
|
|
(entry) => entry.difficultyId === selectedDifficulty.id,
|
|
).length === 0 && (
|
|
<div className="leaderboard-empty">
|
|
{gameMode === 'offline'
|
|
? 'Connect with an online character to compete in rankings.'
|
|
: canShowCloudSync
|
|
? 'No leaderboard entries yet.'
|
|
: 'Complete this difficulty to claim the first ranking.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{screen === 'customize' && (
|
|
<CustomizeScreen
|
|
profile={profile}
|
|
onBack={() => setScreen('menu')}
|
|
onSaved={setProfile}
|
|
/>
|
|
)}
|
|
|
|
{screen === 'talents' && (
|
|
<TalentScreen
|
|
profile={profile}
|
|
onBack={() => setScreen('menu')}
|
|
onUpdated={setProfile}
|
|
/>
|
|
)}
|
|
|
|
{screen === 'equipment' && (
|
|
<EquipmentScreen
|
|
profile={profile}
|
|
onBack={() => setScreen('menu')}
|
|
onUpdated={setProfile}
|
|
/>
|
|
)}
|
|
|
|
{screen === 'settings' && (
|
|
<SettingsScreen onBack={() => setScreen('menu')} />
|
|
)}
|
|
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function ScreenHeading({
|
|
eyebrow,
|
|
title,
|
|
onBack,
|
|
}: {
|
|
eyebrow: string
|
|
title: string
|
|
onBack: () => void
|
|
}) {
|
|
return (
|
|
<div className="screen-heading">
|
|
<div>
|
|
<p className="eyebrow">{eyebrow}</p>
|
|
<h1>{title}</h1>
|
|
</div>
|
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|