Update game 1.0.27

This commit is contained in:
Warren H
2026-06-19 16:00:47 -04:00
parent 814eb1998d
commit bf12aefeeb
42 changed files with 1732 additions and 1225 deletions
+210
View File
@@ -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
View File
@@ -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
View File
@@ -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')
}}
+16 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff