Initial I Want to Heal app

This commit is contained in:
Warren H
2026-06-17 20:04:36 -04:00
parent 3880db1b58
commit 3c90998a61
109 changed files with 32775 additions and 0 deletions
+4955
View File
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
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 { getGameMode, 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 five-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-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'
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('')
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(() => {
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')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
}
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 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">
{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>
<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>
<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'
: '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.'
: '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
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './App.css'
import { AdminScreen } from './components/AdminScreen'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AdminScreen onBack={() => window.close()} />
</StrictMode>,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+766
View File
@@ -0,0 +1,766 @@
import { useEffect, useState } from 'react'
type AdminItem = {
id: number
slug: string
name: string
slot: string
rarity: string
itemLevel: number
healingPower: number
maxResourceBonus: number
glyph: string
imageUrl: string
description: string
}
type AdminEncounter = {
id: number
dungeonId: number
sequence: number
slug: string
enemyName: string
encounterType: string
imageUrl: string
}
type AdminDifficulty = {
id: number
slug: string
name: string
droppedItemLevel: number
}
type AdminLootEntry = {
encounterId: number
itemId: number
difficultyId: number
dropWeight: number
dropChance: number
}
type AdminRecipeComponent = {
itemId: number
quantity: number
}
type AdminRecipe = {
id: number
itemId: number
difficultyId: number | null
sourceDungeonId: number | null
sourceEncounterId: number | null
components: AdminRecipeComponent[]
}
type AdminDungeon = {
id: number
slug: string
name: string
}
type AdminData = {
items: AdminItem[]
encounters: AdminEncounter[]
difficulties: AdminDifficulty[]
encounterLoot: AdminLootEntry[]
craftingRecipes: AdminRecipe[]
dungeons: AdminDungeon[]
}
const API = '/api/admin'
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
const body = await res.json()
if (!res.ok) throw new Error(body.error ?? 'Request failed')
return body
}
export function AdminScreen({ onBack }: { onBack: () => void }) {
const [data, setData] = useState<AdminData | null>(null)
const [tab, setTab] = useState<'items' | 'bosses' | 'loot' | 'crafting'>('items')
const [error, setError] = useState('')
const [saving, setSaving] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchJson<AdminData>(`${API}/data`)
.then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load'))
}, [])
if (error) return <section className="content-screen"><p className="error-message">{error}</p></section>
if (!data) return <section className="content-screen"><p>Loading admin data...</p></section>
return (
<section className="content-screen admin-screen">
<div className="screen-heading">
<div><p className="eyebrow">Developer Tools</p><h1>Admin Panel</h1></div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<nav className="admin-tabs">
{(['items', 'bosses', 'loot', 'crafting'] as const).map((t) => (
<button key={t} className={`admin-tab ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)} type="button">
{t === 'items' ? 'Items' : t === 'bosses' ? 'Boss Images' : t === 'loot' ? 'Boss Loot' : 'Crafting'}
</button>
))}
</nav>
{tab === 'items' && <ItemsTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'bosses' && <BossImagesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'loot' && <LootTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
</section>
)
}
function ItemsTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
if (!data) return null
const [filter, setFilter] = useState('')
const [editId, setEditId] = useState<number | null>(null)
const [form, setForm] = useState<Partial<AdminItem>>({})
const groups = groupBy(data.items.filter((i) =>
i.name.toLowerCase().includes(filter.toLowerCase()) || i.slug.includes(filter)),
(i) => i.slot,
)
async function saveItem(id: number) {
setSaving((prev) => ({ ...prev, [`item-${id}`]: true }))
try {
const body: Record<string, string | number> = {}
if (form.name !== undefined) body.name = form.name
if (form.glyph !== undefined) body.glyph = form.glyph
if (form.description !== undefined) body.description = form.description
if (form.rarity !== undefined) body.rarity = form.rarity
if (form.slot !== undefined) body.slot = form.slot
if (form.itemLevel !== undefined) body.item_level = form.itemLevel
if (form.healingPower !== undefined) body.healing_power = form.healingPower
if (form.maxResourceBonus !== undefined) body.max_resource_bonus = form.maxResourceBonus
await fetchJson(`${API}/items/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((i) => i.id === id ? { ...i, ...form } : i),
} : prev)
setEditId(null)
setForm({})
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving((prev) => ({ ...prev, [`item-${id}`]: false }))
}
}
return (
<div className="admin-panel">
<input className="admin-search" placeholder="Search items..." value={filter}
onChange={(e) => setFilter(e.target.value)} />
{Object.entries(groups).map(([slot, items]) => (
<details key={slot} open>
<summary className="admin-group-header">{slot} ({items.length})</summary>
<div className="admin-grid">
{items.map((item) => (
<div key={item.id} className="admin-card">
{editId === item.id ? (
<div className="admin-edit-form">
<label>Glyph <input value={form.glyph ?? item.glyph} onChange={(e) => setForm({ ...form, glyph: e.target.value })} /></label>
<label>Name <input value={form.name ?? item.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<label>Slot
<select value={form.slot ?? item.slot} onChange={(e) => setForm({ ...form, slot: e.target.value })}>
{['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label>Rarity
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label>iLvl <input type="number" value={form.itemLevel ?? item.itemLevel} onChange={(e) => setForm({ ...form, itemLevel: Number(e.target.value) })} /></label>
<label>Healing <input type="number" value={form.healingPower ?? item.healingPower} onChange={(e) => setForm({ ...form, healingPower: Number(e.target.value) })} /></label>
<label>Resource <input type="number" value={form.maxResourceBonus ?? item.maxResourceBonus} onChange={(e) => setForm({ ...form, maxResourceBonus: Number(e.target.value) })} /></label>
<label>Description <textarea value={form.description ?? item.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<div className="admin-edit-actions">
<button className="primary-button" onClick={() => saveItem(item.id)} disabled={saving[`item-${item.id}`]} type="button">
{saving[`item-${item.id}`] ? 'Saving...' : 'Save'}
</button>
<button className="text-button" onClick={() => { setEditId(null); setForm({}) }} type="button">Cancel</button>
</div>
</div>
) : (
<>
<div className="admin-item-header">
<span className={`admin-glyph rarity-${item.rarity}`}>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small className="admin-item-meta">{item.slot} · iLvl {item.itemLevel} · {item.rarity}</small>
</div>
</div>
<p className="admin-item-desc">{item.description}</p>
<div className="admin-item-stats">
<span>+{item.healingPower} healing</span>
<span>+{item.maxResourceBonus} resource</span>
</div>
<button className="text-button" onClick={() => { setEditId(item.id); setForm({}) }} type="button">Edit</button>
</>
)}
</div>
))}
</div>
</details>
))}
</div>
)
}
function BossImagesTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
if (!data) return null
async function uploadBossImage(encounterId: number, file: File | undefined) {
if (!file) return
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: true }))
try {
const imageData = await fileToDataUrl(file)
const result = await fetchJson<{ imageUrl: string }>(`${API}/encounters/${encounterId}/image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData }),
})
setData((prev) => prev ? {
...prev,
encounters: prev.encounters.map((encounter) => (
encounter.id === encounterId
? { ...encounter, imageUrl: result.imageUrl }
: encounter
)),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Upload failed')
} finally {
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: false }))
}
}
const bosses = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
return (
<div className="admin-panel">
<div className="admin-grid boss-image-grid">
{bosses.map((boss) => (
<div key={boss.id} className="admin-card boss-image-card">
<img src={boss.imageUrl} alt={`${boss.enemyName} icon`} />
<div>
<strong>{boss.enemyName}</strong>
<small className="admin-item-meta">Dungeon {boss.dungeonId} · Encounter {boss.sequence}</small>
</div>
<label className="boss-upload-button">
{saving[`boss-image-${boss.id}`] ? 'Uploading...' : 'Upload Image'}
<input
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={saving[`boss-image-${boss.id}`]}
onChange={(event) => uploadBossImage(boss.id, event.target.files?.[0])}
type="file"
/>
</label>
</div>
))}
</div>
</div>
)
}
function LootTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
const [encounterId, setEncounterId] = useState(data?.encounters.filter(e => e.encounterType === 'boss')[0]?.id ?? 0)
const [difficultyId, setDifficultyId] = useState(data?.difficulties[0]?.id ?? 0)
const [addItemId, setAddItemId] = useState(0)
const [addDropWeight, setAddDropWeight] = useState(100)
const [addDropChance, setAddDropChance] = useState(1)
const [renameItemId, setRenameItemId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const [bossSort, setBossSort] = useState<'dungeon' | 'boss'>('dungeon')
if (!data) return null
const enc = data.encounters.find(e => e.id === encounterId)
const bossOptions = data.encounters
.filter((e) => e.encounterType === 'boss')
.sort((a, b) => bossSort === 'boss'
? a.enemyName.localeCompare(b.enemyName) || a.dungeonId - b.dungeonId || a.sequence - b.sequence
: a.dungeonId - b.dungeonId || a.sequence - b.sequence || a.enemyName.localeCompare(b.enemyName))
const bossLoot = data.encounterLoot.filter(
(l) => l.encounterId === encounterId && l.difficultyId === difficultyId,
)
const items = data.items
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
async function renameItem(itemId: number) {
if (renameValue.trim() === '' || renameValue === itemName(itemId)) return
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: true }))
try {
await fetchJson(`${API}/items/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameValue.trim() }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, name: renameValue.trim() } : item,
),
} : prev)
setRenameItemId(null)
setRenameValue('')
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Rename failed')
} finally {
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: false }))
}
}
async function deleteLoot(eId: number, dId: number, iId: number) {
try {
await fetchJson(`${API}/encounter-loot/${eId}/${dId}/${iId}`, { method: 'DELETE' })
setData((prev) => prev ? {
...prev,
encounterLoot: prev.encounterLoot.filter(
(l) => !(l.encounterId === eId && l.difficultyId === dId && l.itemId === iId),
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed')
}
}
async function addLoot() {
if (!addItemId) return
try {
await fetchJson(`${API}/encounter-loot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance }),
})
setData((prev) => prev ? {
...prev,
encounterLoot: [
...prev.encounterLoot.filter(
(l) => !(l.encounterId === encounterId && l.difficultyId === difficultyId && l.itemId === addItemId),
),
{ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance },
],
} : prev)
setAddItemId(0)
setAddDropWeight(100)
setAddDropChance(1)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Add failed')
}
}
return (
<div className="admin-panel">
<div className="admin-loot-selectors">
<label>Boss
<select value={encounterId} onChange={(e) => setEncounterId(Number(e.target.value))}>
{bossOptions.map((e) => (
<option key={e.id} value={e.id}>{e.enemyName} (dungeon {e.dungeonId})</option>
))}
</select>
</label>
<label>Sort Bosses
<select value={bossSort} onChange={(e) => setBossSort(e.target.value as 'dungeon' | 'boss')}>
<option value="dungeon">Dungeon order</option>
<option value="boss">Boss name</option>
</select>
</label>
<label>Difficulty
<select value={difficultyId} onChange={(e) => setDifficultyId(Number(e.target.value))}>
{data.difficulties.map((d) => (
<option key={d.id} value={d.id}>{d.name} (iLvl {d.droppedItemLevel})</option>
))}
</select>
</label>
</div>
<h3 className="admin-loot-title">
{enc?.enemyName ?? 'Unknown'} {data.difficulties.find(d => d.id === difficultyId)?.name ?? '?'}
</h3>
{bossLoot.length === 0 && <p className="admin-empty">No loot entries for this boss + difficulty.</p>}
<div className="admin-loot-list">
{bossLoot.map((entry) => (
<div key={`${entry.itemId}`} className="admin-loot-row">
<span className={`admin-glyph rarity-${data.items.find(i => i.id === entry.itemId)?.rarity ?? 'common'}`}>
{itemGlyph(entry.itemId)}
</span>
{renameItemId === entry.itemId ? (
<span className="admin-loot-name">
<input
className="admin-rename-input"
ref={(node) => {
if (node) window.requestAnimationFrame(() => node.focus({ preventScroll: true }))
}}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') renameItem(entry.itemId); if (e.key === 'Escape') { setRenameItemId(null); setRenameValue('') } }}
/>
</span>
) : (
<span className="admin-loot-name">{itemName(entry.itemId)}</span>
)}
<span className="admin-loot-weight">Weight: {entry.dropWeight}</span>
<span className="admin-loot-chance">Chance: {(entry.dropChance * 100).toFixed(0)}%</span>
{renameItemId === entry.itemId ? (
<>
<button className="primary-button" disabled={saving[`loot-rename-${entry.itemId}`]} onClick={() => renameItem(entry.itemId)} type="button">
{saving[`loot-rename-${entry.itemId}`] ? 'Saving...' : 'Save'}
</button>
<button className="text-button" onClick={() => { setRenameItemId(null); setRenameValue('') }} type="button">Cancel</button>
</>
) : (
<>
<button className="text-button" onClick={() => { setRenameItemId(entry.itemId); setRenameValue(itemName(entry.itemId)) }} type="button">Rename</button>
<button className="danger-button" onClick={() => deleteLoot(entry.encounterId, entry.difficultyId, entry.itemId)} type="button">X</button>
</>
)}
</div>
))}
</div>
<details className="admin-add-section">
<summary>Add Item to Loot Table</summary>
<div className="admin-add-form">
<label>Item
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
<option value={0}>Select item...</option>
{data.items.filter((i) => !bossLoot.some((l) => l.itemId === i.id)).map((i) => (
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({i.slot})</option>
))}
</select>
</label>
<label>Drop Weight <input type="number" value={addDropWeight} onChange={(e) => setAddDropWeight(Number(e.target.value))} /></label>
<label>Drop Chance <input type="number" min="0" max="1" step="0.05" value={addDropChance} onChange={(e) => setAddDropChance(Number(e.target.value))} /></label>
<button className="primary-button" onClick={addLoot} disabled={!addItemId} type="button">Add</button>
</div>
</details>
</div>
)
}
function CraftingTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
const [recipeId, setRecipeId] = useState(data?.craftingRecipes[0]?.id ?? 0)
const [itemLevelFilter, setItemLevelFilter] = useState('all')
const [bossFilterId, setBossFilterId] = useState(0)
const [addItemId, setAddItemId] = useState(0)
const [addQty, setAddQty] = useState(1)
const [outputNameByItem, setOutputNameByItem] = useState<Record<number, string>>({})
if (!data) return null
const bossEncounters = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
const bossLootItemIds = new Set(
data.encounterLoot
.filter((entry) => bossEncounters.some((boss) => boss.id === entry.encounterId))
.map((entry) => entry.itemId),
)
const itemLevels = Array.from(new Set(
data.craftingRecipes
.map((candidate) => data.items.find((item) => item.id === candidate.itemId)?.itemLevel)
.filter((itemLevel): itemLevel is number => itemLevel !== undefined),
)).sort((a, b) => a - b)
const filteredRecipes = data.craftingRecipes.filter((candidate) => {
const item = data.items.find((i) => i.id === candidate.itemId)
if (!item) return false
if (itemLevelFilter !== 'all' && item.itemLevel !== Number(itemLevelFilter)) return false
if (bossFilterId === 0) return true
return candidate.sourceEncounterId === bossFilterId
|| candidate.components.some((component) => data.encounterLoot.some(
(entry) => entry.encounterId === bossFilterId && entry.itemId === component.itemId,
))
})
const recipe = filteredRecipes.find((r) => r.id === recipeId) ?? filteredRecipes[0] ?? null
const outputItem = recipe ? data.items.find((i) => i.id === recipe.itemId) : null
const outputName = outputItem ? outputNameByItem[outputItem.id] ?? outputItem.name : ''
const bossComponentOptions = data.items.filter((item) => (
item.slot === 'component'
&& bossLootItemIds.has(item.id)
&& !recipe?.components.some((component) => component.itemId === item.id)
))
const addComponentIsValid = bossComponentOptions.some((item) => item.id === addItemId)
const items = data.items
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
function bossName(id: number | null) {
return id ? bossEncounters.find((boss) => boss.id === id)?.enemyName ?? `Boss #${id}` : 'Any boss'
}
function componentBossNames(itemId: number) {
return (data!).encounterLoot
.filter((entry) => entry.itemId === itemId)
.map((entry) => bossEncounters.find((boss) => boss.id === entry.encounterId)?.enemyName ?? `Boss #${entry.encounterId}`)
.join(', ')
}
async function saveOutputName() {
if (!outputItem || outputName.trim() === '' || outputName === outputItem.name) return
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: true }))
try {
await fetchJson(`${API}/items/${outputItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: outputName.trim() }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) => (
item.id === outputItem.id ? { ...item, name: outputName.trim() } : item
)),
} : prev)
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: outputName.trim() }))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Rename failed')
} finally {
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: false }))
}
}
async function uploadItemImage(itemId: number, file: File | undefined) {
if (!file) return
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: true }))
try {
const imageData = await fileToDataUrl(file)
const result = await fetchJson<{ imageUrl: string }>(`${API}/items/${itemId}/image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) => (
item.id === itemId ? { ...item, imageUrl: result.imageUrl } : item
)),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Upload failed')
} finally {
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: false }))
}
}
async function deleteComponent(rId: number, iId: number) {
try {
await fetchJson(`${API}/crafting-recipes/${rId}/components/${iId}`, { method: 'DELETE' })
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === rId ? { ...r, components: r.components.filter((c) => c.itemId !== iId) } : r,
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed')
}
}
async function addComponent() {
if (!addComponentIsValid || !recipe) return
try {
await fetchJson(`${API}/crafting-recipes/${recipe.id}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: addItemId, quantity: addQty }),
})
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === recipe!.id
? { ...r, components: [...r.components.filter((c) => c.itemId !== addItemId), { itemId: addItemId, quantity: addQty }] }
: r,
),
} : prev)
setAddItemId(0)
setAddQty(1)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Add failed')
}
}
async function updateQty(rId: number, iId: number, quantity: number) {
try {
await fetchJson(`${API}/crafting-recipes/${rId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: iId, quantity }),
})
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === rId ? {
...r,
components: r.components.map((c) => c.itemId === iId ? { ...c, quantity } : c),
} : r,
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Update failed')
}
}
return (
<div className="admin-panel">
<div className="admin-crafting-filters">
<label>Item Level
<select value={itemLevelFilter} onChange={(e) => setItemLevelFilter(e.target.value)}>
<option value="all">All levels</option>
{itemLevels.map((itemLevel) => (
<option key={itemLevel} value={itemLevel}>iLvl {itemLevel}</option>
))}
</select>
</label>
<label>Boss
<select value={bossFilterId} onChange={(e) => setBossFilterId(Number(e.target.value))}>
<option value={0}>All bosses</option>
{bossEncounters.map((boss) => (
<option key={boss.id} value={boss.id}>{boss.enemyName}</option>
))}
</select>
</label>
<label>Recipe
<select value={recipe?.id ?? 0} onChange={(e) => setRecipeId(Number(e.target.value))}>
{filteredRecipes.length === 0 && <option value={0}>No matching recipes</option>}
{filteredRecipes.map((r) => (
<option key={r.id} value={r.id}>
[{itemGlyph(r.itemId)}] {itemName(r.itemId)} - {bossName(r.sourceEncounterId)}
</option>
))}
</select>
</label>
</div>
{outputItem && (
<div className="admin-recipe-header">
<img className="admin-recipe-image" src={outputItem.imageUrl || '/equipment-placeholder.svg'} alt={`${outputItem.name} icon`} />
<div>
<label className="admin-inline-field">Name
<input
value={outputName}
onChange={(e) => {
if (!outputItem) return
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: e.target.value }))
}}
/>
</label>
<small className="admin-item-meta">{outputItem.slot} · iLvl {outputItem.itemLevel} · {outputItem.rarity}</small>
</div>
<div className="admin-recipe-actions">
<button
className="primary-button"
disabled={saving[`item-name-${outputItem.id}`] || outputName.trim() === '' || outputName === outputItem.name}
onClick={saveOutputName}
type="button"
>
{saving[`item-name-${outputItem.id}`] ? 'Saving...' : 'Rename'}
</button>
<label className="boss-upload-button">
{saving[`item-image-${outputItem.id}`] ? 'Uploading...' : 'Upload Image'}
<input
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={saving[`item-image-${outputItem.id}`]}
onChange={(event) => uploadItemImage(outputItem.id, event.target.files?.[0])}
type="file"
/>
</label>
</div>
</div>
)}
<h3 className="admin-loot-title">Required Components</h3>
{(!recipe || recipe.components.length === 0) && (
<p className="admin-empty">No component requirements.</p>
)}
<div className="admin-loot-list">
{recipe?.components.map((comp) => (
<div key={comp.itemId} className="admin-loot-row">
<span className={`admin-glyph rarity-${data.items.find(i => i.id === comp.itemId)?.rarity ?? 'common'}`}>
{itemGlyph(comp.itemId)}
</span>
<span className="admin-loot-name">{itemName(comp.itemId)}</span>
<span className="admin-loot-weight">Qty:
<input className="admin-qty-input" type="number" min="1" value={comp.quantity}
onChange={(e) => updateQty(recipe.id, comp.itemId, Number(e.target.value))} />
</span>
<button className="danger-button" onClick={() => deleteComponent(recipe.id, comp.itemId)} type="button">X</button>
</div>
))}
</div>
<details className="admin-add-section">
<summary>Add Component</summary>
<div className="admin-add-form">
<label>Component
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
<option value={0}>Select component...</option>
{bossComponentOptions.map((i) => (
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({componentBossNames(i.id)})</option>
))}
</select>
</label>
<label>Quantity <input type="number" min="1" value={addQty} onChange={(e) => setAddQty(Number(e.target.value))} /></label>
<button className="primary-button" onClick={addComponent} disabled={!recipe || !addComponentIsValid} type="button">Add</button>
</div>
</details>
</div>
)
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Unable to read image.'))
})
reader.addEventListener('error', () => reject(reader.error ?? new Error('Unable to read image.')))
reader.readAsDataURL(file)
})
}
function groupBy<T>(items: T[], keyFn: (item: T) => string): Record<string, T[]> {
const groups: Record<string, T[]> = {}
for (const item of items) {
const key = keyFn(item)
if (!groups[key]) groups[key] = []
groups[key].push(item)
}
return groups
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react'
import {
loginAccount,
registerAccount,
type AuthSession,
} from '../profile'
import {
createOfflineCharacter,
hasOfflineCharacter,
resumeOfflineCharacter,
selectOnlineMode,
} from '../gameRepository'
type Props = {
onAuthenticated: (session: AuthSession) => void
serverMessage?: string
}
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [characterName, setCharacterName] = useState('')
const [offlineName, setOfflineName] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const offlineCharacterExists = hasOfflineCharacter()
async function submit(event: React.FormEvent) {
event.preventDefault()
setBusy(true)
setMessage('')
try {
selectOnlineMode()
const session = mode === 'login'
? await loginAccount(username, password)
: await registerAccount(username, password, characterName)
onAuthenticated(session)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
} finally {
setBusy(false)
}
}
function beginOffline() {
setMessage('')
try {
onAuthenticated(createOfflineCharacter(offlineName))
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
}
}
function resumeOffline() {
const session = resumeOfflineCharacter()
if (session) onAuthenticated(session)
}
return (
<main className="auth-shell">
<section className="auth-panel">
<div className="auth-brand">
<p className="eyebrow">Healer RPG</p>
<h1>I want to Heal</h1>
<p>
Build your healer, master each dungeon, and compete for the most
efficient clears.
</p>
</div>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
}}
type="button"
>
Sign In
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
type="button"
>
Create Account
</button>
</div>
<form onSubmit={submit}>
<label>
Username
<input
autoComplete="username"
maxLength={20}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={username}
/>
</label>
{mode === 'register' && (
<label>
Character Name
<input
autoComplete="nickname"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
required
value={characterName}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter Chronicle'
: 'Begin Adventure'}
</button>
</form>
<p className={`auth-message ${message ? 'error' : ''}`}>
{message || serverMessage || (
mode === 'register'
? 'The first account keeps the current local character and save.'
: 'Sign in to continue your character.'
)}
</p>
<div className="offline-divider"><span>or</span></div>
<section className="offline-entry">
<div>
<p className="eyebrow">Local Save</p>
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device and is excluded from online leaderboards.
</p>
</div>
{offlineCharacterExists && (
<button
className="offline-resume-button"
onClick={resumeOffline}
type="button"
>
Continue Offline Character
</button>
)}
<label>
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
<input
maxLength={20}
minLength={2}
onChange={(event) => setOfflineName(event.target.value)}
placeholder="Mira"
value={offlineName}
/>
</label>
<button
className="text-button offline-new-button"
onClick={beginOffline}
type="button"
>
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
</button>
</section>
</div>
</section>
</main>
)
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react'
import {
bindingLabel,
compactBindingLabel,
type ControllerIconStyle,
} from '../input'
const FACE_BUTTONS: Record<ControllerIconStyle, Partial<Record<number, { color: string; label: string }>>> = {
xbox: {
0: { color: '#107c10', label: 'A' },
1: { color: '#d13438', label: 'B' },
2: { color: '#0078d4', label: 'X' },
3: { color: '#ffb900', label: 'Y' },
},
playstation: {
0: { color: '#0070d1', label: '×' },
1: { color: '#df0024', label: '○' },
2: { color: '#f27ab8', label: '□' },
3: { color: '#00a35a', label: '△' },
},
nintendo: {
0: { color: '#e60012', label: 'B' },
1: { color: '#e60012', label: 'A' },
2: { color: '#e60012', label: 'Y' },
3: { color: '#e60012', label: 'X' },
},
}
function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
if (!binding.startsWith('Button')) return null
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
}
function FaceIcon({
color,
iconStyle,
label,
title,
}: {
color: string
iconStyle: ControllerIconStyle
label: string
title: string
}) {
return (
<span
aria-label={title}
className={`controller-face-icon controller-face-${iconStyle}`}
role="img"
style={{ '--button-color': color } as CSSProperties}
>
{label}
</span>
)
}
export function ControllerBindingLabel({
binding,
compact = false,
iconStyle,
}: {
binding: string
compact?: boolean
iconStyle: ControllerIconStyle
}) {
const faceButton = faceButtonFor(binding, iconStyle)
const title = bindingLabel(binding, iconStyle)
if (faceButton) {
return (
<FaceIcon
color={faceButton.color}
iconStyle={iconStyle}
label={faceButton.label}
title={title}
/>
)
}
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
}
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
return (
<span className="controller-style-preview" aria-hidden="true">
{[0, 1, 2, 3].map((button) => {
const faceButton = FACE_BUTTONS[iconStyle][button]
if (!faceButton) return null
return (
<FaceIcon
color={faceButton.color}
iconStyle={iconStyle}
key={button}
label={faceButton.label}
title=""
/>
)
})}
</span>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
type Props = {
profile: CharacterProfile
onBack: () => void
onSaved: (profile: CharacterProfile) => void
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5)
.map((ability) => ability.id)
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setMessage('')
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
return
}
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? abilityId : spellId),
)
setMessage('')
}
function clearSlot() {
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? null : spellId),
)
}
async function persistChanges() {
saveScroll()
setSaving(true)
setMessage('')
try {
const updated = await saveProfile(classId, slots)
onSaved(updated)
setMessage('Character saved.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to save character.')
} finally {
setSaving(false)
}
}
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</div>
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'talents' && (
<TalentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'class' && (
<div className="customize-layout">
<aside className="class-picker">
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
<div>
<strong>{candidate.name}</strong>
<small>{candidate.resourceName}</small>
</div>
</button>
))}
</aside>
<div className="loadout-editor">
<div className="class-detail">
<div
className="class-portrait"
style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}
>
{gameClass.name[0]}
</div>
<div>
<p className="eyebrow">Level {profile.character.level} Healer</p>
<h2>{gameClass.name}</h2>
<p>{gameClass.description}</p>
</div>
</div>
<div className="loadout-heading">
<div>
<p className="eyebrow">Active Loadout</p>
<h2>Ability Bar</h2>
</div>
<span>Select a slot, then choose an ability.</span>
</div>
<div className="ability-slots">
{slots.map((abilityId, index) => {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
key={index}
onClick={() => setSelectedSlot(index)}
type="button"
>
<kbd>{index + 1}</kbd>
<span>{ability?.glyph ?? '-'}</span>
<strong>{ability?.name ?? 'Empty Slot'}</strong>
</button>
)
})}
</div>
<div className="ability-library-heading">
<div>
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
</div>
<i>{locked ? `Level ${ability.unlockLevel}` : equipped ? 'Equipped' : `${ability.cost} ${gameClass.resourceName}`}</i>
</button>
)
})}
</div>
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
disabled={saving}
onClick={persistChanges}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
</button>
</div>
</div>
</div>
)}
</section>
)
}
+537
View File
@@ -0,0 +1,537 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
breakdownItem,
craftItem,
equipItem,
loadProfile,
type CharacterProfile,
type EquipmentSlot,
type Item,
} from '../profile'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
helmet: 'Helmet',
chest: 'Chest',
gloves: 'Gloves',
boots: 'Boots',
pants: 'Pants',
ring: 'Ring',
necklace: 'Necklace',
trinket: 'Trinket',
component: 'Component',
}
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
)
const firstItem = profile.inventory.find((item) => !item.equipped)
?? profile.inventory[0]
const [selectedItemId, setSelectedItemId] = useState<number | null>(
firstItem?.id ?? null,
)
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null)
const [equipping, setEquipping] = useState(false)
const [breakingDown, setBreakingDown] = useState(false)
const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
?? profile.craftingRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const equippedBySlot = useMemo(
() => new Map(
profile.inventory
.filter((item) => item.equipped)
.map((item) => [item.slot, item]),
),
[profile.inventory],
)
const comparisonItem = selectedItem
? equippedBySlot.get(selectedItem.slot)
: undefined
const visibleInventory = useMemo(
() => selectedSlot
? profile.inventory.filter((item) => item.slot === selectedSlot)
: profile.inventory,
[profile.inventory, selectedSlot],
)
const visibleItemCount = visibleInventory.reduce(
(total, item) => total + item.quantity,
0,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes],
)
const filteredRecipes = useMemo(
() => {
let result = [...profile.craftingRecipes]
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
return result
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
}
}, [equipmentTab])
function saveScroll() {
scrollRef.current = window.scrollY
}
async function equipSelected() {
if (!selectedItem || selectedItem.equipped) return
saveScroll()
setEquipping(true)
setMessage('')
try {
const updated = await equipItem(selectedItem.id)
onUpdated(updated)
setMessage(`${selectedItem.name} equipped.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to equip item.')
} finally {
setEquipping(false)
}
}
async function breakdownSelected() {
if (!selectedItem) return
saveScroll()
setBreakingDown(true)
setMessage('')
try {
const updated = await breakdownItem(selectedItem.id)
onUpdated(updated)
setMessage(
selectedItem.quantity > 1
? `One duplicate ${selectedItem.name} broken down into components.`
: `${selectedItem.name} broken down into components.`,
)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to break down item.')
} finally {
setBreakingDown(false)
}
}
async function craftSelected() {
if (!selectedRecipe) return
saveScroll()
setCrafting(true)
setMessage('')
try {
const updated = await craftItem(selectedRecipe.id)
onUpdated(updated)
setSelectedItemId(selectedRecipe.item.id)
setMessage(`${selectedRecipe.item.name} crafted.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to craft item.')
} finally {
setCrafting(false)
}
}
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
<p>Select an item to inspect it.</p>
)}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
<div className="equipment-slots">
{profile.equipmentSlots.map((slot) => {
const item = equippedBySlot.get(slot)
return (
<button
className={`${item ? `rarity-${item.rarity}` : 'empty'} ${selectedSlot === slot ? 'selected-slot' : ''}`}
key={slot}
onClick={() => {
setSelectedSlot(slot)
const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot,
)
setSelectedItemId(item?.id ?? firstSlotItem?.id ?? null)
}}
type="button"
>
<span>{item?.glyph ?? '-'}</span>
<div>
<strong>{item?.name ?? SLOT_LABELS[slot]}</strong>
<small>{SLOT_LABELS[slot]}{item ? ` - iLvl ${item.itemLevel}` : ' - Empty'}</small>
</div>
<div className="item-status">
{item && <i>Equipped</i>}
</div>
</button>
)
})}
</div>
</section>
<section className="inventory-panel">
<EquipmentHeading
eyebrow="Owned Items"
title={selectedSlot ? `${SLOT_LABELS[selectedSlot]} Inventory` : 'Inventory'}
detail={selectedSlot
? `${visibleItemCount} items - ${visibleInventory.length} types`
: `${totalItemCount} items - ${profile.inventory.length} types`}
/>
{selectedSlot && (
<button
className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)}
type="button"
>
Show All Items
</button>
)}
<div className="inventory-list">
{visibleInventory.map((item) => (
<button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
<div className="item-status">
{item.equipped && <i>Equipped</i>}
{item.quantity > 1 && <i className="item-quantity">x{item.quantity}</i>}
</div>
</button>
))}
{visibleInventory.length === 0 && (
<p className="inventory-empty">
No {SLOT_LABELS[selectedSlot ?? 'component'].toLowerCase()} items owned.
</p>
)}
</div>
</section>
</div>
</>
) : (
<section className="crafting-panel">
<EquipmentHeading
eyebrow="Crafting"
title="Recipes"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
/>
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
{filteredRecipes.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
onClick={() => setSelectedRecipeId(recipe.id)}
type="button"
>
<span>{recipe.item.glyph}</span>
<div>
<strong>{recipe.item.name}</strong>
<small>
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small>
</div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button>
))}
</div>
{selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-components">
{selectedRecipe.components.map((component) => (
<div
className={component.owned >= component.quantity ? 'ready' : 'missing'}
key={component.item.id}
>
<span>{component.item.glyph}</span>
<strong>{component.item.name}</strong>
<i>{component.owned}/{component.quantity}</i>
</div>
))}
</div>
<button
className="primary-button"
disabled={!selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : 'Craft Item'}
</button>
</div>
)}
</div>
)}
</section>
)}
{profile.setBonuses.length > 0 && (
<section className="set-bonus-panel">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Set Bonuses</p>
<h2>Raid Sets</h2>
</div>
<button
className="text-button"
onClick={() => setShowSetBonuses((current) => !current)}
type="button"
>
{showSetBonuses ? 'Hide Raid Sets' : 'Show Raid Sets'}
</button>
</div>
{showSetBonuses && (
<div className="set-bonus-list">
{profile.setBonuses.map((bonus) => (
<div className={bonus.active ? 'active' : ''} key={`${bonus.setId}-${bonus.requiredPieces}`}>
<strong>{bonus.requiredPieces} pieces</strong>
<span>{bonus.description}</span>
<i>{bonus.equippedPieces}/{bonus.requiredPieces}</i>
</div>
))}
</div>
)}
</section>
)}
<footer className="equipment-footer">
{message || 'Equipment changes are saved immediately.'}
</footer>
</>
)
if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div>
}
return (
<section className="content-screen equipment-screen">
{content}
</section>
)
}
function GearStat({ value, label }: { value: string; label: string }) {
return (
<div className="gear-stat">
<strong>{value}</strong>
<span>{label}</span>
</div>
)
}
function EquipmentHeading({
eyebrow,
title,
detail,
}: {
eyebrow: string
title: string
detail?: string
}) {
return (
<div className="equipment-heading">
<div><p className="eyebrow">{eyebrow}</p><h2>{title}</h2></div>
{detail && <span>{detail}</span>}
</div>
)
}
function ItemDetail({ title, item }: { title: string; item: Item }) {
return (
<article className={`item-detail rarity-${item.rarity}`}>
<p className="eyebrow">{title}</p>
<div className="item-title">
<span>{item.glyph}</span>
<div>
<h2>{item.name}</h2>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
</div>
<p>{item.description}</p>
{item.quantity > 1 && <p className="owned-quantity">Owned: {item.quantity}</p>}
{item.slot !== 'component' && (
<ul>
<li>+{item.healingPower} Healing Power</li>
<li>+{item.maxResourceBonus} Max Resource</li>
</ul>
)}
</article>
)
}
function ComparisonDelta({
selected,
equipped,
}: {
selected: Item
equipped?: Item
}) {
const healingDelta = selected.healingPower - (equipped?.healingPower ?? 0)
const resourceDelta = selected.maxResourceBonus - (equipped?.maxResourceBonus ?? 0)
return (
<div className="comparison-delta">
<span className={healingDelta >= 0 ? 'positive' : 'negative'}>
{healingDelta >= 0 ? '+' : ''}{healingDelta} Healing
</span>
<span className={resourceDelta >= 0 ? 'positive' : 'negative'}>
{resourceDelta >= 0 ? '+' : ''}{resourceDelta} Resource
</span>
</div>
)
}
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react'
import {
ACTION_LABELS,
INPUT_ACTIONS,
useInput,
type InputDevice,
} from '../input'
import {
ControllerBindingLabel,
ControllerStylePreview,
} from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen'
import {
getNativeDisplays,
hasNativeDualScreenBridge,
type AndroidDisplay,
} from '../nativeDualScreen'
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const {
bindings,
capture,
controllerIconStyle,
directPartyTargeting,
beginCapture,
cancelCapture,
resetBindings,
setControllerIconStyle,
setDirectPartyTargeting,
} = useInput()
const {
enabled: dualScreenEnabled,
connected: topDisplayConnected,
setEnabled: setDualScreenEnabled,
openTopDisplay,
} = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget'
: !directTargetActions.has(action)
))
async function refreshNativeDisplays() {
if (!nativeDualScreen) return
try {
const result = await getNativeDisplays()
setAndroidDisplays(result.displays)
} catch {
setAndroidDisplays([])
}
}
useEffect(() => {
if (!nativeDualScreen) return
getNativeDisplays()
.then((result) => setAndroidDisplays(result.displays))
.catch(() => setAndroidDisplays([]))
}, [nativeDualScreen])
async function launchTopDisplay() {
const opened = await openTopDisplay()
setDisplayMessage(opened
? nativeDualScreen
? 'Android placed the game on the larger display and controls on the smaller display.'
: 'Companion display opened. Move it to the Thor screen you want and select Fullscreen.'
: 'No usable second display was found. Check the Thor display mode and try again.')
await refreshNativeDisplays()
}
return (
<section className="content-screen settings-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Game Options</p>
<h1>Settings</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<section className="dual-screen-settings">
<div>
<p className="eyebrow">Display</p>
<h2>AYN Thor Dual-Screen Mode</h2>
<p>
The upper display shows enemy and party health. The lower display
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<section className="controller-preferences">
<div>
<p className="eyebrow">Targeting</p>
<h3>Direct Party Keybinds</h3>
<p>
Assign party slots directly. In raids, use the group-switch binding
to alternate between members 1-5 and 6-10.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</div>
</section>
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
</button>
))}
</div>
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
{capture && (
<div className="binding-capture" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Remapping</p>
<h2>{ACTION_LABELS[capture.action]}</h2>
<p>
Press any {capture.device === 'pc' ? 'keyboard key' : 'controller button or move a stick'}.
</p>
<button onClick={cancelCapture} type="button">Cancel</button>
</div>
</div>
)}
</section>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from 'react'
import {
allocateTalent,
resetTalents,
type CharacterProfile,
type Talent,
} from '../profile'
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
}
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId,
)!
const classPointsSpent = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
)
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function lowerTierPoints(talent: Talent) {
return gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
}
function lockReason(talent: Talent) {
if (talent.rank >= talent.maxRank) return 'Maximum rank'
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints(talent) < requiredTierPoints) {
return `Requires ${requiredTierPoints} earlier-tier points`
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
}
}
if (profile.character.talentPoints <= 0) return 'No points available'
return ''
}
async function purchaseRank(talent: Talent) {
saveScroll()
setBusyTalentId(talent.id)
setMessage('')
try {
const updated = await allocateTalent(talent.id)
onUpdated(updated)
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
} finally {
setBusyTalentId(null)
}
}
async function refundTree() {
saveScroll()
setResetting(true)
setMessage('')
try {
const updated = await resetTalents()
onUpdated(updated)
setMessage('All points in this talent tree were refunded.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
} finally {
setResetting(false)
}
}
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Growth</p>
<h1>Talents</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="talent-toolbar">
<div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]}
</span>
<div>
<p className="eyebrow">{gameClass.name} Tree</p>
<h2>Shape Your Healing Style</h2>
</div>
</div>
<div className="talent-points">
<strong>{profile.character.talentPoints}</strong>
<span>Available</span>
<small>{classPointsSpent} spent in this tree</small>
</div>
</div>
<div className="talent-tree">
{tiers.map((tier) => {
const requiredPoints = (tier - 1) * 5
return (
<section className="talent-tier" key={tier}>
<div className="tier-label">
<span>Tier {tier}</span>
<small>
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
</small>
</div>
<div className="tier-talents">
{gameClass.talents
.filter((talent) => talent.tier === tier)
.sort((a, b) => a.branch - b.branch)
.map((talent) => {
const reason = lockReason(talent)
const isBusy = busyTalentId === talent.id
return (
<article
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
key={talent.id}
style={{ gridColumn: talent.branch }}
>
<div className="talent-node-header">
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>Rank {talent.rank}/{talent.maxRank}</small>
</div>
</div>
<p>{talent.description}</p>
<div className="rank-pips">
{Array.from({ length: talent.maxRank }, (_, index) => (
<i className={index < talent.rank ? 'filled' : ''} key={index} />
))}
</div>
<button
disabled={Boolean(reason) || isBusy}
onClick={() => purchaseRank(talent)}
type="button"
>
{isBusy ? 'Saving...' : reason || 'Add Rank'}
</button>
</article>
)
})}
</div>
</section>
)
})}
</div>
<footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span>
<button
className="text-button"
disabled={classPointsSpent === 0 || resetting}
onClick={refundTree}
type="button"
>
{resetting ? 'Refunding...' : 'Reset Tree'}
</button>
</footer>
</>
)
if (embedded) {
return <div className="talent-screen embedded-screen">{content}</div>
}
return (
<section className="content-screen talent-screen">
{content}
</section>
)
}
+447
View File
@@ -0,0 +1,447 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type { CombatLogEntry, PartyMember, Spell } from './game'
import {
hasNativeDualScreenBridge,
openNativeTopDisplay,
} from './nativeDualScreen'
import {
dispatchExternalGameAction,
type ControllerIconStyle,
type InputAction,
} from './input'
import { ControllerBindingLabel } from './components/ControllerIcons'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
const CHANNEL_NAME = 'ashen-halls-dual-screen'
export type DualScreenCombatState = {
difficultyName: string
dungeonName: string
contentName: string
encounterName: string
encounterDescription: string
encounterHealth: number
encounterMaxHealth: number
encounterIsBoss: boolean
encounterIndex: number
encounterCount: number
party: PartyMember[]
partySize: number
selectedId: string
log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
resource: number
maxResource: number
resourceName: string
playerIsAlive: boolean
spells: Array<(Spell & { slotIndex: number; remaining: number }) | null>
activeDevice: 'pc' | 'controller'
bindings: Record<InputAction, string>
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
type DualScreenContextValue = {
enabled: boolean
connected: boolean
setEnabled: (enabled: boolean) => void
openTopDisplay: () => Promise<boolean>
}
const DualScreenContext = createContext<DualScreenContextValue | null>(null)
function createChannel() {
return typeof BroadcastChannel === 'undefined'
? null
: new BroadcastChannel(CHANNEL_NAME)
}
function saveSnapshot(state: DualScreenCombatState) {
try {
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify({
savedAt: Date.now(),
state,
}))
} catch {
// Live BroadcastChannel updates still work if storage is unavailable.
}
}
function loadRecentSnapshot() {
try {
const snapshot = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) ?? 'null') as {
savedAt: number
state: DualScreenCombatState
} | null
if (!snapshot || Date.now() - snapshot.savedAt > 15000) return null
return snapshot.state
} catch {
return null
}
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
)
const [connected, setConnected] = useState(false)
const heartbeatRef = useRef(0)
const setEnabled = useCallback((nextEnabled: boolean) => {
localStorage.setItem(STORAGE_KEY, String(nextEnabled))
setEnabledState(nextEnabled)
if (!nextEnabled) setConnected(false)
}, [])
const openTopDisplay = useCallback(async () => {
setEnabled(true)
if (hasNativeDualScreenBridge()) {
try {
await openNativeTopDisplay()
return true
} catch {
return false
}
}
const url = new URL(window.location.href)
url.searchParams.set('display', 'bottom')
const companion = window.open(
url.toString(),
'ashen-halls-top-display',
'popup=yes,width=1280,height=720',
)
companion?.focus()
return Boolean(companion)
}, [setEnabled])
useEffect(() => {
const channel = createChannel()
if (!channel) return
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (
event.data.type !== 'companion-ready'
&& event.data.type !== 'companion-heartbeat'
) return
heartbeatRef.current = Date.now()
setConnected(true)
}
const timer = window.setInterval(() => {
if (Date.now() - heartbeatRef.current > 3500) setConnected(false)
}, 1000)
return () => {
window.clearInterval(timer)
channel.close()
}
}, [])
const value = useMemo(
() => ({ enabled, connected, setEnabled, openTopDisplay }),
[connected, enabled, openTopDisplay, setEnabled],
)
return (
<DualScreenContext.Provider value={value}>
{children}
</DualScreenContext.Provider>
)
}
export function useDualScreen() {
const context = useContext(DualScreenContext)
if (!context) throw new Error('useDualScreen must be used inside DualScreenProvider')
return context
}
export function useDualScreenPublisher(
state: DualScreenCombatState,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled) return
const channel = createChannel()
if (!channel) return
const publish = () => channel.postMessage({
type: 'combat-state',
state: stateRef.current,
} satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled])
useEffect(() => {
if (!enabled) return
saveSnapshot(state)
const channel = createChannel()
channel?.postMessage({ type: 'combat-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state)
if (event.data.type === 'combat-ended') setState(null)
}
announce()
const timer = window.setInterval(() => {
channel.postMessage({ type: 'companion-heartbeat' } satisfies DualScreenMessage)
}, 1500)
return () => {
window.clearInterval(timer)
channel.close()
}
}, [])
function sendAction(action: InputAction) {
const channel = createChannel()
channel?.postMessage({ type: 'control-action', action } satisfies DualScreenMessage)
channel?.close()
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
<section>
<p className="eyebrow">Dual-Screen HUD</p>
<h1>Waiting for Combat</h1>
<p>Choose a dungeon or raid on the upper screen.</p>
</section>
</main>
)
}
return (
<main className="dual-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
<h1>{state.dungeonName}</h1>
</div>
<div className="dual-controls-progress">
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
</div>
</header>
<section className="dual-controls-resource">
<div>
<p className="eyebrow">Active Target</p>
<strong>
{state.party.find((member) => member.id === state.selectedId)?.name ?? 'No Target'}
</strong>
</div>
<div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div>
</div>
</section>
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? (
<>
{([1, 2, 3, 4, 5] as const).map((slot) => {
const action = `targetParty${slot}` as InputAction
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
return (
<button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel
binding={state.bindings[action]}
iconStyle={state.controllerIconStyle}
/>{' '}
{state.party[memberIndex]?.name ?? `Party ${slot}`}
</button>
)
})}
{state.partySize === 10 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel
binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle}
/>{' '}
Party Group {state.targetGroup + 1}
</button>
)}
</>
) : (
<>
<button onClick={() => sendAction('previousTarget')} type="button">
<ControllerBindingLabel
binding={state.bindings.previousTarget}
iconStyle={state.controllerIconStyle}
/> Previous Target
</button>
<button onClick={() => sendAction('nextTarget')} type="button">
Next Target <ControllerBindingLabel
binding={state.bindings.nextTarget}
iconStyle={state.controllerIconStyle}
/>
</button>
</>
)}
</div>
<section className="dual-controls-spells">
{state.spells.map((spell, slotIndex) => {
if (!spell) {
return (
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
<kbd>{slotIndex + 1}</kbd>
<strong>Empty</strong>
</div>
)
}
const action = `ability${slotIndex + 1}` as InputAction
return (
<button
className="spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => sendAction(action)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={state.bindings[action]}
compact
iconStyle={state.controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{spell.cost} {state.resourceName}</small>
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</main>
)
}
export function DualScreenTopCombat({
state,
onSelectTarget,
}: {
state: DualScreenCombatState
onSelectTarget: (id: string) => void
}) {
const enemyPercent = Math.max(
0,
(state.encounterHealth / state.encounterMaxHealth) * 100,
)
return (
<div className="dual-top-main">
<section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'}
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{state.encounterName}</strong>
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div>
<div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} />
</div>
<p>{state.encounterDescription}</p>
</div>
</section>
<section className="dual-top-party">
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
{state.party.map((member, index) => {
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
const targetAction = `targetParty${partySlot}` as InputAction
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
key={member.id}
onClick={() => onSelectTarget(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.ceil(member.health)} / {member.maxHealth}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
{member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)}
</div>
{state.directPartyTargeting && targetBinding && (
<div className="member-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={state.controllerIconStyle}
/>
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
)
})}
</div>
</section>
<footer className="dual-top-log">
{state.log.slice(0, 3).map((entry) => (
<span className={entry.tone} key={entry.id}>{entry.text}</span>
))}
</footer>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
export type Role = 'Tank' | 'Healer' | 'Damage'
export type PartyMember = {
id: string
name: string
role: Role
health: number
maxHealth: number
shield: number
hotTicks: number
debuff?: string
debuffTicks?: number
poisonStacks?: number
maxHealthPenaltyTicks?: number
healingReductionTicks?: number
}
export type Spell = {
id: string
key: string
name: string
description: string
cost: number
cooldown: number
power: number
glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
}
export type Encounter = {
id: string
enemyName: string
description: string
maxHealth: number
damage: number
tankDamage: number
partyDamage: number
isBoss: boolean
}
export type CombatLogEntry = {
id: number
text: string
tone: 'system' | 'heal' | 'danger' | 'loot'
}
export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
]
export const RAID_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 165, maxHealth: 165, shield: 0, hotTicks: 0 },
{ id: 'tala', name: 'Tala', role: 'Tank', health: 155, maxHealth: 155, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'seren', name: 'Seren', role: 'Healer', health: 102, maxHealth: 102, shield: 0, hotTicks: 0 },
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
]
export const SPELLS: Spell[] = [
{
id: 'mend',
key: '1',
name: 'Mend',
description: 'A fast, efficient single-target heal.',
cost: 5,
cooldown: 0.5,
power: 30,
glyph: '+',
kind: 'direct',
},
{
id: 'renew',
key: '2',
name: 'Renew',
description: 'Heals now and continues healing over time.',
cost: 7,
cooldown: 0.5,
power: 12,
glyph: '~',
kind: 'hot',
},
{
id: 'radiance',
key: '3',
name: 'Radiance',
description: 'Restores health to every living party member.',
cost: 12,
cooldown: 8,
power: 18,
glyph: '*',
kind: 'group',
},
{
id: 'ward',
key: '4',
name: 'Sun Ward',
description: 'Places a damage-absorbing shield on your target.',
cost: 8,
cooldown: 7,
power: 36,
glyph: 'O',
kind: 'shield',
},
{
id: 'purify',
key: '5',
name: 'Purify',
description: 'Removes a harmful effect and restores a little health.',
cost: 5,
cooldown: 5,
power: 10,
glyph: 'x',
kind: 'cleanse',
},
]
export const ENCOUNTERS: Encounter[] = [
{
id: 'ashfang-pack',
enemyName: 'Ashfang Pack',
description: 'Three beasts snap at random party members.',
maxHealth: 390,
damage: 13,
tankDamage: 7,
partyDamage: 24,
isBoss: false,
},
{
id: 'cinder-adepts',
enemyName: 'Cinder Adepts',
description: 'Cultists pressure the tank while throwing embers into the group.',
maxHealth: 470,
damage: 16,
tankDamage: 10,
partyDamage: 25,
isBoss: false,
},
{
id: 'warden-vhal',
enemyName: 'Warden Vhal',
description: 'Boss: cleanse Searing Mark and prepare group healing for Cinder Pulse.',
maxHealth: 820,
damage: 18,
tankDamage: 13,
partyDamage: 27,
isBoss: true,
},
]
+946
View File
@@ -0,0 +1,946 @@
import starterProfile from './offline-starter-profile.json'
import type {
AuthSession,
CharacterProfile,
DungeonReward,
LootRoll,
Item,
EquipmentSlot,
} from './profile'
export type GameMode = 'online' | 'offline'
export interface GameRepository {
loadSession(): Promise<AuthSession>
register(username: string, password: string, characterName: string): Promise<AuthSession>
login(username: string, password: string): Promise<AuthSession>
logout(): Promise<void>
loadProfile(): Promise<CharacterProfile>
saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
completeDungeon(
dungeonId: number,
difficultyId: number,
resourceSpent: number,
durationSeconds: number,
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
): Promise<DungeonReward>
completeRoguelike(
dungeonId: number,
difficultyId: number,
encountersCleared: number,
resourceSpent: number,
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
},
): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
resetTalents(): Promise<CharacterProfile>
equipItem(itemId: number): Promise<CharacterProfile>
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
runToken: string,
): Promise<LootRoll>
}
type CharacterData = {
level: number
experience: number
talentPoints: number
abilitySlots: Array<number | null>
talentRanks: Record<string, number>
inventory: Item[]
}
type OfflineSave = {
version: 3
characterName: string
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
}
}
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
for (const cid of classes) {
const gameClass = p.classes.find((c) => c.id === cid)!
const talentRanks: Record<string, number> = {}
for (const t of gameClass.talents) {
talentRanks[String(t.id)] = t.rank
}
characters[cid] = {
level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0,
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}
function requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return save
}
function buildProfile(save: OfflineSave): CharacterProfile {
const static_ = clone(starterProfile) as CharacterProfile
const cd = save.characters[save.activeClassId]
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
static_.character.name = save.characterName
static_.character.level = cd.level
static_.character.experience = cd.experience
static_.character.talentPoints = cd.talentPoints
static_.character.classId = gameClass.id
static_.character.classSlug = gameClass.slug
static_.character.className = gameClass.name
static_.character.resourceName = gameClass.resourceName
static_.character.maxResource = gameClass.maxResource
static_.character.themeColor = gameClass.themeColor
static_.character.classDescription = gameClass.description
static_.character.currentLevelExperience = experienceForLevel(cd.level)
static_.character.nextLevelExperience = cd.level >= static_.maxLevel
? experienceForLevel(static_.maxLevel)
: experienceForLevel(cd.level + 1)
static_.abilitySlots = cd.abilitySlots
for (const c of static_.classes) {
for (const t of c.talents) {
t.rank = cd.talentRanks[String(t.id)] ?? 0
}
}
static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0)
static_.inventory = cd.inventory
updateGearStats(static_)
updateSetBonuses(static_)
updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases
return static_
}
function updateGearStats(profile: CharacterProfile) {
const equipped = profile.inventory.filter((item) => item.equipped)
profile.gearStats = {
averageItemLevel: profile.equipmentSlots.length === 0
? 0
: equipped.reduce((total, item) => total + item.itemLevel, 0)
/ profile.equipmentSlots.length,
healingPower: equipped.reduce((total, item) => total + item.healingPower, 0),
maxResourceBonus: equipped.reduce(
(total, item) => total + item.maxResourceBonus,
0,
),
}
}
function updateSetBonuses(profile: CharacterProfile) {
const equippedSetCounts = new Map<number, number>()
for (const item of profile.inventory) {
if (!item.equipped || !item.setId) continue
equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1)
}
profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => {
const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0
return {
...bonus,
equippedPieces,
active: equippedPieces >= bonus.requiredPieces,
}
})
}
function updateCraftingRecipes(profile: CharacterProfile) {
const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity]))
profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => {
const components = recipe.components.map((component) => ({
...component,
owned: owned.get(component.item.id) ?? 0,
}))
return {
...recipe,
components,
canCraft: components.every((component) => component.owned >= component.quantity),
}
})
}
function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equipped'>, quantity: number) {
const existing = inventory.find((candidate) => candidate.id === item.id)
if (existing) {
existing.quantity += quantity
return { duplicate: true, quantityAfter: existing.quantity }
}
inventory.push({
...item,
quantity,
equipped: false,
})
return { duplicate: false, quantityAfter: quantity }
}
function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100
}
function scaledPvpBossExperience(
startingExperience: number,
startingLevel: number,
bossesCleared: number,
maxLevel: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
}
return { experience, level }
}
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
}
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b)
let best = levels[0]
for (const level of levels) {
if (level <= itemLevel) best = level
}
return COMPONENT_ITEMS[best]
}
function componentDropQuantity(itemLevel: number) {
const tier = Math.max(0, Math.floor((itemLevel - 5) / 5))
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
let weightedRoll = Math.random() * totalWeight
for (const entry of entries) {
weightedRoll -= entry.dropWeight
if (weightedRoll < 0) return entry
}
return entries[entries.length - 1]
}
function getApiBaseUrl(): string {
const browserWindow = typeof window === 'undefined'
? undefined
: window as WindowWithApiBase
if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL
}
return ''
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body
}
const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }),
}),
login: (username, password) =>
requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' })
},
loadProfile: () => requestJson('/api/profile'),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
}
function emptyCharacterData(classId: number): CharacterData {
const static_ = clone(starterProfile) as CharacterProfile
const gc = static_.classes.find((c) => c.id === classId)!
const talentRanks: Record<string, number> = {}
for (const t of gc.talents) talentRanks[String(t.id)] = 0
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1)
.slice(0, 5)
.map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
return {
level: 1,
experience: 0,
talentPoints: 1,
abilitySlots: startingAbilitySlots,
talentRanks,
inventory,
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
return {
account: save ? offlineAccount : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6)
while (slots.length < 6) slots.push(null)
const selectedIds = slots.filter((id): id is number => id !== null)
if (new Set(selectedIds).size !== selectedIds.length) {
throw new Error('The same ability cannot be equipped twice.')
}
const activeChar = save.characters[save.activeClassId]
const validIds = new Set(
gameClass.spells
.filter((spell) => spell.unlockLevel <= activeChar.level)
.map((spell) => spell.id),
)
if (selectedIds.some((id) => !validIds.has(id))) {
throw new Error('One or more abilities are locked or belong to another class.')
}
if (!save.characters[classId]) {
save.characters[classId] = emptyCharacterData(classId)
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
void startPart
void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this dungeon.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const partCount = completedPart ?? 1
const experienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
)
const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
let newLevel = previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
if (dungeon.contentType === 'raid') {
save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount)
} else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
}
let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3
const eligibleLoot = dungeon.completionLoot.filter(
(item) => item.itemLevel >= targetItemLevel,
)
if (eligibleLoot.length > 0) {
const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel))
const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel)
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing)
let quantityAfter = 1
if (existing) {
existing.quantity += 1
quantityAfter = existing.quantity
} else {
profile.inventory.push({
...selected,
quantity: 1,
equipped: false,
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter }
}
}
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: dungeon.name,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
profile: updatedProfile,
}
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this roguelike.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
: null
const newExperience = scaledReward
? scaledReward.experience
: Math.min(
previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
maxExperience,
)
let newLevel = scaledReward?.level ?? previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: `${dungeon.name} Roguelike`,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem: null,
profile: updatedProfile,
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.')
if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
if (talent.rank >= talent.maxRank) {
throw new Error('That talent is already at maximum rank.')
}
const lowerTierPoints = gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints < requiredTierPoints) {
throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
throw new Error(
`The prerequisite talent requires rank ${talent.prerequisiteRank}.`,
)
}
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const refunded = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
)
for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0
}
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
for (const candidate of profile.inventory) {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === 'component') throw new Error('Components cannot be broken down.')
if (item.equipped && item.quantity <= 1) {
throw new Error('Equipped items cannot be broken down.')
}
const componentTemplate = componentForItemLevel(item.itemLevel)
if (!componentTemplate) throw new Error('No component type exists for this item level.')
if (item.quantity <= 1) {
profile.inventory.splice(profile.inventory.indexOf(item), 1)
} else {
item.quantity -= 1
}
const existing = profile.inventory.find((c) => c.id === componentTemplate.id)
const count = Math.floor(Math.random() * 3) + 1
if (existing) {
existing.quantity += count
} else {
profile.inventory.push({
id: componentTemplate.id,
slug: componentTemplate.slug,
name: componentTemplate.name,
slot: 'component' as EquipmentSlot,
rarity: 'common' as const,
itemLevel: componentTemplate.itemLevel,
healingPower: 0,
maxResourceBonus: 0,
glyph: componentTemplate.glyph,
description: componentTemplate.description,
quantity: count,
equipped: false,
})
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
}
for (const component of recipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
owned.quantity -= component.quantity
}
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
}
const save = requireOfflineSave()
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId)
const difficulty = profile.dungeons
.flatMap((dungeon) => dungeon.difficulties)
.find((candidate) => candidate.id === difficultyId)
const entries = encounter?.lootTables.filter(
(entry) => entry.difficultyId === difficultyId,
) ?? []
if (!encounter || !difficulty || entries.length === 0) {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) =>
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
)
const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, {
entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
})
}
for (const { entry, quantity } of selectedQuantities.values()) {
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...rolledItem
} = entry
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const added = addInventoryItem(profile.inventory, {
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
}, quantity)
items.push({
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
})
}
const item = items[0] ?? null
const result: LootRoll = {
encounterId,
encounterName: encounter.enemyName,
difficultyId,
difficultyName: difficulty.name,
dropChance,
dropped: items.length > 0,
item,
items,
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return clone(result)
},
}
export function getGameMode(): GameMode {
return readMode()
}
export function selectOnlineMode() {
writeMode('online')
}
export function createOfflineCharacter(characterName: string): AuthSession {
const name = characterName.trim() || 'Mira'
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
throw new Error('Character name must be 2-20 characters and start with a letter.')
}
const characters: Record<number, CharacterData> = {}
for (const cid of [1, 2, 3]) {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function hasOfflineCharacter(): boolean {
return readOfflineSave() !== null
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
}
+15
View File
@@ -0,0 +1,15 @@
:root {
background:
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8;
font-family: 'VT323', Consolas, monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
+716
View File
@@ -0,0 +1,716 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export const INPUT_ACTIONS = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
'previousTarget',
'nextTarget',
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
'pause',
] as const
export type InputAction = typeof INPUT_ACTIONS[number]
export type InputBindings = Record<InputAction, string>
export const ACTION_LABELS: Record<InputAction, string> = {
navigateUp: 'Navigate Up',
navigateDown: 'Navigate Down',
navigateLeft: 'Navigate Left',
navigateRight: 'Navigate Right',
confirm: 'Confirm / Select',
back: 'Back',
ability1: 'Ability Slot 1',
ability2: 'Ability Slot 2',
ability3: 'Ability Slot 3',
ability4: 'Ability Slot 4',
ability5: 'Ability Slot 5',
ability6: 'Ability Slot 6',
previousTarget: 'Previous Party Target',
nextTarget: 'Next Party Target',
targetParty1: 'Target Party Member 1',
targetParty2: 'Target Party Member 2',
targetParty3: 'Target Party Member 3',
targetParty4: 'Target Party Member 4',
targetParty5: 'Target Party Member 5',
toggleTargetGroup: 'Switch Raid Target Group',
pause: 'Pause Menu',
}
export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
pc: {
navigateUp: 'ArrowUp',
navigateDown: 'ArrowDown',
navigateLeft: 'ArrowLeft',
navigateRight: 'ArrowRight',
confirm: 'Enter',
back: 'Escape',
ability1: 'Digit1',
ability2: 'Digit2',
ability3: 'Digit3',
ability4: 'Digit4',
ability5: 'Digit5',
ability6: 'Digit6',
previousTarget: 'KeyQ',
nextTarget: 'KeyE',
targetParty1: 'F1',
targetParty2: 'F2',
targetParty3: 'F3',
targetParty4: 'F4',
targetParty5: 'F5',
toggleTargetGroup: 'Tab',
pause: 'Escape',
},
controller: {
navigateUp: 'Axis1-',
navigateDown: 'Axis1+',
navigateLeft: 'Axis0-',
navigateRight: 'Axis0+',
confirm: 'Button0',
back: 'Button1',
ability1: 'Button3',
ability2: 'Button2',
ability3: 'Button0',
ability4: 'Button1',
ability5: 'Button5',
ability6: 'Button7',
previousTarget: 'Button14',
nextTarget: 'Button15',
targetParty1: 'Button14',
targetParty2: 'Button12',
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
toggleTargetGroup: 'Button6',
pause: 'Button9',
},
}
const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
type CaptureState = {
device: InputDevice
action: InputAction
} | null
type InputContextValue = {
bindings: Record<InputDevice, InputBindings>
capture: CaptureState
lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void
resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void
}
const InputContext = createContext<InputContextValue | null>(null)
function loadBindings(): Record<InputDevice, InputBindings> {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
const usesLegacyAbilityDefaults = [
'Button2',
'Button3',
'Button4',
'Button5',
'Button6',
'Button7',
].every((binding, index) => (
controller[`ability${index + 1}` as InputAction] === binding
))
if (usesLegacyAbilityDefaults) {
Object.assign(controller, {
ability1: DEFAULT_BINDINGS.controller.ability1,
ability2: DEFAULT_BINDINGS.controller.ability2,
ability3: DEFAULT_BINDINGS.controller.ability3,
ability4: DEFAULT_BINDINGS.controller.ability4,
ability5: DEFAULT_BINDINGS.controller.ability5,
ability6: DEFAULT_BINDINGS.controller.ability6,
})
}
return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller,
}
} catch {
return structuredClone(DEFAULT_BINDINGS)
}
}
function loadPreferences() {
try {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
directPartyTargeting: saved.directPartyTargeting ?? false,
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
directPartyTargeting: false,
}
}
}
function isTextInput(element: Element | null): element is HTMLInputElement | HTMLTextAreaElement {
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
}
function bindingGroup(action: InputAction) {
if (action.startsWith('ability')) return 'abilities'
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause'
return 'navigation'
}
function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
const scope: ParentNode = keyboard ?? pauseMenu ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
).filter(isVisible)
}
export function focusFirstControl() {
const first = focusableElements()[0]
first?.focus({ preventScroll: true })
return first
}
function moveFocus(action: InputAction) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = document.activeElement instanceof HTMLElement
&& candidates.includes(document.activeElement)
? document.activeElement
: null
if (!current) {
focusFirstControl()
return
}
const currentRect = current.getBoundingClientRect()
const currentX = currentRect.left + currentRect.width / 2
const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const ranked = candidates
.filter((candidate) => candidate !== current)
.map((candidate) => {
const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
})
.filter(({ primary }) => Math.sign(primary) === direction)
.sort((a, b) => a.score - b.score)
const next = ranked[0]?.candidate
if (!next) return
next.focus({ preventScroll: true })
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'Left Bumper',
5: 'Right Bumper',
6: 'Left Trigger',
7: 'Right Trigger',
8: 'View / Select',
9: 'Menu / Start',
10: 'Left Stick',
11: 'Right Stick',
12: 'D-Pad Up',
13: 'D-Pad Down',
14: 'D-Pad Left',
15: 'D-Pad Right',
16: 'Home',
}
const KEY_LABELS: Record<string, string> = {
ArrowUp: 'Up Arrow',
ArrowDown: 'Down Arrow',
ArrowLeft: 'Left Arrow',
ArrowRight: 'Right Arrow',
Enter: 'Enter',
Escape: 'Escape',
Space: 'Space',
}
export function bindingLabel(binding: string, iconStyle: ControllerIconStyle = 'xbox') {
if (binding.startsWith('Button')) {
const button = Number(binding.slice(6))
const faceLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
xbox: { 0: 'A', 1: 'B', 2: 'X', 3: 'Y' },
playstation: { 0: '×', 1: '○', 2: '□', 3: '△' },
nintendo: { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' },
}
const shoulderLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
xbox: { 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'View', 9: 'Start' },
playstation: { 4: 'L1', 5: 'R1', 6: 'L2', 7: 'R2', 8: 'Share', 9: 'Options' },
nintendo: { 4: 'L', 5: 'R', 6: 'ZL', 7: 'ZR', 8: 'Minus', 9: 'Plus' },
}
return faceLabels[iconStyle][button]
?? shoulderLabels[iconStyle][button]
?? BUTTON_LABELS[button]
?? `Button ${button}`
}
if (binding.startsWith('Axis')) {
const axis = Number(binding.slice(4, -1))
const direction = binding.endsWith('-') ? '-' : '+'
const labels: Record<string, string> = {
'0-': 'Left Stick Left',
'0+': 'Left Stick Right',
'1-': 'Left Stick Up',
'1+': 'Left Stick Down',
'2-': 'Right Stick Left',
'2+': 'Right Stick Right',
'3-': 'Right Stick Up',
'3+': 'Right Stick Down',
}
return labels[`${axis}${direction}`] ?? `Axis ${axis} ${direction}`
}
if (KEY_LABELS[binding]) return KEY_LABELS[binding]
if (binding.startsWith('Key') || binding.startsWith('Digit')) return binding.slice(-1)
return binding
}
export function compactBindingLabel(
binding: string,
iconStyle: ControllerIconStyle = 'xbox',
) {
const controllerLabels: Record<string, string> = {
Button14: 'D-Pad Left',
Button15: 'D-Pad Right',
}
return controllerLabels[binding] ?? bindingLabel(binding, iconStyle)
}
function gamepadTokens(gamepad: Gamepad) {
const tokens = new Set<string>()
gamepad.buttons.forEach((button, index) => {
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`)
})
gamepad.axes.forEach((value, index) => {
if (value < -0.65) tokens.add(`Axis${index}-`)
if (value > 0.65) tokens.add(`Axis${index}+`)
})
return tokens
}
function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) {
const prototype = input instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
setter?.call(input, nextValue)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
export function InputProvider({ children }: { children: ReactNode }) {
const [bindings, setBindings] = useState(loadBindings)
const [capture, setCapture] = useState<CaptureState>(null)
const [lastDevice, setLastDevice] = useState<InputDevice>('pc')
const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false)
const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences)
const captureRef = useRef(capture)
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
useEffect(() => {
bindingsRef.current = bindings
localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings))
}, [bindings])
useEffect(() => {
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
preferencesRef.current = preferences
}, [preferences])
useEffect(() => {
captureRef.current = capture
}, [capture])
useEffect(() => {
keyboardInputRef.current = keyboardInput
}, [keyboardInput])
const assignBinding = useCallback((device: InputDevice, action: InputAction, token: string) => {
setBindings((current) => {
const nextDevice = { ...current[device] }
const previousToken = nextDevice[action]
const collision = INPUT_ACTIONS.find(
(candidate) => (
candidate !== action
&& bindingGroup(candidate) === bindingGroup(action)
&& nextDevice[candidate] === token
),
)
if (collision) nextDevice[collision] = previousToken
nextDevice[action] = token
return { ...current, [device]: nextDevice }
})
setCapture(null)
}, [])
const closeKeyboard = useCallback(() => {
const input = keyboardInputRef.current
setKeyboardInput(null)
window.requestAnimationFrame(() => input?.focus({ preventScroll: true }))
}, [])
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (action.startsWith('navigate')) {
if (!combatActive) moveFocus(action)
} else if (action === 'confirm') {
const active = document.activeElement
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
active.click()
} else {
focusFirstControl()
}
} else if (action === 'back') {
if (keyboardInputRef.current) {
closeKeyboard()
} else if (!combatActive) {
const backButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
).find(isVisible)
backButton?.click()
}
}
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}, [closeKeyboard])
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
if (captureRef.current?.device === 'controller') {
if (!repeat) assignBinding('controller', captureRef.current.action, token)
return
}
if (captureRef.current) return
const combatActive = Boolean(
document.querySelector('[data-combat-active="true"]'),
)
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const directTargetActions = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
] satisfies InputAction[]
const combatPriority = [
'pause',
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
'previousTarget',
'nextTarget',
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
: combatActive && menuDpadActions[token]
? menuDpadActions[token]
: !combatActive && menuDpadActions[token]
? menuDpadActions[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
if (!action) return
if (repeat && !action.startsWith('navigate')) return
dispatchAction(action, 'controller')
}, [assignBinding, dispatchAction])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const active = document.activeElement
if (captureRef.current?.device === 'pc') {
event.preventDefault()
if (event.code === 'Escape') {
setCapture(null)
return
}
assignBinding('pc', captureRef.current.action, event.code)
return
}
if (captureRef.current || (isTextInput(active) && !keyboardInputRef.current)) return
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.pc[candidate] === event.code,
)
if (!action) return
event.preventDefault()
if (event.repeat && !action.startsWith('navigate')) return
dispatchAction(action, 'pc')
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [assignBinding, dispatchAction])
useEffect(() => {
const listener = (event: Event) => {
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
dispatchControllerToken(detail.token, Boolean(detail.repeat))
}
window.addEventListener(NATIVE_CONTROLLER_EVENT, listener)
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken])
useEffect(() => {
const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
if (
document.activeElement === document.body
&& !keyboardInputRef.current
&& !captureRef.current
) {
focusFirstControl()
}
}
const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus)
})
observer.observe(document.getElementById('root') ?? document.body, {
childList: true,
subtree: true,
})
window.requestAnimationFrame(ensureFocus)
return () => observer.disconnect()
}, [])
useEffect(() => {
let frame = 0
const poll = (time: number) => {
const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean)
const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set<string>()
const previousTokens = previousTokensRef.current
currentTokens.forEach((token) => {
const pressed = !previousTokens.has(token)
if (pressed && captureRef.current?.device === 'controller') {
assignBinding('controller', captureRef.current.action, token)
return
}
if (captureRef.current) return
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
const canRepeat = action?.startsWith('navigate') ?? false
const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125)
}
})
Object.keys(repeatRef.current).forEach((token) => {
if (!currentTokens.has(token)) delete repeatRef.current[token]
})
previousTokensRef.current = currentTokens
frame = window.requestAnimationFrame(poll)
}
frame = window.requestAnimationFrame(poll)
return () => window.cancelAnimationFrame(frame)
}, [assignBinding, dispatchControllerToken])
const contextValue = useMemo<InputContextValue>(() => ({
bindings,
capture,
lastDevice,
controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting,
beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({
...current,
[device]: { ...DEFAULT_BINDINGS[device] },
})),
setControllerIconStyle: (controllerIconStyle) => setPreferences((current) => ({
...current,
controllerIconStyle,
})),
setDirectPartyTargeting: (directPartyTargeting) => setPreferences((current) => ({
...current,
directPartyTargeting,
})),
}), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) {
if (!keyboardInput) return
const start = keyboardInput.selectionStart ?? keyboardInput.value.length
const end = keyboardInput.selectionEnd ?? start
if (key === 'backspace') {
const from = start === end ? Math.max(0, start - 1) : start
setInputValue(keyboardInput, keyboardInput.value.slice(0, from) + keyboardInput.value.slice(end))
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(from, from))
return
}
const value = key === 'space' ? ' ' : keyboardShift ? key.toUpperCase() : key
const next = keyboardInput.value.slice(0, start) + value + keyboardInput.value.slice(end)
const maxLength = keyboardInput.maxLength > 0 ? keyboardInput.maxLength : Number.POSITIVE_INFINITY
const limited = next.slice(0, maxLength)
setInputValue(keyboardInput, limited)
const cursor = Math.min(start + value.length, limited.length)
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(cursor, cursor))
}
const keyboardKeys = [
...'1234567890',
...'qwertyuiop',
...'asdfghjkl',
...'zxcvbnm',
'_', '-', '@', '.', '!', '?', '#', '$',
]
return (
<InputContext.Provider value={contextValue}>
{children}
{keyboardInput && (
<div className="controller-keyboard-backdrop" role="presentation">
<section className="controller-keyboard" aria-label="On-screen keyboard">
<div className="controller-keyboard-heading">
<div>
<p className="eyebrow">Controller Keyboard</p>
<strong>{keyboardInput.value || 'Enter text'}</strong>
</div>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
<div className="controller-keyboard-grid">
{keyboardKeys.map((key) => (
<button key={key} onClick={() => typeKeyboardKey(key)} type="button">
{keyboardShift ? key.toUpperCase() : key}
</button>
))}
</div>
<div className="controller-keyboard-actions">
<button
className={keyboardShift ? 'active' : ''}
onClick={() => setKeyboardShift((value) => !value)}
type="button"
>
Shift
</button>
<button onClick={() => typeKeyboardKey('space')} type="button">Space</button>
<button onClick={() => typeKeyboardKey('backspace')} type="button">Backspace</button>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
</section>
</div>
)}
</InputContext.Provider>
)
}
export function useInput() {
const context = useContext(InputContext)
if (!context) throw new Error('useInput must be used inside InputProvider')
return context
}
export function useGameAction(
handler: (action: InputAction, device: InputDevice) => void,
) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const listener = (event: Event) => {
const detail = (event as CustomEvent<{ action: InputAction; device: InputDevice }>).detail
handlerRef.current(detail.action, detail.device)
}
window.addEventListener(GAME_ACTION_EVENT, listener)
return () => window.removeEventListener(GAME_ACTION_EVENT, listener)
}, [])
}
export function dispatchExternalGameAction(
action: InputAction,
device: InputDevice,
) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<DualScreenBottomDisplay />
) : (
<DualScreenProvider>
<InputProvider>
<App />
</InputProvider>
</DualScreenProvider>
)}
</StrictMode>,
)
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').catch(() => {
// Offline launch remains optional when registration is unavailable.
})
})
}
+42
View File
@@ -0,0 +1,42 @@
import { Capacitor, registerPlugin } from '@capacitor/core'
export type AndroidDisplay = {
id: number
name: string
width: number
height: number
refreshRate: number
isCurrent: boolean
isPresentation: boolean
}
type DualScreenNativePlugin = {
getDisplays: () => Promise<{
currentDisplayId: number
displays: AndroidDisplay[]
}>
openTopDisplay: (options?: { displayId?: number }) => Promise<AndroidDisplay & {
opened: boolean
}>
closeTopDisplay: () => Promise<void>
}
const nativePlugin = registerPlugin<DualScreenNativePlugin>('DualScreen')
export function hasNativeDualScreenBridge() {
return Capacitor.isNativePlatform()
}
export function getNativeDisplays() {
return nativePlugin.getDisplays()
}
export function openNativeTopDisplay(displayId?: number) {
return nativePlugin.openTopDisplay(
displayId === undefined ? undefined : { displayId },
)
}
export function closeNativeTopDisplay() {
return nativePlugin.closeTopDisplay()
}
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
export type GameClass = {
id: number
slug: string
name: string
resourceName: string
maxResource: number
themeColor: string
description: string
spells: Ability[]
talents: Talent[]
}
export type Ability = {
id: number
classId: number
slug: string
name: string
spellType: string
cost: number
cooldown: number
power: number
unlockLevel: number
glyph: string
description: string
}
export type Talent = {
id: number
classId: number
slug: string
name: string
maxRank: number
tier: number
branch: number
prerequisiteTalentId: number | null
prerequisiteRank: number
prerequisiteName: string | null
effectType: string
effectValuePerRank: number
glyph: string
description: string
rank: number
}
export type EquipmentSlot =
| 'weapon'
| 'helmet'
| 'chest'
| 'gloves'
| 'boots'
| 'pants'
| 'ring'
| 'necklace'
| 'trinket'
| 'component'
export type Item = {
id: number
slug: string
name: string
slot: EquipmentSlot
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
itemLevel: number
healingPower: number
maxResourceBonus: number
glyph: string
description: string
setId?: number | null
setSlug?: string | null
setName?: string | null
quantity: number
equipped: boolean
}
export type SetBonus = {
setId: number
setSlug: string
setName: string
requiredPieces: number
effectType: 'mend_extra_target' | 'renew_extra_target' | 'mend_applies_renew' | string
description: string
equippedPieces: number
active: boolean
}
export type Difficulty = {
dungeonId: number
id: number
slug: string
name: string
droppedItemLevel: number
unlockLevel: number
healthMultiplier: number
damageMultiplier: number
experienceMultiplier: number
description: string
}
export type DungeonEncounter = {
id: number
dungeonId: number
sequence: number
slug: string
enemyName: string
encounterType: 'trash' | 'boss'
maxHealth: number
damage: number
tankDamage: number
partyDamage: number
description: string
imageUrl: string
isBoss: boolean
lootTables: LootTableEntry[]
}
export type LootTableEntry = Omit<Item, 'quantity' | 'equipped'> & {
encounterId: number
difficultyId: number
dropWeight: number
dropChance: number
}
export type CraftingRecipe = {
id: number
difficultyId: number | null
sourceDungeonId: number | null
sourceEncounterId: number | null
item: Omit<Item, 'quantity' | 'equipped'>
components: Array<{
item: Omit<Item, 'quantity' | 'equipped'>
quantity: number
owned: number
}>
canCraft: boolean
}
export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
quantity: number
duplicate: boolean
quantityAfter: number
}
export type LootRoll = {
encounterId: number
encounterName: string
difficultyId: number
difficultyName: string
dropChance: number
dropped: boolean
item: LootRollItem | null
items: LootRollItem[]
awarded: boolean
duplicate: boolean
quantityAfter: number
}
export type Dungeon = {
id: number
slug: string
name: string
recommendedLevel: number
contentType: 'dungeon' | 'raid'
partySize: number
completionItemLevel: number | null
experienceReward: number
description: string
locationName: string
difficulties: Difficulty[]
encounters: DungeonEncounter[]
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
leaderboard: LeaderboardEntry[]
leaderboards: {
part_1: LeaderboardEntry[]
part_2: LeaderboardEntry[]
part_3: LeaderboardEntry[]
full_run: LeaderboardEntry[]
}
}
export type LeaderboardEntry = {
rank: number
dungeonId: number
difficultyId: number
characterName: string
className: string
characterLevel: number
averageItemLevel: number
resourceSpent: number
durationSeconds: number
completedAt: string
}
export type CharacterProfile = {
character: {
id: number
name: string
level: number
experience: number
currentLevelExperience: number
nextLevelExperience: number
talentPoints: number
classId: number
classSlug: string
className: string
resourceName: string
maxResource: number
themeColor: string
classDescription: string
}
classes: GameClass[]
abilitySlots: Array<number | null>
maxLevel: number
maxTalentPoints: number
allocatedTalentPoints: number
equipmentSlots: EquipmentSlot[]
inventory: Item[]
completedDungeonParts: number
completedRaidPhases: number
gearStats: {
averageItemLevel: number
healingPower: number
maxResourceBonus: number
}
setBonuses: SetBonus[]
craftingRecipes: CraftingRecipe[]
dungeons: Dungeon[]
}
export type Account = {
id: number
username: string
}
export type AuthSession = {
account: Account | null
profile: CharacterProfile | null
}
export type BonusItem = {
id: number
slug: string
name: string
slot: string
rarity: string
itemLevel: number
healingPower: number
maxResourceBonus: number
glyph: string
description: string
duplicate: boolean
quantityAfter: number
}
export type DungeonReward = {
dungeonName: string
difficultyName: string
droppedItemLevel: number
experienceGained: number
previousLevel: number
newLevel: number
levelsGained: number
talentPointsGained: number
resourceSpent: number
durationSeconds: number
averageItemLevel: number
unlockedAbilities: Array<{
id: number
name: string
unlockLevel: number
glyph: string
}>
bonusItem: BonusItem | null
profile: CharacterProfile
}
import { activeGameRepository } from './gameRepository'
export function loadAuthSession(): Promise<AuthSession> {
return activeGameRepository().loadSession()
}
export function registerAccount(
username: string,
password: string,
characterName: string,
): Promise<AuthSession> {
return activeGameRepository().register(username, password, characterName)
}
export function loginAccount(
username: string,
password: string,
): Promise<AuthSession> {
return activeGameRepository().login(username, password)
}
export async function logoutAccount(): Promise<void> {
await activeGameRepository().logout()
}
export async function loadProfile(): Promise<CharacterProfile> {
return activeGameRepository().loadProfile()
}
export async function saveProfile(
classId: number,
abilitySlots: Array<number | null>,
): Promise<CharacterProfile> {
return activeGameRepository().saveProfile(classId, abilitySlots)
}
export async function completeDungeon(
dungeonId: number,
difficultyId: number,
resourceSpent: number,
durationSeconds: number,
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
): Promise<DungeonReward> {
return activeGameRepository().completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
)
}
export async function completeRoguelike(
dungeonId: number,
difficultyId: number,
encountersCleared: number,
resourceSpent: number,
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
},
): Promise<DungeonReward> {
return activeGameRepository().completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
)
}
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
return activeGameRepository().allocateTalent(talentId)
}
export async function resetTalents(): Promise<CharacterProfile> {
return activeGameRepository().resetTalents()
}
export async function equipItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().equipItem(itemId)
}
export async function discardExtraItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().discardExtraItem(itemId)
}
export async function breakdownItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().breakdownItem(itemId)
}
export async function craftItem(recipeId: number): Promise<CharacterProfile> {
return activeGameRepository().craftItem(recipeId)
}
export async function rollEncounterLoot(
encounterId: number,
difficultyId: number,
runToken: string,
): Promise<LootRoll> {
return activeGameRepository().rollEncounterLoot(
encounterId,
difficultyId,
runToken,
)
}
+46
View File
@@ -0,0 +1,46 @@
export type PvpContentType = 'dungeon' | 'raid'
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
export type CpuPvpLeaderboardEntry = {
characterName: string
className: string
contentType: PvpContentType
encountersCleared: number
cpuDifficulty: CpuDifficulty
result: 'victory' | 'defeat'
completedAt: string
}
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
export function randomCpuDifficulty(): CpuDifficulty {
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
}
export function loadCpuPvpLeaderboard(contentType?: PvpContentType): CpuPvpLeaderboardEntry[] {
try {
const raw = JSON.parse(localStorage.getItem(cpuLeaderboardKey) ?? '[]') as CpuPvpLeaderboardEntry[]
return raw
.filter((entry) => !contentType || entry.contentType === contentType)
.sort((left, right) =>
right.encountersCleared - left.encountersCleared
|| right.cpuDifficulty - left.cpuDifficulty
|| right.completedAt.localeCompare(left.completedAt),
)
.slice(0, 12)
} catch {
return []
}
}
export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
const current = loadCpuPvpLeaderboard()
const next = [...current, entry]
.sort((left, right) =>
right.encountersCleared - left.encountersCleared
|| right.cpuDifficulty - left.cpuDifficulty
|| right.completedAt.localeCompare(left.completedAt),
)
.slice(0, 30)
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
}