I Want To Heal 2 web/server v1.0.0
This commit is contained in:
@@ -0,0 +1,790 @@
|
||||
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}`
|
||||
}
|
||||
Reference in New Issue
Block a user