791 lines
26 KiB
TypeScript
791 lines
26 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
loadActionMechanicConfig,
|
|
resetActionMechanicConfig,
|
|
saveActionMechanicConfig,
|
|
type ActionAttackConfig,
|
|
type ActionMechanicConfig,
|
|
} from '../actionBoss/actionEncounterConfig'
|
|
import type { EnemyKind } from '../actionBoss/bulldromeSimulation'
|
|
import {
|
|
ACTION_DIFFICULTY_TIERS,
|
|
ACTION_GEAR_SLOTS,
|
|
ACTION_GEAR_UPGRADE_COST,
|
|
completeActionDungeonHunt,
|
|
getActionCoinCount,
|
|
getActionDifficultyTier,
|
|
getActionGearStats,
|
|
getActionLevelProgress,
|
|
getUpgradeCoinCount,
|
|
getUpgradeCoinName,
|
|
loadActionCharacter,
|
|
saveActionCharacter,
|
|
upgradeBulldromeGear,
|
|
type ActionCharacter,
|
|
type ActionDifficulty,
|
|
type ActionDungeonId,
|
|
type ActionGearPiece,
|
|
type ActionGearSlot,
|
|
type ActionGearSource,
|
|
type ActionRunMode,
|
|
type ActionRunReward,
|
|
} from '../actionMode'
|
|
import { BulldromeBossSlice } from './BulldromeBossSlice'
|
|
|
|
type ActionModeScreenProps = {
|
|
onBack?: () => void
|
|
}
|
|
|
|
type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings'
|
|
type ActionHubScreen = 'menu' | ActionHubTab
|
|
type PlayableActionDungeonId = Exclude<ActionDungeonId, 'rathian'>
|
|
|
|
const ACTION_HUB_ITEMS: Array<{
|
|
id: ActionHubTab
|
|
label: string
|
|
glyph: string
|
|
description: string
|
|
}> = [
|
|
{ id: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Run action dungeons and earn gear.' },
|
|
{ id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' },
|
|
{ id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' },
|
|
{ id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' },
|
|
{ id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' },
|
|
{ id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' },
|
|
]
|
|
|
|
export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
|
|
const [character, setCharacter] = useState<ActionCharacter>(() => loadActionCharacter())
|
|
const [activeDifficulty, setActiveDifficulty] = useState<ActionDifficulty | null>(null)
|
|
const [activeDungeonId, setActiveDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
|
|
const [activeRunMode, setActiveRunMode] = useState<ActionRunMode>('hunt')
|
|
const [activeScreen, setActiveScreen] = useState<ActionHubScreen>('menu')
|
|
const [lastReward, setLastReward] = useState<ActionRunReward | null>(null)
|
|
const [runKey, setRunKey] = useState(0)
|
|
const [message, setMessage] = useState('')
|
|
const progress = useMemo(() => getActionLevelProgress(character), [character])
|
|
|
|
useEffect(() => {
|
|
saveActionCharacter(character)
|
|
}, [character])
|
|
|
|
if (activeDifficulty) {
|
|
return (
|
|
<div className="action-run-shell">
|
|
<BulldromeBossSlice
|
|
dungeonId={activeDungeonId}
|
|
difficulty={activeDifficulty}
|
|
runMode={activeRunMode}
|
|
key={`${activeDungeonId}-${activeDifficulty}-${activeRunMode}-${runKey}`}
|
|
onExit={() => {
|
|
setLastReward(null)
|
|
setActiveDifficulty(null)
|
|
setActiveScreen('dungeons')
|
|
}}
|
|
onRunComplete={() => {
|
|
if (lastReward && activeRunMode === 'hunt') return
|
|
const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty)
|
|
setCharacter(nextCharacter)
|
|
if (activeRunMode === 'hunt') setLastReward(reward)
|
|
setMessage('')
|
|
}}
|
|
/>
|
|
{lastReward && (
|
|
<RunRewardModal
|
|
dungeonId={activeDungeonId}
|
|
difficulty={activeDifficulty}
|
|
reward={lastReward}
|
|
onGoAgain={() => {
|
|
setLastReward(null)
|
|
setRunKey((current) => current + 1)
|
|
}}
|
|
onMainMenu={() => {
|
|
setLastReward(null)
|
|
setActiveDifficulty(null)
|
|
setActiveScreen('menu')
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="game-shell action-mode-shell">
|
|
<section className="content-screen action-mode-screen">
|
|
<div className="screen-heading action-screen-heading">
|
|
<div>
|
|
<p className="eyebrow">Action Mode</p>
|
|
<h1>{getHubTitle(activeScreen)}</h1>
|
|
</div>
|
|
<div className="action-heading-meta">
|
|
<div className="action-character-strip">
|
|
<strong>{character.name}</strong>
|
|
<small>Healer</small>
|
|
<small>Level {character.level}</small>
|
|
<div className="header-xp" title={`${character.experience} action experience`}>
|
|
<span style={{ width: `${progress.percent}%` }} />
|
|
</div>
|
|
</div>
|
|
{(activeScreen !== 'menu' || onBack) && (
|
|
<button
|
|
className="back-button"
|
|
onClick={() => {
|
|
if (activeScreen === 'menu') onBack?.()
|
|
else setActiveScreen('menu')
|
|
}}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{activeScreen === 'menu' && (
|
|
<nav className="action-hub-nav" aria-label="Action mode sections">
|
|
{ACTION_HUB_ITEMS.map((item) => (
|
|
<button
|
|
className="menu-card"
|
|
key={item.id}
|
|
onClick={() => setActiveScreen(item.id)}
|
|
type="button"
|
|
>
|
|
<span>{item.glyph}</span>
|
|
<div>
|
|
<strong>{item.label}</strong>
|
|
<small>{item.description}</small>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
{message && <p className="action-mode-message">{message}</p>}
|
|
|
|
{activeScreen === 'dungeons' && (
|
|
<DungeonsPanel
|
|
character={character}
|
|
onStart={(dungeonId, difficulty) => {
|
|
setActiveRunMode('hunt')
|
|
setLastReward(null)
|
|
setActiveDungeonId(dungeonId)
|
|
setRunKey((current) => current + 1)
|
|
setActiveDifficulty(difficulty)
|
|
}}
|
|
onStartMarathon={(dungeonId, difficulty) => {
|
|
setActiveRunMode('marathon')
|
|
setLastReward(null)
|
|
setActiveDungeonId(dungeonId)
|
|
setRunKey((current) => current + 1)
|
|
setActiveDifficulty(difficulty)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{activeScreen === 'customize' && (
|
|
<CustomizePanel
|
|
character={character}
|
|
onUpgrade={(itemId) => {
|
|
const before = character.inventory.find((item) => item.id === itemId)
|
|
const nextCharacter = upgradeBulldromeGear(character, itemId)
|
|
const coinCount = before ? getUpgradeCoinCount(character, before) : 0
|
|
setCharacter(nextCharacter)
|
|
setMessage(
|
|
before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST
|
|
? `${before.name} upgraded to item level ${before.itemLevel + 1}.`
|
|
: 'Upgrade unavailable.',
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{activeScreen === 'settings' && (
|
|
<ActionMechanicsAdmin />
|
|
)}
|
|
|
|
{activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && (
|
|
<section className="action-placeholder-panel">
|
|
<p className="eyebrow">{getHubTitle(activeScreen)}</p>
|
|
<h2>Coming Soon</h2>
|
|
<p>This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.</p>
|
|
</section>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function ActionMechanicsAdmin() {
|
|
const [config, setConfig] = useState<ActionMechanicConfig>(() => loadActionMechanicConfig())
|
|
const [enemyPage, setEnemyPage] = useState(0)
|
|
const [attackPageByEnemy, setAttackPageByEnemy] = useState<Partial<Record<EnemyKind, number>>>({})
|
|
|
|
function updateAttack(enemyKind: EnemyKind, attackId: string, patch: Partial<ActionAttackConfig>) {
|
|
const next = {
|
|
...config,
|
|
[enemyKind]: {
|
|
...config[enemyKind],
|
|
attacks: config[enemyKind].attacks.map((attack) => (
|
|
attack.id === attackId ? { ...attack, ...patch } : attack
|
|
)),
|
|
},
|
|
}
|
|
setConfig(next)
|
|
saveActionMechanicConfig(next)
|
|
}
|
|
|
|
function resetConfig() {
|
|
setConfig(resetActionMechanicConfig())
|
|
setEnemyPage(0)
|
|
setAttackPageByEnemy({})
|
|
}
|
|
|
|
const enemyKinds = Object.keys(config) as EnemyKind[]
|
|
const enemyKind = enemyKinds[Math.min(enemyPage, enemyKinds.length - 1)] ?? enemyKinds[0]
|
|
const enemy = config[enemyKind]
|
|
const enabledAttacks = enemy.attacks.filter((attack) => attack.enabled)
|
|
const disabledAttacks = enemy.attacks.filter((attack) => !attack.enabled)
|
|
const attackPage = Math.min(attackPageByEnemy[enemyKind] ?? 0, Math.max(0, enabledAttacks.length - 1))
|
|
const activeAttack = enabledAttacks[attackPage] ?? null
|
|
|
|
function setAttackPage(enemyKind: EnemyKind, page: number) {
|
|
setAttackPageByEnemy((current) => ({ ...current, [enemyKind]: page }))
|
|
}
|
|
|
|
return (
|
|
<section className="action-mechanics-admin">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Admin</p>
|
|
<h2>Mob And Boss Mechanics</h2>
|
|
</div>
|
|
<button className="back-button" onClick={resetConfig} type="button">Reset Defaults</button>
|
|
</header>
|
|
|
|
<article className="action-mechanic-card" key={enemy.kind}>
|
|
<div className="action-mechanic-heading">
|
|
<div>
|
|
<p className="eyebrow">{enemy.role}</p>
|
|
<h3>{enemy.label}</h3>
|
|
</div>
|
|
<span>{enemyPage + 1} / {enemyKinds.length}</span>
|
|
</div>
|
|
|
|
<div className="action-page-controls" aria-label="Mechanic pages">
|
|
<button onClick={() => setEnemyPage((page) => Math.max(0, page - 1))} disabled={enemyPage === 0} type="button">
|
|
Prev Mob
|
|
</button>
|
|
<button
|
|
onClick={() => setEnemyPage((page) => Math.min(enemyKinds.length - 1, page + 1))}
|
|
disabled={enemyPage >= enemyKinds.length - 1}
|
|
type="button"
|
|
>
|
|
Next Mob
|
|
</button>
|
|
</div>
|
|
|
|
{activeAttack ? (
|
|
<div className="action-attack-list">
|
|
<div className="action-page-controls" aria-label="Attack pages">
|
|
<button onClick={() => setAttackPage(enemyKind, Math.max(0, attackPage - 1))} disabled={attackPage === 0} type="button">
|
|
Prev Attack
|
|
</button>
|
|
<span>{attackPage + 1} / {enabledAttacks.length}</span>
|
|
<button
|
|
onClick={() => setAttackPage(enemyKind, Math.min(enabledAttacks.length - 1, attackPage + 1))}
|
|
disabled={attackPage >= enabledAttacks.length - 1}
|
|
type="button"
|
|
>
|
|
Next Attack
|
|
</button>
|
|
</div>
|
|
<AttackEditor
|
|
attack={activeAttack}
|
|
key={activeAttack.id}
|
|
onChange={(patch) => updateAttack(enemyKind, activeAttack.id, patch)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<p className="action-empty-note">No active attacks.</p>
|
|
)}
|
|
|
|
{disabledAttacks.length > 0 && (
|
|
<div className="action-disabled-attacks">
|
|
<strong>Add Attack</strong>
|
|
{disabledAttacks.map((attack) => (
|
|
<button
|
|
key={attack.id}
|
|
onClick={() => {
|
|
updateAttack(enemyKind, attack.id, { enabled: true })
|
|
setAttackPage(enemyKind, enabledAttacks.length)
|
|
}}
|
|
type="button"
|
|
>
|
|
{attack.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</article>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function AttackEditor({
|
|
attack,
|
|
onChange,
|
|
}: {
|
|
attack: ActionAttackConfig
|
|
onChange: (patch: Partial<ActionAttackConfig>) => void
|
|
}) {
|
|
return (
|
|
<section className="action-attack-editor">
|
|
<header>
|
|
<div>
|
|
<strong>{attack.label}</strong>
|
|
<small>{attack.kind}</small>
|
|
</div>
|
|
<button onClick={() => onChange({ enabled: false })} type="button">Remove</button>
|
|
</header>
|
|
|
|
<div className="action-attack-fields">
|
|
<label>
|
|
Frequency
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ frequencySeconds: Number(event.target.value) })}
|
|
step="0.05"
|
|
type="number"
|
|
value={attack.frequencySeconds}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Damage
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ damage: Number(event.target.value) })}
|
|
step="1"
|
|
type="number"
|
|
value={attack.damage}
|
|
/>
|
|
</label>
|
|
{attack.windupSeconds !== undefined && (
|
|
<label>
|
|
Windup
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ windupSeconds: Number(event.target.value) })}
|
|
step="0.05"
|
|
type="number"
|
|
value={attack.windupSeconds}
|
|
/>
|
|
</label>
|
|
)}
|
|
{attack.recoverSeconds !== undefined && (
|
|
<label>
|
|
Recover
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ recoverSeconds: Number(event.target.value) })}
|
|
step="0.05"
|
|
type="number"
|
|
value={attack.recoverSeconds}
|
|
/>
|
|
</label>
|
|
)}
|
|
{attack.speed !== undefined && (
|
|
<label>
|
|
Speed
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ speed: Number(event.target.value) })}
|
|
step="10"
|
|
type="number"
|
|
value={attack.speed}
|
|
/>
|
|
</label>
|
|
)}
|
|
{attack.radius !== undefined && (
|
|
<label>
|
|
Radius
|
|
<input
|
|
min="0"
|
|
onChange={(event) => onChange({ radius: Number(event.target.value) })}
|
|
step="1"
|
|
type="number"
|
|
value={attack.radius}
|
|
/>
|
|
</label>
|
|
)}
|
|
{attack.everyNthCharge !== undefined && (
|
|
<label>
|
|
Every Charges
|
|
<input
|
|
min="1"
|
|
onChange={(event) => onChange({ everyNthCharge: Number(event.target.value) })}
|
|
step="1"
|
|
type="number"
|
|
value={attack.everyNthCharge}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DungeonsPanel({
|
|
character,
|
|
onStart,
|
|
onStartMarathon,
|
|
}: {
|
|
character: ActionCharacter
|
|
onStart: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
|
|
onStartMarathon: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
|
|
}) {
|
|
const [selectedDungeonId, setSelectedDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
|
|
const [selectedDifficulty, setSelectedDifficulty] = useState<ActionDifficulty>('ilvl-1')
|
|
const selectedDungeon = ACTION_DUNGEONS.find((dungeon) => dungeon.id === selectedDungeonId) ?? ACTION_DUNGEONS[0]
|
|
const selectedTier = getActionDifficultyTier(selectedDifficulty)
|
|
const selectedCoinCount = getActionCoinCount(character, selectedDungeon.source, selectedTier.coinColor)
|
|
|
|
return (
|
|
<div className="action-dungeon-board">
|
|
<section className="action-dungeon-list" aria-label="Action dungeons">
|
|
{ACTION_DUNGEONS.map((dungeon) => {
|
|
const selected = dungeon.id === selectedDungeonId
|
|
const locked = dungeon.locked
|
|
return (
|
|
<button
|
|
className={`action-dungeon-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
|
disabled={locked}
|
|
key={dungeon.id}
|
|
onClick={() => {
|
|
if (!locked) setSelectedDungeonId(dungeon.id as PlayableActionDungeonId)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span className="action-dungeon-glyph">{dungeon.glyph}</span>
|
|
<span>
|
|
<small>{dungeon.eyebrow}</small>
|
|
<strong>{dungeon.name}</strong>
|
|
<i>{dungeon.summary}</i>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</section>
|
|
|
|
<aside className="action-dungeon-setup">
|
|
<section className="action-dungeon-selected">
|
|
<p className="eyebrow">Selected Run</p>
|
|
<h2>{selectedDungeon.name}</h2>
|
|
<p>{selectedDungeon.description}</p>
|
|
<div className="tag-row">
|
|
<span>{selectedTier.label}</span>
|
|
<span>{selectedTier.healthMultiplier}x HP</span>
|
|
<span>{selectedTier.damageMultiplier}x damage</span>
|
|
<span>{selectedTier.coinLabel}</span>
|
|
</div>
|
|
<dl>
|
|
<div><dt>Tier Coins</dt><dd>{selectedCoinCount}</dd></div>
|
|
<div><dt>Gear Drop</dt><dd>iLvl {selectedTier.itemLevel}</dd></div>
|
|
<div><dt>XP</dt><dd>{selectedTier.experience}</dd></div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="action-dungeon-tier">
|
|
<div>
|
|
<p className="eyebrow">Item Level</p>
|
|
<h2>Tier</h2>
|
|
</div>
|
|
<div className="action-tier-grid">
|
|
{ACTION_DIFFICULTY_TIERS.map((tier) => (
|
|
<button
|
|
className={`${selectedDifficulty === tier.id ? 'selected' : ''} coin-${tier.coinColor}`}
|
|
key={tier.id}
|
|
onClick={() => setSelectedDifficulty(tier.id)}
|
|
type="button"
|
|
>
|
|
<strong>{tier.label}</strong>
|
|
<span>{tier.coinLabel}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="action-dungeon-start">
|
|
<div>
|
|
<p className="eyebrow">Start</p>
|
|
<h2>Run</h2>
|
|
</div>
|
|
<div className="action-dungeon-actions">
|
|
<button className="primary-button" onClick={() => onStart(selectedDungeonId, selectedDifficulty)} type="button">
|
|
Start Hunt
|
|
</button>
|
|
<button className="primary-button" onClick={() => onStartMarathon(selectedDungeonId, selectedDifficulty)} type="button">
|
|
Start Marathon
|
|
</button>
|
|
</div>
|
|
<p>Marathon respawns the boss and extra mobs after a 5 second break.</p>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ACTION_DUNGEONS: Array<{
|
|
id: ActionDungeonId
|
|
eyebrow: string
|
|
glyph: string
|
|
name: string
|
|
summary: string
|
|
description: string
|
|
source: ActionGearSource
|
|
coinLabel: string
|
|
locked?: boolean
|
|
}> = [
|
|
{
|
|
id: 'bulldrome',
|
|
eyebrow: 'Dungeon 1',
|
|
glyph: 'B',
|
|
name: 'Bulldrome Hunting Grounds',
|
|
summary: 'Charges, slams, and Bullfango pressure.',
|
|
description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.',
|
|
source: 'bulldrome',
|
|
coinLabel: 'White Bulldrome Coins',
|
|
},
|
|
{
|
|
id: 'yian-kut-ku',
|
|
eyebrow: 'Dungeon 2',
|
|
glyph: 'Y',
|
|
name: 'Yian Kut-Ku Roost',
|
|
summary: 'Bouncing fireballs and dive birds.',
|
|
description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.',
|
|
source: 'yian-kut-ku',
|
|
coinLabel: 'White Yian Kut-Ku Coins',
|
|
},
|
|
{
|
|
id: 'rathian',
|
|
eyebrow: 'Dungeon 3',
|
|
glyph: 'R',
|
|
name: 'Rathian Nest',
|
|
summary: 'Coming next.',
|
|
description: 'Rathian mechanics will be built after Yian Kut-Ku.',
|
|
source: 'bulldrome',
|
|
coinLabel: 'Rathian Coins',
|
|
locked: true,
|
|
},
|
|
]
|
|
|
|
function RunRewardModal({
|
|
dungeonId,
|
|
difficulty,
|
|
onGoAgain,
|
|
onMainMenu,
|
|
reward,
|
|
}: {
|
|
dungeonId: PlayableActionDungeonId
|
|
difficulty: ActionDifficulty
|
|
onGoAgain: () => void
|
|
onMainMenu: () => void
|
|
reward: ActionRunReward
|
|
}) {
|
|
return (
|
|
<div className="action-run-reward-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-run-reward-title">
|
|
<section className="action-run-reward-modal">
|
|
<p className="eyebrow">Dungeon Complete</p>
|
|
<h1 id="action-run-reward-title">
|
|
{getRunRewardTitle(dungeonId, difficulty)}
|
|
</h1>
|
|
<div className="action-run-reward-summary">
|
|
<div>
|
|
<dt>XP</dt>
|
|
<dd>{reward.experience}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{reward.coinName}</dt>
|
|
<dd>{reward.coins}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Level</dt>
|
|
<dd>{reward.leveledUp ? 'Up' : 'No Change'}</dd>
|
|
</div>
|
|
</div>
|
|
<div className="action-run-loot-list">
|
|
<strong>Loot</strong>
|
|
{reward.gear.length === 0 ? (
|
|
<p>No gear dropped.</p>
|
|
) : (
|
|
reward.gear.map((item) => (
|
|
<span key={item.id}>{item.name} · ilvl {item.itemLevel}</span>
|
|
))
|
|
)}
|
|
</div>
|
|
<div className="action-run-reward-actions">
|
|
<button className="primary-button" onClick={onGoAgain} type="button">Go Again</button>
|
|
<button className="back-button" onClick={onMainMenu} type="button">Main Menu</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomizePanel({
|
|
character,
|
|
onUpgrade,
|
|
}: {
|
|
character: ActionCharacter
|
|
onUpgrade: (itemId: string) => void
|
|
}) {
|
|
const [selectedSlot, setSelectedSlot] = useState<ActionGearSlot>('weapon')
|
|
const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0]
|
|
const filteredItems = useMemo(() => (
|
|
character.inventory
|
|
.filter((item) => item.slot === selectedSlot)
|
|
.sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name))
|
|
), [character.inventory, selectedSlot])
|
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
|
|
const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null
|
|
|
|
return (
|
|
<section className="action-gear-panel">
|
|
<article className="action-gear-summary">
|
|
<p className="eyebrow">Craft Table</p>
|
|
<h2>Bulldrome Gear</h2>
|
|
<p>
|
|
Pick a gear slot, inspect that slot inventory, then preview the upgrade
|
|
cost and stat gain before spending coins.
|
|
</p>
|
|
</article>
|
|
|
|
<div className="action-customize-layout">
|
|
<div className="action-slot-grid" aria-label="Gear slots">
|
|
{ACTION_GEAR_SLOTS.map((slot) => {
|
|
const count = character.inventory.filter((item) => item.slot === slot.slot).length
|
|
return (
|
|
<button
|
|
className={selectedSlot === slot.slot ? 'selected' : ''}
|
|
key={slot.slot}
|
|
onClick={() => {
|
|
setSelectedSlot(slot.slot)
|
|
setSelectedItemId(null)
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>{slot.glyph}</span>
|
|
<strong>{slot.label}</strong>
|
|
<small>{count} owned</small>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<aside className="action-inventory-panel">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Inventory</p>
|
|
<h2>{slotMeta.label}</h2>
|
|
</div>
|
|
<span>{selectedItem ? `${getUpgradeCoinCount(character, selectedItem)} Coins` : `${character.bulldromeCoins} / ${character.yianKutKuCoins} Coins`}</span>
|
|
</header>
|
|
|
|
<div className="action-inventory-list">
|
|
{filteredItems.length === 0 ? (
|
|
<p>No {slotMeta.label} pieces yet.</p>
|
|
) : (
|
|
filteredItems.map((item) => (
|
|
<button
|
|
className={selectedItem?.id === item.id ? 'selected' : ''}
|
|
key={item.id}
|
|
onClick={() => setSelectedItemId(item.id)}
|
|
type="button"
|
|
>
|
|
<strong>{item.name}</strong>
|
|
<small>ilvl {item.itemLevel}</small>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<GearDetail
|
|
coins={selectedItem ? getUpgradeCoinCount(character, selectedItem) : 0}
|
|
item={selectedItem}
|
|
onUpgrade={() => {
|
|
if (selectedItem) onUpgrade(selectedItem.id)
|
|
}}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function GearDetail({
|
|
coins,
|
|
item,
|
|
onUpgrade,
|
|
}: {
|
|
coins: number
|
|
item: ActionGearPiece | null
|
|
onUpgrade: () => void
|
|
}) {
|
|
if (!item) {
|
|
return (
|
|
<section className="action-gear-detail empty">
|
|
<p>Select a slot with gear to see stats and upgrade costs.</p>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const currentStats = getActionGearStats(item)
|
|
const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) }
|
|
const nextStats = getActionGearStats(nextItem)
|
|
const coinName = getUpgradeCoinName(item)
|
|
const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5
|
|
|
|
return (
|
|
<section className="action-gear-detail">
|
|
<header>
|
|
<div>
|
|
<p className="eyebrow">Selected</p>
|
|
<h2>{item.name}</h2>
|
|
</div>
|
|
<span>ilvl {item.itemLevel}</span>
|
|
</header>
|
|
<dl className="action-stat-compare">
|
|
<div>
|
|
<dt>Healing</dt>
|
|
<dd>{currentStats.healingPower} → {nextStats.healingPower}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Stamina</dt>
|
|
<dd>{currentStats.stamina} → {nextStats.stamina}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Upgrade Cost</dt>
|
|
<dd>{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>You Have</dt>
|
|
<dd>{coins} {coinName}</dd>
|
|
</div>
|
|
</dl>
|
|
<button disabled={!canUpgrade} onClick={onUpgrade} type="button">
|
|
{item.itemLevel >= 5 ? 'Max Level' : `Upgrade to ilvl ${item.itemLevel + 1}`}
|
|
</button>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function getHubTitle(screen: ActionHubScreen) {
|
|
if (screen === 'menu') return 'Action Mode'
|
|
return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode'
|
|
}
|
|
|
|
function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) {
|
|
const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt'
|
|
return `${getActionDifficultyTier(difficulty).label} ${dungeonName}`
|
|
}
|