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
+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>
)}