Android build v1.1.2

This commit is contained in:
Warren H
2026-06-29 22:35:49 -04:00
parent cbe42b6164
commit c6251167a5
52 changed files with 5554 additions and 3117 deletions
+222 -126
View File
@@ -1,13 +1,6 @@
import { useEffect, useState } from 'react'
import { lazy, Suspense, useEffect, useMemo, 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 { PvpStadiumScreen } from './components/PvpStadiumScreen'
import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen'
import {
loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry,
@@ -28,6 +21,14 @@ import {
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
const SettingsScreen = lazy(() => import('./components/SettingsScreen').then((module) => ({ default: module.SettingsScreen })))
type Screen =
| 'menu'
| 'dungeons'
@@ -67,6 +68,17 @@ function activityInitials(name: string) {
.join('')
}
function ScreenLoading() {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeVariant = 'pve' | 'pvp'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
@@ -148,9 +160,138 @@ function App() {
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
useEffect(() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
const frame = window.requestAnimationFrame(() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
})
return () => window.cancelAnimationFrame(frame)
}, [pvpContentType, screen, roguelikeVariant])
const profileDungeons = profile?.dungeons
const dungeonOptions = useMemo(
() => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [],
[profileDungeons],
)
const raidOptions = useMemo(
() => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [],
[profileDungeons],
)
const selectedDungeonOption = useMemo(
() => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0],
[dungeonOptions, selectedDungeonId],
)
const selectedRaidOption = useMemo(
() => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0],
[raidOptions, selectedRaidId],
)
const activityOptions = useMemo(
() => screen === 'raids' ? raidOptions : dungeonOptions,
[dungeonOptions, raidOptions, screen],
)
const combatDungeonOption = useMemo(
() => {
if (!profileDungeons) return undefined
return combatContentId < 0
? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0]
: profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0]
},
[combatContentId, profileDungeons, roguelikeKind],
)
const combatDifficultyOption = useMemo(
() => combatDungeonOption?.difficulties.find(
(candidate) => candidate.id === selectedDifficultyId,
) ?? combatDungeonOption?.difficulties[0],
[combatDungeonOption, selectedDifficultyId],
)
const roguelikePool = useMemo(
() => profileDungeons
?.filter((candidate) => candidate.contentType === roguelikeKind)
.flatMap((candidate) => candidate.encounters) ?? [],
[profileDungeons, roguelikeKind],
)
const pvpPool = useMemo(
() => profileDungeons
?.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters) ?? [],
[profileDungeons, pvpContentType],
)
const tierOptions = useMemo(
() => activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
))
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel),
[activityOptions],
)
const savedDifficulty = useMemo(
() => profileDungeons
?.flatMap((option) => option.difficulties)
.find((candidate) => candidate.id === selectedDifficultyId),
[profileDungeons, selectedDifficultyId],
)
const selectedTier = useMemo(
() => {
const characterLevel = profile?.character.level ?? 0
const savedTier = tierOptions.find((candidate) => (
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
&& characterLevel >= candidate.unlockLevel
))
if (savedTier) return savedTier
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index]
}
return tierOptions[0]
},
[profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions],
)
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = useMemo(
() => activityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
),
[activityOptions, currentActivityPage],
)
const selectedActivityOption = useMemo(
() => {
const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption
const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id
return activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? activityOptions[0]
?? fallback
},
[activityOptions, screen, selectedDungeonOption, selectedRaidOption],
)
const selectedDifficultyOption = useMemo(
() => selectedActivityOption?.difficulties.find(
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? selectedActivityOption?.difficulties[0],
[selectedActivityOption, selectedTierItemLevel],
)
const lootPreviewEncounters = useMemo(
() => [...(selectedActivityOption?.encounters ?? [])]
.filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss'
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
: a.sequence - b.sequence),
[lootSort, selectedActivityOption?.encounters],
)
const lootPreviewByEncounterId = useMemo(
() => new Map(lootPreviewEncounters.map((encounter) => [
encounter.id,
encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id),
])),
[lootPreviewEncounters, selectedDifficultyOption?.id],
)
const leaderboardEntries = useMemo(
() => selectedActivityOption?.leaderboards[leaderboardCategory].filter(
(entry) => entry.difficultyId === selectedDifficultyOption?.id,
) ?? [],
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
)
function acceptSession(session: AuthSession) {
setAccount(session.account)
setProfile(session.profile)
@@ -224,65 +365,61 @@ function App() {
}
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 dungeon = combatDungeonOption ?? profile.dungeons[0]
const difficulty = combatDifficultyOption ?? dungeon.difficulties[0]
return (
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={1}
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onProfileUpdated={setProfile}
/>
<Suspense fallback={<ScreenLoading />}>
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={1}
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onProfileUpdated={setProfile}
/>
</Suspense>
)
}
if (screen === 'pvp') {
if (pvpContentType === 'stadium') {
return (
<PvpStadiumScreen
<Suspense fallback={<ScreenLoading />}>
<PvpStadiumScreen
gameMode={gameMode}
onExit={() => {
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
</Suspense>
)
}
return (
<Suspense fallback={<ScreenLoading />}>
<PvPRoguelikeScreen
contentType={pvpContentType}
encounterPool={pvpPool}
gameMode={gameMode}
onExit={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
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}
/>
</Suspense>
)
}
@@ -293,13 +430,7 @@ function App() {
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 activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const dungeon = selectedDungeonOption ?? profile.dungeons[0]!
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
@@ -313,47 +444,15 @@ function App() {
setSelectedMarathonMode(false)
setScreen('combat')
}
const tierOptions = activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
))
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
const savedDifficulty = profile.dungeons
.flatMap((option) => option.difficulties)
.find((candidate) => candidate.id === selectedDifficultyId)
const selectedTier = tierOptions.find((candidate) => (
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
&& profile.character.level >= candidate.unlockLevel
))
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
?? tierOptions[0]
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = activityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
)
const activityPageStart = activityOptions.length === 0
? 0
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? activityOptions[0]
?? (screen === 'raids' && raid ? raid : dungeon)
const selectedDifficulty = activity.difficulties.find(
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? activity.difficulties[0]
const activity = selectedActivityOption ?? dungeon
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
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 ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
@@ -851,9 +950,7 @@ function App() {
</p>
<div className="loot-preview-grid">
{lootPreviewEncounters.map((encounter) => {
const loot = encounter.lootTables.filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
)
const loot = lootPreviewByEncounterId.get(encounter.id) ?? []
return (
<article key={encounter.id}>
<div className="loot-encounter-title">
@@ -937,8 +1034,7 @@ function App() {
<span>Resource</span>
<span>Time</span>
</div>
{activity.leaderboards[leaderboardCategory]
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
{leaderboardEntries
.map((entry) => (
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
<strong>#{entry.rank}</strong>
@@ -950,9 +1046,7 @@ function App() {
<span>{entry.durationSeconds}s</span>
</div>
))}
{activity.leaderboards[leaderboardCategory].filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
).length === 0 && (
{leaderboardEntries.length === 0 && (
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
@@ -971,33 +1065,35 @@ function App() {
</section>
)}
{screen === 'customize' && (
<CustomizeScreen
profile={profile}
onBack={() => setScreen('menu')}
onSaved={setProfile}
/>
)}
<Suspense fallback={<ScreenLoading />}>
{screen === 'customize' && (
<CustomizeScreen
profile={profile}
onBack={() => setScreen('menu')}
onSaved={setProfile}
/>
)}
{screen === 'talents' && (
<TalentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'talents' && (
<TalentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'equipment' && (
<EquipmentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'equipment' && (
<EquipmentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
</Suspense>
</main>
)