Android build v1.0.32
This commit is contained in:
+217
-58
@@ -113,6 +113,43 @@ function healMember(member: PartyMember, amount: number) {
|
||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
return [
|
||||
...memberHotEffects(member),
|
||||
{
|
||||
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
power: Math.max(1, Math.round(spell.power / 2)),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||
return (effects ?? [])
|
||||
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||
.filter((effect) => effect.ticks > 0)
|
||||
}
|
||||
|
||||
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
||||
return upgrades.filter((upgrade) => upgrade.id === id).length
|
||||
}
|
||||
@@ -195,9 +232,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
|
||||
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
||||
const kinds: Record<string, Spell['kind']> = {
|
||||
direct_heal: 'direct',
|
||||
direct_hot: 'direct',
|
||||
heal_over_time: 'hot',
|
||||
party_heal: 'group',
|
||||
party_hot: 'group',
|
||||
party_absorb: 'group',
|
||||
absorb: 'shield',
|
||||
damage_reduction: 'damage_reduction',
|
||||
bounce_heal: 'bounce_heal',
|
||||
cleanse: 'cleanse',
|
||||
}
|
||||
return {
|
||||
@@ -210,6 +252,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
|
||||
power: ability.power + healingPower,
|
||||
glyph: ability.glyph,
|
||||
kind: kinds[ability.spellType] ?? 'direct',
|
||||
effectType: ability.spellType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +336,7 @@ function makeRoguelikeSegment(
|
||||
export function CombatScreen({
|
||||
difficulty,
|
||||
dungeon,
|
||||
hardMode = false,
|
||||
profile,
|
||||
startPart = 1,
|
||||
roguelikeMode,
|
||||
@@ -304,6 +348,7 @@ export function CombatScreen({
|
||||
}: {
|
||||
difficulty: Difficulty
|
||||
dungeon: Dungeon
|
||||
hardMode?: boolean
|
||||
profile: CharacterProfile
|
||||
startPart?: number
|
||||
roguelikeMode?: RoguelikeMode
|
||||
@@ -354,15 +399,16 @@ 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 enemyCount = hardMode ? 2 : 1
|
||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||
party: partyTemplate,
|
||||
resource: maxResource,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
|
||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||
@@ -381,7 +427,7 @@ export function CombatScreen({
|
||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const profileRefreshedRef = useRef(false)
|
||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||
const runTokenRef = useRef(crypto.randomUUID())
|
||||
const resourceSpentRef = useRef(0)
|
||||
const runStartedAtRef = useRef(0)
|
||||
@@ -397,12 +443,17 @@ export function CombatScreen({
|
||||
const pausedRef = useRef(paused)
|
||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||
const encounter = encounters[encounterIndex]
|
||||
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||
const currentPart = getCurrentPart(encounterIndex)
|
||||
const completedSections = dungeon.contentType === 'raid'
|
||||
? profile.completedRaidPhases
|
||||
: profile.completedDungeonParts
|
||||
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
|
||||
const firstEncounterIndex = (startPart - 1) * 3
|
||||
const expectedLootRolls = encounters
|
||||
.slice(firstEncounterIndex, encounterIndex + 1)
|
||||
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
||||
.length
|
||||
.length * enemyCount
|
||||
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
||||
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
||||
const playerHealer = party.find((member) => member.id === 'mira')
|
||||
@@ -484,10 +535,12 @@ export function CombatScreen({
|
||||
}, [])
|
||||
|
||||
const requestLootRoll = useCallback(
|
||||
(encounterId: number) => {
|
||||
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
||||
rolledEncounterIdsRef.current.add(encounterId)
|
||||
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
||||
(encounterId: number, rollIndex = 0) => {
|
||||
const rollKey = `${encounterId}:${rollIndex}`
|
||||
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
||||
rolledEncounterIdsRef.current.add(rollKey)
|
||||
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
|
||||
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
||||
.then((result) => {
|
||||
setLootRolls((current) => [...current, result])
|
||||
const awarded = result.items
|
||||
@@ -519,7 +572,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
party: freshParty,
|
||||
resource: maxResource,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
@@ -547,7 +600,7 @@ 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, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
|
||||
const castSpell = useCallback(
|
||||
(spell: Spell) => {
|
||||
@@ -571,7 +624,7 @@ export function CombatScreen({
|
||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||
: [],
|
||||
)
|
||||
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
||||
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||
const extra = extraTarget([targetId])
|
||||
@@ -604,16 +657,38 @@ export function CombatScreen({
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
if (spell.effectType === 'party_absorb') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, power) }
|
||||
}
|
||||
if (spell.effectType === 'party_hot') {
|
||||
return {
|
||||
...member,
|
||||
hotTicks: 0,
|
||||
hotEffects: addHotEffect(member, spell),
|
||||
}
|
||||
}
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, power)
|
||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||
return { ...member, health: nextHealth }
|
||||
}
|
||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||
if (
|
||||
!directTargets.has(member.id)
|
||||
&& !hotTargets.has(member.id)
|
||||
&& !shieldTargets.has(member.id)
|
||||
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
|
||||
) return member
|
||||
if (spell.kind === 'shield') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, power) }
|
||||
}
|
||||
if (spell.kind === 'damage_reduction') {
|
||||
return { ...member, damageReductionTicks: 12 }
|
||||
}
|
||||
if (spell.kind === 'bounce_heal') {
|
||||
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||
}
|
||||
if (spell.kind === 'cleanse') {
|
||||
return {
|
||||
...member,
|
||||
@@ -632,7 +707,8 @@ export function CombatScreen({
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
hotTicks: 0,
|
||||
hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects,
|
||||
}
|
||||
})
|
||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||
@@ -686,6 +762,7 @@ export function CombatScreen({
|
||||
completedPart,
|
||||
runStartPart,
|
||||
[partDuration(1), partDuration(2), partDuration(3)],
|
||||
hardMode,
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
@@ -698,7 +775,7 @@ export function CombatScreen({
|
||||
)
|
||||
})
|
||||
},
|
||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
||||
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||
)
|
||||
|
||||
const finishRoguelikeRun = useCallback(
|
||||
@@ -796,6 +873,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
||||
const nextSegment = clearedBoss
|
||||
@@ -814,7 +894,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
cooldowns: {},
|
||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
@@ -822,7 +902,7 @@ export function CombatScreen({
|
||||
setUpgradeChoices([])
|
||||
setStatus('playing')
|
||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||
@@ -914,7 +994,11 @@ export function CombatScreen({
|
||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||
const tankPressure = tankPressureTargets(current.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const nextParty = current.party.map((member) => {
|
||||
const pendingJumpHeals: Array<{
|
||||
targetId: string
|
||||
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||
}> = []
|
||||
const damagedParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||
if (tankPressureIds.has(member.id)) {
|
||||
@@ -929,8 +1013,28 @@ export function CombatScreen({
|
||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
||||
damage *= enemyCount
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
damage = Math.round(damage * 0.5)
|
||||
}
|
||||
const absorbed = Math.min(member.shield, damage)
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
|
||||
const hotEffects = memberHotEffects(member)
|
||||
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power), 0)
|
||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
|
||||
pendingJumpHeals.push({
|
||||
targetId: jumpTarget.id,
|
||||
heal: { ...effect, charges: nextCharges },
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
if (healing > 0) addFloatingHeal(member.id, healing)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 15
|
||||
@@ -944,9 +1048,12 @@ export function CombatScreen({
|
||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||
return {
|
||||
...member,
|
||||
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
|
||||
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
|
||||
shield: Math.max(0, member.shield - damage),
|
||||
hotTicks: Math.max(0, member.hotTicks - 1),
|
||||
hotTicks: 0,
|
||||
hotEffects: tickHotEffects(hotEffects),
|
||||
bounceHeals: nextBounceHeals,
|
||||
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
|
||||
debuff: nextDebuffTicks > 0
|
||||
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
||||
: undefined,
|
||||
@@ -956,6 +1063,17 @@ export function CombatScreen({
|
||||
healingReductionTicks: nextHealingReductionTicks,
|
||||
}
|
||||
})
|
||||
const nextParty = damagedParty.map((member) => {
|
||||
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
|
||||
if (jumped.length === 0) return member
|
||||
return {
|
||||
...member,
|
||||
bounceHeals: [
|
||||
...(member.bounceHeals ?? []),
|
||||
...jumped.map((jump) => jump.heal),
|
||||
],
|
||||
}
|
||||
})
|
||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||
|
||||
if (
|
||||
@@ -996,7 +1114,9 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
||||
requestLootRoll(encounter.id)
|
||||
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
|
||||
requestLootRoll(encounter.id, rollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
@@ -1053,6 +1173,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setCombat({
|
||||
@@ -1061,13 +1184,14 @@ export function CombatScreen({
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: 0,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
})
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [
|
||||
addLog,
|
||||
addFloatingHeal,
|
||||
difficulty.damageMultiplier,
|
||||
enemyCount,
|
||||
encounter,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
@@ -1136,15 +1260,23 @@ export function CombatScreen({
|
||||
})
|
||||
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
||||
|
||||
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
|
||||
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
|
||||
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
|
||||
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
|
||||
return {
|
||||
index,
|
||||
health: remaining,
|
||||
percent: (remaining / encounter.maxHealth) * 100,
|
||||
}
|
||||
}).reverse()
|
||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||
difficultyName: difficulty.name,
|
||||
dungeonName: dungeon.name,
|
||||
dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
|
||||
contentName,
|
||||
encounterName: encounter.enemyName,
|
||||
encounterDescription: encounter.description,
|
||||
encounterHealth: enemyHealth,
|
||||
encounterMaxHealth: encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
encounterIsBoss: encounter.isBoss,
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
@@ -1186,7 +1318,8 @@ export function CombatScreen({
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
encounter.isBoss,
|
||||
encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
hardMode,
|
||||
enemyHealth,
|
||||
encounterIndex,
|
||||
encounters.length,
|
||||
@@ -1215,7 +1348,7 @@ export function CombatScreen({
|
||||
>
|
||||
{!dualScreenEnabled && <header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<h1>{dungeon.name}</h1>
|
||||
</div>
|
||||
<div className="combat-header-actions">
|
||||
@@ -1238,10 +1371,21 @@ export function CombatScreen({
|
||||
</div>
|
||||
<div className="enemy-info">
|
||||
<div className="bar-label">
|
||||
<strong>{encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
||||
<strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
|
||||
</div>
|
||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||
{hardMode ? (
|
||||
<div className="hard-enemy-bars">
|
||||
{enemyHealthSegments.map((segment) => (
|
||||
<div className="bar enemy-health" key={segment.index}>
|
||||
<span style={{ width: `${segment.percent}%` }} />
|
||||
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||
)}
|
||||
<p>{encounter.description}</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1288,7 +1432,14 @@ export function CombatScreen({
|
||||
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||
</div>
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||
))}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||
{(member.bounceHeals ?? []).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||
))}
|
||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
||||
@@ -1518,33 +1669,41 @@ export function CombatScreen({
|
||||
<div>
|
||||
<p className="eyebrow">{sectionName} Complete</p>
|
||||
<h2>{encounter.enemyName} Defeated</h2>
|
||||
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextIndex = encounterIndex + 1
|
||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||
const nextEncounter = encounters[nextIndex]
|
||||
const current = combatRef.current
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex(nextIndex)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue to {sectionName} {currentPart + 1}
|
||||
</button>
|
||||
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
|
||||
{canContinueAfterPart && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextIndex = encounterIndex + 1
|
||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||
const nextEncounter = encounters[nextIndex]
|
||||
const current = combatRef.current
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex(nextIndex)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue to {sectionName} {currentPart + 1}
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
||||
End Run
|
||||
</button>
|
||||
|
||||
@@ -28,6 +28,7 @@ const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||
.filter((slot) => slot !== 'component')
|
||||
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
@@ -68,18 +69,17 @@ export function EquipmentScreen({
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
||||
?? profile.craftingRecipes[0]
|
||||
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
)
|
||||
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||
?? craftableRecipes[0]
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||
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,
|
||||
)
|
||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||
: false
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
@@ -126,12 +126,14 @@ export function EquipmentScreen({
|
||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||
const availableLevels = useMemo(
|
||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
() => [...new Set(profile.craftingRecipes
|
||||
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
[profile.craftingRecipes],
|
||||
)
|
||||
const filteredRecipes = useMemo(
|
||||
() => {
|
||||
let result = [...profile.craftingRecipes]
|
||||
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||
@@ -144,7 +146,10 @@ export function EquipmentScreen({
|
||||
() => new Map(
|
||||
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||
slot,
|
||||
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
||||
profile.craftingRecipes.filter((recipe) =>
|
||||
recipe.item.slot === slot
|
||||
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
).length,
|
||||
]),
|
||||
),
|
||||
[profile.craftingRecipes],
|
||||
@@ -589,7 +594,7 @@ export function EquipmentScreen({
|
||||
type="button"
|
||||
>
|
||||
<strong>All</strong>
|
||||
<span>{profile.craftingRecipes.length}</span>
|
||||
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||
</button>
|
||||
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user