Update game 1.0.27
This commit is contained in:
+210
@@ -1734,6 +1734,151 @@ h2 {
|
||||
outline-color: #a85d20;
|
||||
}
|
||||
|
||||
.run-setup-panel,
|
||||
.run-summary-card {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
margin-top: 14px;
|
||||
outline: 2px solid #3e3d47;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.run-setup-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.run-setup-heading h2,
|
||||
.run-summary-copy h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.run-setup-heading small {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.tier-grid button,
|
||||
.activity-card {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
outline: 2px solid #41404a;
|
||||
}
|
||||
|
||||
.tier-grid button {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-height: 70px;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tier-grid button:hover:not(:disabled),
|
||||
.tier-grid button.selected,
|
||||
.activity-card:hover:not(:disabled),
|
||||
.activity-card.selected {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.tier-grid button.selected,
|
||||
.activity-card.selected {
|
||||
background: #29291f;
|
||||
box-shadow: inset 0 0 0 2px #6e5727;
|
||||
}
|
||||
|
||||
.tier-grid strong,
|
||||
.tier-grid span,
|
||||
.activity-card strong,
|
||||
.activity-card small,
|
||||
.activity-card i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tier-grid strong,
|
||||
.activity-card strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.tier-grid span,
|
||||
.activity-card small,
|
||||
.activity-card i {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.tier-grid button.locked,
|
||||
.activity-card.locked {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.75);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.activity-card-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(245px, 1fr));
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px 12px;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
min-height: 112px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.activity-card .dungeon-art {
|
||||
grid-row: span 3;
|
||||
height: 68px;
|
||||
width: 68px;
|
||||
}
|
||||
|
||||
.run-summary-card {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: 92px minmax(0, 1fr) minmax(190px, 260px);
|
||||
}
|
||||
|
||||
.run-summary-copy p:not(.eyebrow) {
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.part-picker {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.part-picker .primary-button.selected-part {
|
||||
background: #f0cb79;
|
||||
outline-color: #fff;
|
||||
}
|
||||
|
||||
.part-picker .primary-button.locked {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.65);
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.dungeon-card h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -4724,6 +4869,71 @@ h2 {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.run-setup-panel,
|
||||
.run-summary-card {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.run-setup-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.run-setup-heading small {
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tier-grid button {
|
||||
min-height: 52px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.activity-card-grid {
|
||||
gap: 8px;
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
gap: 5px 10px;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
min-height: 78px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.activity-card .dungeon-art {
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.run-summary-card {
|
||||
gap: 10px;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.run-summary-card > .dungeon-art {
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.run-summary-copy p:not(.eyebrow) {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.part-picker {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.activity-select,
|
||||
.part-buttons {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
+115
-54
@@ -282,10 +282,32 @@ function App() {
|
||||
?? 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 tierOptions = activityOptions
|
||||
.flatMap((option) => option.difficulties)
|
||||
.filter((difficulty, index, all) => (
|
||||
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||
))
|
||||
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
|
||||
const savedDifficulty = profile.dungeons
|
||||
.flatMap((option) => option.difficulties)
|
||||
.find((candidate) => candidate.id === selectedDifficultyId)
|
||||
const selectedTier = tierOptions.find((candidate) => (
|
||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||
&& profile.character.level >= candidate.unlockLevel
|
||||
))
|
||||
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||
?? tierOptions[0]
|
||||
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||
const tierActivityOptions = activityOptions.filter((option) =>
|
||||
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
||||
)
|
||||
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||
?? tierActivityOptions[0]
|
||||
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||
const selectedDifficulty = activity.difficulties.find(
|
||||
(candidate) => candidate.id === selectedDifficultyId,
|
||||
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||
) ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
const completedSections = activity.contentType === 'raid'
|
||||
@@ -570,44 +592,108 @@ function App() {
|
||||
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
||||
onBack={() => setScreen('menu')}
|
||||
/>
|
||||
<article className="dungeon-card">
|
||||
<section className="run-setup-panel">
|
||||
<div className="run-setup-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Step 1</p>
|
||||
<h2>Item Level</h2>
|
||||
</div>
|
||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by character level.</small>
|
||||
</div>
|
||||
<div className="tier-grid">
|
||||
{tierOptions.map((difficulty) => {
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||
return (
|
||||
<button
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
disabled={locked}
|
||||
key={difficulty.id}
|
||||
onClick={() => {
|
||||
const nextActivity = activity.difficulties.some(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
? activity
|
||||
: activityOptions.find((option) =>
|
||||
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||
)
|
||||
if (nextActivity) {
|
||||
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||
else setSelectedDungeonId(nextActivity.id)
|
||||
const nextDifficulty = nextActivity.difficulties.find(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="run-setup-panel">
|
||||
<div className="run-setup-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Step 2</p>
|
||||
<h2>{screen === 'raids' ? 'Pick Raid' : 'Pick Dungeon'}</h2>
|
||||
</div>
|
||||
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
||||
</div>
|
||||
<div className="activity-card-grid">
|
||||
{tierActivityOptions.map((candidate) => {
|
||||
const difficulty = candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = candidate.id === activity.id
|
||||
return (
|
||||
<button
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
disabled={locked}
|
||||
key={candidate.id}
|
||||
onClick={() => {
|
||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
{activityInitials(candidate.name)}
|
||||
</span>
|
||||
<strong>{candidate.name}</strong>
|
||||
<small>{candidate.locationName}</small>
|
||||
<i>
|
||||
Level {candidate.recommendedLevel} | {candidate.partySize} Players
|
||||
</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article className="run-summary-card">
|
||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
{activityInitials(activity.name)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">{activity.locationName}</p>
|
||||
<div className="run-summary-copy">
|
||||
<p className="eyebrow">Step 3</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>iLvl {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">
|
||||
<div className="part-picker">
|
||||
{parts.map((p) => (
|
||||
<button
|
||||
key={p.part}
|
||||
@@ -616,6 +702,7 @@ function App() {
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
@@ -626,32 +713,6 @@ function App() {
|
||||
</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>
|
||||
|
||||
+188
-109
@@ -35,6 +35,7 @@ import {
|
||||
} from '../dualScreen'
|
||||
|
||||
const TICK_MS = 700
|
||||
const TARGET_RENDER_THROTTLE_MS = 180
|
||||
|
||||
type RoguelikeMode = 'dungeon' | 'raid'
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
@@ -73,6 +74,16 @@ type FloatingCombatText = {
|
||||
value: number
|
||||
}
|
||||
|
||||
type SinglePlayerCombatState = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
enemyHealth: number
|
||||
cooldowns: Record<string, number>
|
||||
elapsedTicks: number
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -340,13 +351,18 @@ export function CombatScreen({
|
||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||
const initialEncounterIndex = (startPart - 1) * 3
|
||||
const [party, setParty] = useState<PartyMember[]>(partyTemplate)
|
||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||
party: partyTemplate,
|
||||
resource: maxResource,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const [resource, setResource] = useState(maxResource)
|
||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
|
||||
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
|
||||
const [, setElapsedTicks] = useState(0)
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
@@ -360,8 +376,6 @@ export function CombatScreen({
|
||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||
const [, setCastsTowardFree] = useState(0)
|
||||
const [freeCastReady, setFreeCastReady] = useState(false)
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const profileRefreshedRef = useRef(false)
|
||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||
@@ -371,10 +385,11 @@ export function CombatScreen({
|
||||
const partStartTimesRef = useRef<Record<number, number>>({})
|
||||
const nextLogId = useRef(2)
|
||||
const nextFloatingTextId = useRef(1)
|
||||
const partyRef = useRef(partyTemplate)
|
||||
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
||||
const elapsedTicksRef = useRef(0)
|
||||
const combatRef = useRef(initialCombatState)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const selectedRenderTimeoutRef = useRef<number | null>(null)
|
||||
const lastSelectedRenderAtRef = useRef(0)
|
||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||
const encounter = encounters[encounterIndex]
|
||||
const currentPart = getCurrentPart(encounterIndex)
|
||||
const firstEncounterIndex = (startPart - 1) * 3
|
||||
@@ -416,9 +431,38 @@ export function CombatScreen({
|
||||
})
|
||||
}, [paused])
|
||||
|
||||
const setCombat = useCallback((
|
||||
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
|
||||
) => {
|
||||
const next = typeof nextState === 'function'
|
||||
? nextState(combatRef.current)
|
||||
: nextState
|
||||
combatRef.current = next
|
||||
setCombatState(next)
|
||||
}, [])
|
||||
|
||||
const setSelectedTargetId = useCallback((id: string) => {
|
||||
if (selectedIdRef.current === id) return
|
||||
selectedIdRef.current = id
|
||||
setSelectedId(id)
|
||||
const now = performance.now()
|
||||
const elapsed = now - lastSelectedRenderAtRef.current
|
||||
if (elapsed >= TARGET_RENDER_THROTTLE_MS) {
|
||||
lastSelectedRenderAtRef.current = now
|
||||
setSelectedId(id)
|
||||
return
|
||||
}
|
||||
if (selectedRenderTimeoutRef.current !== null) return
|
||||
selectedRenderTimeoutRef.current = window.setTimeout(() => {
|
||||
selectedRenderTimeoutRef.current = null
|
||||
lastSelectedRenderAtRef.current = performance.now()
|
||||
setSelectedId(selectedIdRef.current)
|
||||
}, TARGET_RENDER_THROTTLE_MS - elapsed)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (selectedRenderTimeoutRef.current !== null) {
|
||||
window.clearTimeout(selectedRenderTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
@@ -468,18 +512,19 @@ export function CombatScreen({
|
||||
: []
|
||||
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
||||
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
||||
partyRef.current = freshParty
|
||||
enemyHealthRef.current = nextEncounters[initialEncounterIndex].maxHealth
|
||||
setCombat({
|
||||
party: freshParty,
|
||||
resource: maxResource,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
})
|
||||
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
||||
setRoguelikeStage(1)
|
||||
setParty(freshParty)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
setResource(maxResource)
|
||||
setEncounterIndex(initialEncounterIndex)
|
||||
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
||||
setCooldowns({})
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
@@ -490,8 +535,6 @@ export function CombatScreen({
|
||||
setFloatingTexts([])
|
||||
setRoguelikeUpgrades([])
|
||||
setUpgradeChoices([])
|
||||
setCastsTowardFree(0)
|
||||
setFreeCastReady(false)
|
||||
rewardClaimedRef.current = false
|
||||
profileRefreshedRef.current = false
|
||||
rolledEncounterIdsRef.current = new Set()
|
||||
@@ -500,18 +543,19 @@ export function CombatScreen({
|
||||
runStartedAtRef.current = Date.now()
|
||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setSelectedTargetId, startPart, staticEncounters])
|
||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
|
||||
const castSpell = useCallback(
|
||||
(spell: Spell) => {
|
||||
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
|
||||
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
||||
const healer = partyRef.current.find((member) => member.id === 'mira')
|
||||
const current = combatRef.current
|
||||
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
|
||||
if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
|
||||
const healer = current.party.find((member) => member.id === 'mira')
|
||||
if (!healer || healer.health <= 0) return
|
||||
const targetId = selectedIdRef.current
|
||||
const selected = partyRef.current.find((member) => member.id === targetId)
|
||||
const selected = current.party.find((member) => member.id === targetId)
|
||||
if (!selected || selected.health <= 0) return
|
||||
const extraTarget = (blockedIds: string[]) => partyRef.current
|
||||
const extraTarget = (blockedIds: string[]) => current.party
|
||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||
const directTargets = new Set([targetId])
|
||||
@@ -547,28 +591,7 @@ export function CombatScreen({
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
|
||||
setResource((value) => value - effectiveCost)
|
||||
resourceSpentRef.current += effectiveCost
|
||||
setCooldowns((current) => ({
|
||||
...current,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||
}))
|
||||
if (upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') > 0) {
|
||||
if (freeCastReady) {
|
||||
setFreeCastReady(false)
|
||||
setCastsTowardFree(0)
|
||||
} else {
|
||||
setCastsTowardFree((current) => {
|
||||
const next = current + 1
|
||||
if (next >= 5) {
|
||||
setFreeCastReady(true)
|
||||
return 0
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
const nextParty = partyRef.current.map((member) => {
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||
@@ -602,11 +625,35 @@ export function CombatScreen({
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
})
|
||||
partyRef.current = nextParty
|
||||
setParty(nextParty)
|
||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
|
||||
? false
|
||||
: current.freeCastReady
|
||||
const nextCastsTowardFree = freeCastStacks > 0
|
||||
? current.freeCastReady
|
||||
? 0
|
||||
: current.castsTowardFree + 1 >= 5
|
||||
? 0
|
||||
: current.castsTowardFree + 1
|
||||
: current.castsTowardFree
|
||||
const gainedFreeCast = freeCastStacks > 0
|
||||
&& !current.freeCastReady
|
||||
&& current.castsTowardFree + 1 >= 5
|
||||
resourceSpentRef.current += effectiveCost
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||
},
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
})
|
||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||
},
|
||||
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, status],
|
||||
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
||||
)
|
||||
|
||||
const finishRun = useCallback(
|
||||
@@ -669,7 +716,7 @@ export function CombatScreen({
|
||||
)
|
||||
|
||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||
const living = partyRef.current.filter((member) => member.health > 0)
|
||||
const living = combatRef.current.party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextIndex = currentIndex < 0
|
||||
@@ -680,14 +727,14 @@ export function CombatScreen({
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const columns = dungeon.partySize >= 10 ? 6 : 3
|
||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
if (currentIndex < 0) {
|
||||
setSelectedTargetId(partyRef.current[0].id)
|
||||
setSelectedTargetId(combatRef.current.party[0].id)
|
||||
return
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / columns)
|
||||
const currentColumn = currentIndex % columns
|
||||
const candidates = partyRef.current
|
||||
const candidates = combatRef.current.party
|
||||
.map((member, index) => ({
|
||||
member,
|
||||
index,
|
||||
@@ -721,14 +768,15 @@ export function CombatScreen({
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
||||
const member = partyRef.current[index]
|
||||
const member = combatRef.current.party[index]
|
||||
if (member) setSelectedTargetId(member.id)
|
||||
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
||||
|
||||
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
||||
if (!roguelikeMode) return
|
||||
const current = combatRef.current
|
||||
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
||||
const recoveredParty = partyRef.current.map((member) => ({
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: member.health <= 0
|
||||
? 0
|
||||
@@ -747,24 +795,24 @@ export function CombatScreen({
|
||||
? nextSegment[0]
|
||||
: encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) return
|
||||
partyRef.current = recoveredParty
|
||||
enemyHealthRef.current = nextEncounter.maxHealth
|
||||
setRoguelikeUpgrades((current) => [...current, upgrade])
|
||||
if (clearedBoss) {
|
||||
setRoguelikeStage(nextStage)
|
||||
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
||||
}
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex((current) => current + 1)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setCooldowns({})
|
||||
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
elapsedTicks: 0,
|
||||
cooldowns: {},
|
||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
})
|
||||
setUpgradeChoices([])
|
||||
setStatus('playing')
|
||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage])
|
||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||
@@ -783,10 +831,10 @@ export function CombatScreen({
|
||||
if (action === 'toggleTargetGroup') {
|
||||
if (dungeon.partySize <= 6) return
|
||||
setTargetGroup((current) => {
|
||||
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
|
||||
const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6))
|
||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember) setSelectedTargetId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
@@ -809,20 +857,17 @@ export function CombatScreen({
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || paused) return
|
||||
const timer = window.setInterval(() => {
|
||||
const nextElapsedTicks = elapsedTicksRef.current + 1
|
||||
elapsedTicksRef.current = nextElapsedTicks
|
||||
setElapsedTicks(nextElapsedTicks)
|
||||
setResource((value) => clamp(value + 2.4, 0, maxResource))
|
||||
setCooldowns((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).map(([id, seconds]) => [
|
||||
id,
|
||||
Math.max(0, seconds - TICK_MS / 1000),
|
||||
]),
|
||||
),
|
||||
const current = combatRef.current
|
||||
const nextElapsedTicks = current.elapsedTicks + 1
|
||||
const nextCooldowns = Object.fromEntries(
|
||||
Object.entries(current.cooldowns).map(([id, seconds]) => [
|
||||
id,
|
||||
Math.max(0, seconds - TICK_MS / 1000),
|
||||
]),
|
||||
)
|
||||
let nextResource = clamp(current.resource + 2.4, 0, maxResource)
|
||||
|
||||
const living = partyRef.current.filter((member) => member.health > 0)
|
||||
const living = current.party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) {
|
||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||
setStatus('lost')
|
||||
@@ -854,12 +899,12 @@ export function CombatScreen({
|
||||
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
||||
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
||||
if (resourceDrain) {
|
||||
setResource((value) => clamp(value - 8, 0, maxResource))
|
||||
nextResource = clamp(nextResource - 8, 0, maxResource)
|
||||
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
||||
}
|
||||
|
||||
const healerBeforeDamage = partyRef.current.find((member) => member.id === 'mira')
|
||||
const nextParty = partyRef.current.map((member) => {
|
||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
||||
@@ -898,8 +943,6 @@ export function CombatScreen({
|
||||
}
|
||||
})
|
||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||
partyRef.current = nextParty
|
||||
setParty(nextParty)
|
||||
|
||||
if (
|
||||
healerBeforeDamage
|
||||
@@ -911,16 +954,30 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (nextParty.every((member) => member.health <= 0)) {
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: current.enemyHealth,
|
||||
})
|
||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||
setStatus('lost')
|
||||
addLog('The party has fallen.', 'danger')
|
||||
return
|
||||
}
|
||||
|
||||
const nextEnemyHealth = enemyHealthRef.current - encounter.partyDamage
|
||||
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
||||
if (nextEnemyHealth > 0) {
|
||||
enemyHealthRef.current = nextEnemyHealth
|
||||
setEnemyHealth(nextEnemyHealth)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: nextEnemyHealth,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -929,8 +986,14 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
enemyHealthRef.current = 0
|
||||
setEnemyHealth(0)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: 0,
|
||||
})
|
||||
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
||||
setStatus('upgrade-choice')
|
||||
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
||||
@@ -938,16 +1001,28 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (isPartBoss && !isFinalBoss) {
|
||||
enemyHealthRef.current = 0
|
||||
setEnemyHealth(0)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: 0,
|
||||
})
|
||||
setStatus('part-complete')
|
||||
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
||||
return
|
||||
}
|
||||
|
||||
if (encounterIndex === encounters.length - 1) {
|
||||
enemyHealthRef.current = 0
|
||||
setEnemyHealth(0)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: 0,
|
||||
})
|
||||
finishRun(currentPart, startPart)
|
||||
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
||||
return
|
||||
@@ -965,13 +1040,15 @@ export function CombatScreen({
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
}))
|
||||
partyRef.current = recoveredParty
|
||||
enemyHealthRef.current = nextEncounter.maxHealth
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: 0,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
})
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, TICK_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -994,6 +1071,7 @@ export function CombatScreen({
|
||||
gameClass.resourceName,
|
||||
requestLootRoll,
|
||||
profile.character.name,
|
||||
setCombat,
|
||||
startPart,
|
||||
status,
|
||||
currentPart,
|
||||
@@ -1398,19 +1476,20 @@ export function CombatScreen({
|
||||
const nextIndex = encounterIndex + 1
|
||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||
const nextEncounter = encounters[nextIndex]
|
||||
const recoveredParty = partyRef.current.map((member) => ({
|
||||
const current = combatRef.current
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
}))
|
||||
partyRef.current = recoveredParty
|
||||
enemyHealthRef.current = nextEncounter.maxHealth
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex(nextIndex)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}}
|
||||
|
||||
@@ -61,15 +61,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||
? profile.craftingRecipes.some((recipe) =>
|
||||
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedRecipe.item.slot
|
||||
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
||||
)
|
||||
: false
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
: undefined
|
||||
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||
? profile.craftingRecipes.find((recipe) =>
|
||||
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedItem.slot
|
||||
&& recipe.item.itemLevel === selectedItem.itemLevel + 5,
|
||||
)
|
||||
? profile.craftingRecipes
|
||||
.filter((recipe) =>
|
||||
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedItem.slot
|
||||
&& recipe.item.itemLevel > selectedItem.itemLevel,
|
||||
)
|
||||
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||
: undefined
|
||||
const equippedBySlot = useMemo(
|
||||
() => new Map(
|
||||
@@ -503,11 +512,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!selectedRecipe.canCraft || crafting}
|
||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+14
-7
@@ -760,8 +760,7 @@ function emptyCharacterData(classId: number): CharacterData {
|
||||
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 inventory: Item[] = []
|
||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||
.filter((s) => s.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
@@ -1164,6 +1163,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
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 requiresUpgrade = profile.craftingRecipes.some((candidate) =>
|
||||
candidate.sourceEncounterId === recipe.sourceEncounterId
|
||||
&& candidate.item.slot === recipe.item.slot
|
||||
&& candidate.item.itemLevel < recipe.item.itemLevel,
|
||||
)
|
||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||
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.`)
|
||||
@@ -1191,11 +1196,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
|
||||
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
|
||||
const targetRecipe = currentRecipe
|
||||
? profile.craftingRecipes.find((recipe) =>
|
||||
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === item.slot
|
||||
&& recipe.item.itemLevel === item.itemLevel + 5,
|
||||
)
|
||||
? profile.craftingRecipes
|
||||
.filter((recipe) =>
|
||||
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === item.slot
|
||||
&& recipe.item.itemLevel > item.itemLevel,
|
||||
)
|
||||
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||
: null
|
||||
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
|
||||
|
||||
+2
-1
@@ -121,6 +121,7 @@ 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'
|
||||
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
|
||||
|
||||
type CaptureState = {
|
||||
device: InputDevice
|
||||
@@ -446,7 +447,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
||||
const now = performance.now()
|
||||
if (now - lastCombatNavigationRef.current < 125) return
|
||||
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
|
||||
lastCombatNavigationRef.current = now
|
||||
}
|
||||
|
||||
|
||||
+541
-857
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user