diff --git a/IWantToHeal-Thor-v1.0.31.apk b/IWantToHeal-Thor-v1.0.31.apk new file mode 100644 index 0000000..571d70f Binary files /dev/null and b/IWantToHeal-Thor-v1.0.31.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 641fbc5..383b9c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 48 - versionName "1.0.30" + versionCode 49 + versionName "1.0.31" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/seed.sql b/db/seed.sql index a1505a2..e7a7387 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells VALUES (1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'), (2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'), - (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'), + (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'), (4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'), (5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'), - (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'), + (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'), (7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'), (8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'), - (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'), + (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'), (20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'), (21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'), - (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'), + (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'), (23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'), (24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'), (25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'), (30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'), (31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'), - (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'), + (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'), (33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'), (34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'), (35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.'); diff --git a/src/App.css b/src/App.css index a0fd585..91be524 100644 --- a/src/App.css +++ b/src/App.css @@ -7567,6 +7567,7 @@ h2 { flex: 1; gap: 5px; grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(52px, 1fr)); margin-top: 5px; max-height: none; min-height: 0; @@ -7605,10 +7606,6 @@ h2 { font-size: 8px; } - .workshop-shell .ability-library > button:nth-child(n+6) { - display: none; - } - .workshop-shell .save-row .primary-button { font-size: 8px; min-height: 28px; @@ -7682,11 +7679,8 @@ h2 { } .workshop-shell .ability-library { - grid-template-columns: 1fr; - } - - .workshop-shell .ability-library > button:nth-child(n+6) { - display: none; + grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(52px, 1fr)); } .workshop-bottom-grid { @@ -7814,6 +7808,7 @@ h2 { flex: 1; gap: 5px; grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(52px, 1fr)); margin-top: 5px; max-height: none; min-height: 0; @@ -7843,9 +7838,6 @@ h2 { line-height: 1; } - .workshop-shell .ability-library > button:nth-child(n+6) { - display: none; - } } @media (max-width: 700px) and (max-height: 620px) { diff --git a/src/App.tsx b/src/App.tsx index 5f411bd..cfcf6f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -283,6 +283,19 @@ function App() { const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0] const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions + const startPveRoguelike = () => { + const baseDungeon = dungeonOptions[0] + const baseRaid = raidOptions[0] + if (roguelikeKind === 'raid') { + setCombatContentId(-2) + setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101) + } else { + setCombatContentId(-1) + setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1) + } + setSelectedPart(1) + setScreen('combat') + } const tierOptions = activityOptions .flatMap((option) => option.difficulties) .filter((difficulty, index, all) => ( @@ -425,84 +438,90 @@ function App() { {roguelikeVariant === 'pve' && ( <> -
-
-

Upgrade Timing

-

Buff Drafts

-
-
- - -
-
-
-
-

Upgrade Labels

-

Display Mode

-
-
- - -
-
-
- - -
+
+
+

Run Type

+

PvE Roguelike

+
+
+ + +
+
+
+
+

Upgrade Timing

+

Buff Drafts

+
+
+ + +
+
+
+
+

Upgrade Labels

+

Display Mode

+
+
+ + +
+
+
+ {roguelikeKind === 'raid' ? 'R' : 'D'} +
+ {roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'} + + {roguelikeKind === 'raid' + ? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.' + : 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'} + +
+ +
)} {roguelikeVariant === 'pvp' && ( diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 71e8bc1..00b450e 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -10,6 +10,10 @@ import { import { INITIAL_PARTY, RAID_PARTY, + DEFAULT_GROUP_HEAL_TARGETS, + groupHealTargets, + partyDamageOutput, + tankPressureTargets, type CombatLogEntry, type PartyMember, type Spell, @@ -561,6 +565,12 @@ export function CombatScreen({ const directTargets = new Set([targetId]) const hotTargets = new Set() const shieldTargets = new Set() + const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId) + const groupTargets = new Set( + spell.kind === 'group' + ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) + : [], + ) if (spell.kind === 'hot') hotTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId) if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { @@ -574,7 +584,6 @@ export function CombatScreen({ if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) { hotTargets.add(targetId) } - const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId) for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break if (spell.kind === 'hot') { @@ -594,6 +603,7 @@ export function CombatScreen({ const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { + if (!groupTargets.has(member.id)) return member 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)) @@ -902,11 +912,17 @@ 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) => { if (member.health <= 0) return member let damage = member.id === primaryTarget.id ? encounter.damage : 0 - if (member.role === 'Tank') damage += encounter.tankDamage - if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier) + if (tankPressureIds.has(member.id)) { + damage += Math.round(encounter.tankDamage * tankPressure.multiplier) + } + if (tankBuster && tankPressureIds.has(member.id)) { + damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier) + } if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier) if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier) const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id @@ -966,7 +982,7 @@ export function CombatScreen({ return } - const nextEnemyHealth = current.enemyHealth - encounter.partyDamage + const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage) if (nextEnemyHealth > 0) { setCombat({ ...current, @@ -1351,7 +1367,7 @@ export function CombatScreen({ {status === 'upgrade-choice' && (
-
+

{encounter.isBoss ? `Roguelike Stage ${roguelikeStage} Complete` @@ -1359,13 +1375,18 @@ export function CombatScreen({

Choose Upgrade

Pick one upgrade before the next fight.

-
- {upgradeChoices.map((upgrade) => ( - - ))} +
+
+ Run Buff +
+ {upgradeChoices.map((upgrade) => ( + + ))} +
+
{roguelikeUpgrades.length > 0 && (

diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index 85c8e3b..b1827df 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -1,5 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game' +import { + INITIAL_PARTY, + RAID_PARTY, + DEFAULT_GROUP_HEAL_TARGETS, + groupHealTargets, + partyDamageOutput, + tankPressureTargets, + type CombatLogEntry, + type PartyMember, + type Spell, +} from '../game' import { completeRoguelike, type DungeonReward } from '../profile' import type { Ability, CharacterProfile, DungeonEncounter } from '../profile' import type { GameMode } from '../gameRepository' @@ -78,6 +88,17 @@ type FloatingCombatText = { value: number } +type PvpRunSummary = { + bossesKilled: number + experienceGained: number + previousLevel: number | null + newLevel: number | null + levelsGained: number + talentPointsGained: number + unlockedAbilities: DungeonReward['unlockedAbilities'] + loot: Array> +} + const BOSS_MECHANICS: BossMechanic[] = [ 'party-pulse', 'searing-mark', @@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) { return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` } +function createEmptyPvpRunSummary(): PvpRunSummary { + return { + bossesKilled: 0, + experienceGained: 0, + previousLevel: null, + newLevel: null, + levelsGained: 0, + talentPointsGained: 0, + unlockedAbilities: [], + loot: [], + } +} + function buffStacks(items: T[], id: T) { return items.filter((item) => item === id).length } @@ -411,11 +445,13 @@ export function PvPRoguelikeScreen({ const [playerSide, setPlayerSide] = useState(() => starterSide(partyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState(() => starterSide(cpuPartyTemplate, maxResource)) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) + const selectedIdRef = useRef(partyTemplate[0].id) const [elapsedTicks, setElapsedTicks] = useState(0) const [cpuDifficulty, setCpuDifficulty] = useState(null) const [queueMessage, setQueueMessage] = useState('') const [log, setLog] = useState([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [reward, setReward] = useState(null) + const [runSummary, setRunSummary] = useState(() => createEmptyPvpRunSummary()) const [rewardError, setRewardError] = useState('') const [showEndLog, setShowEndLog] = useState(false) const [floatingTexts, setFloatingTexts] = useState([]) @@ -433,6 +469,8 @@ export function PvPRoguelikeScreen({ const bossRewardClaimedRef = useRef(new Set()) const cpuDefeatedRef = useRef(false) const playerClearedEncounterRef = useRef(-1) + const queuedMatchRef = useRef(false) + const encounterPoolRef = useRef(encounterPool) const playerRef = useRef(playerSide) const cpuRef = useRef(cpuSide) const encounter = encounters[encounterIndex] @@ -459,6 +497,12 @@ export function PvPRoguelikeScreen({ const { enabled: dualScreenEnabled, } = useDualScreen() + + const setSelectedTargetId = useCallback((id: string) => { + selectedIdRef.current = id + setSelectedId(id) + }, []) + const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60)) }, []) @@ -473,11 +517,16 @@ export function PvPRoguelikeScreen({ }, []) useEffect(() => { + if (queuedMatchRef.current) return const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) setCheckpointStage(loadedCheckpoint) setStartStage(loadedCheckpoint) }, [contentType, profile.character.id]) + useEffect(() => { + encounterPoolRef.current = encounterPool + }, [encounterPool]) + const awardBossReward = useCallback((encounterIndexValue: number) => { if (bossRewardClaimedRef.current.has(encounterIndexValue)) return bossRewardClaimedRef.current.add(encounterIndexValue) @@ -497,6 +546,20 @@ export function PvPRoguelikeScreen({ ) .then((result) => { setReward(result) + setRunSummary((current) => { + const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability])) + result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability)) + return { + bossesKilled: current.bossesKilled + 1, + experienceGained: current.experienceGained + result.experienceGained, + previousLevel: current.previousLevel ?? result.previousLevel, + newLevel: result.newLevel, + levelsGained: current.levelsGained + result.levelsGained, + talentPointsGained: current.talentPointsGained + result.talentPointsGained, + unlockedAbilities: Array.from(unlockedById.values()), + loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot, + } + }) onProfileUpdated(result.profile) if (result.bonusItem) { addLog( @@ -532,8 +595,9 @@ export function PvPRoguelikeScreen({ : null) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) - useEffect(() => { - const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType) + const startMatch = useCallback((nextStartStage?: number) => { + const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) + const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) const firstEncounter = firstSegment[0] const basePlayer = starterSide(partyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource) @@ -543,15 +607,18 @@ export function PvPRoguelikeScreen({ cpuRef.current = baseCpu nextLogId.current = 2 playerClearedEncounterRef.current = -1 + queuedMatchRef.current = true bossRewardClaimedRef.current = new Set() setEncounters(firstSegment) setEncounterIndex(0) - setStage(startStage) + setCheckpointStage(matchStartStage) + setStartStage(matchStartStage) + setStage(matchStartStage) setElapsedTicks(0) setStatus('queueing') setPlayerSide(basePlayer) setCpuSide(baseCpu) - setSelectedId(partyTemplate[0].id) + setSelectedTargetId(partyTemplate[0].id) setPlayerBuffChoices([]) setPlayerDebuffChoices([]) setSelectedBuff(null) @@ -560,6 +627,7 @@ export function PvPRoguelikeScreen({ setPaused(false) setTargetGroup(0) setReward(null) + setRunSummary(createEmptyPvpRunSummary()) setRewardError('') setShowEndLog(false) setFloatingTexts([]) @@ -569,26 +637,28 @@ export function PvPRoguelikeScreen({ cpuDefeatedRef.current = false if (gameMode === 'offline') { const randomCpu = randomCpuDifficulty() - setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`) + setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`) setCpuDifficulty(randomCpu) - setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }]) + setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }]) const timer = window.setTimeout(() => { setStatus('playing') - addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system') + addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system') }, 500) return () => window.clearTimeout(timer) } - setQueueMessage(`Searching queue. Stage ${startStage} start ready.`) - setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }]) + setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`) + setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }]) const timer = window.setTimeout(() => { const randomCpu = randomCpuDifficulty() setCpuDifficulty(randomCpu) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setStatus('playing') - addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system') + addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system') }, 1400) return () => window.clearTimeout(timer) - }, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage]) + }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId]) + + useEffect(() => startMatch(), [startMatch]) const applySpell = useCallback(( current: SideState, @@ -611,6 +681,11 @@ export function PvPRoguelikeScreen({ const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId) + const groupTargets = new Set( + spell.kind === 'group' + ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) + : [], + ) for (let index = 0; index < extraTargets; index += 1) { if (spell.kind === 'group') break if (spell.kind === 'hot') { @@ -629,6 +704,7 @@ export function PvPRoguelikeScreen({ const nextParty = current.party.map((member) => { if (member.health <= 0) return member if (spell.kind === 'group') { + if (!groupTargets.has(member.id)) return member const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost'))) const nextHealth = healMember(member, groupPower, debuffs) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) @@ -687,29 +763,30 @@ export function PvPRoguelikeScreen({ const castPlayerSpell = useCallback((spell: Spell) => { if (status !== 'playing' || playerDone || !playerAlive) return + const targetId = selectedIdRef.current const succeeded = applySpell(playerRef.current, (value) => { const next = typeof value === 'function' ? value(playerRef.current) : value playerRef.current = next setPlayerSide(next) - }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId) - if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal') - }, [addLog, applySpell, playerAlive, playerDone, selectedId, status]) + }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId) + if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') + }, [addLog, applySpell, playerAlive, playerDone, status]) const selectRelativeTarget = useCallback((direction: -1 | 1) => { const living = playerRef.current.party.filter((member) => member.health > 0) if (living.length === 0) return - const currentIndex = living.findIndex((member) => member.id === selectedId) + const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current) const nextIndex = currentIndex < 0 ? 0 : (currentIndex + direction + living.length) % living.length - setSelectedId(living[nextIndex].id) - }, [selectedId]) + setSelectedTargetId(living[nextIndex].id) + }, [setSelectedTargetId]) const selectDirectionalTarget = useCallback((action: InputAction) => { - const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) + const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) if (currentIndex < 0) { const firstLiving = playerRef.current.party.find((member) => member.health > 0) - if (firstLiving) setSelectedId(firstLiving.id) + if (firstLiving) setSelectedTargetId(firstLiving.id) return } const currentRow = Math.floor(currentIndex / partyColumns) @@ -736,14 +813,14 @@ export function PvPRoguelikeScreen({ const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) return aPrimary - bPrimary || aSecondary - bSecondary }) - if (candidates[0]) setSelectedId(candidates[0].member.id) - }, [partyColumns, selectedId]) + if (candidates[0]) setSelectedTargetId(candidates[0].member.id) + }, [partyColumns, setSelectedTargetId]) const selectDirectTarget = useCallback((slot: number) => { const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0) const member = playerRef.current.party[index] - if (member?.health > 0) setSelectedId(member.id) - }, [contentType, targetGroup]) + if (member?.health > 0) setSelectedTargetId(member.id) + }, [contentType, setSelectedTargetId, targetGroup]) const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return @@ -790,10 +867,14 @@ export function PvPRoguelikeScreen({ const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction') const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison') const damageMultiplier = incomingDamageMultiplier(side.debuffs) + const tankPressure = tankPressureTargets(side.party) + const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) const nextParty = side.party.map((member) => { if (member.health <= 0) return member let damage = member.id === primaryTarget.id ? encounterValue.damage : 0 - if (member.role === 'Tank') damage += encounterValue.tankDamage + if (tankPressureIds.has(member.id)) { + damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier) + } if (bossPulse) damage += 10 if (member.debuff) damage += 6 const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id @@ -842,7 +923,7 @@ export function PvPRoguelikeScreen({ cooldowns: Object.fromEntries( Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), ), - enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage), + enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)), } }, [addFloatingHeal, elapsedTicks, maxResource]) @@ -895,6 +976,12 @@ export function PvPRoguelikeScreen({ addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') } if (nextPlayer.enemyHealth <= 0) { + if (encounter.isBoss && cpuDefeatedRef.current) { + finishRoguelikeRun() + setStatus('won') + addLog('CPU defeated. Match complete.', 'loot') + return + } addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') beginUpgradePhase() } @@ -955,10 +1042,17 @@ export function PvPRoguelikeScreen({ } const clearedBoss = encounter.isBoss + if (clearedBoss && cpuDefeatedRef.current) { + finishRoguelikeRun() + setStatus('won') + addLog('CPU defeated. Match complete.', 'loot') + return + } const nextStage = clearedBoss ? stage + 1 : stage const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : [] const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1] if (!nextEncounter) { + finishRoguelikeRun() setStatus('won') addLog('No further encounters remain.', 'loot') return @@ -1007,7 +1101,7 @@ export function PvPRoguelikeScreen({ setElapsedTicks(0) setStatus('playing') addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') - }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) + }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) useGameAction((action) => { if (action === 'pause' || action === 'back') { @@ -1036,9 +1130,9 @@ export function PvPRoguelikeScreen({ setTargetGroup((current) => { const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6)) const next = ((current + 1) % groupCount) as 0 | 1 | 2 - const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) + const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] - if (nextMember?.health > 0) setSelectedId(nextMember.id) + if (nextMember?.health > 0) setSelectedTargetId(nextMember.id) return next }) return @@ -1128,7 +1222,7 @@ export function PvPRoguelikeScreen({ {dualScreenEnabled && status !== 'queueing' && ( )} @@ -1152,7 +1246,7 @@ export function PvPRoguelikeScreen({

diff --git a/src/game.ts b/src/game.ts index a6e0f87..a9dd482 100644 --- a/src/game.ts +++ b/src/game.ts @@ -44,6 +44,9 @@ export type CombatLogEntry = { tone: 'system' | 'heal' | 'danger' | 'loot' } +export const TANKLESS_DAMAGE_MULTIPLIER = 1.35 +export const DEFAULT_GROUP_HEAL_TARGETS = 4 + export const INITIAL_PARTY: PartyMember[] = [ { id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 }, { id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 }, @@ -101,7 +104,7 @@ export const SPELLS: Spell[] = [ id: 'radiance', key: '3', name: 'Radiance', - description: 'Restores health to every living party member.', + description: 'Restores health to up to 4 injured party members.', cost: 12, cooldown: 8, power: 18, @@ -164,3 +167,28 @@ export const ENCOUNTERS: Encounter[] = [ isBoss: true, }, ] + +export function partyDamageOutput(party: PartyMember[], baseDamage: number) { + const livingCount = party.filter((member) => member.health > 0).length + return Math.round(baseDamage * (livingCount / Math.max(1, party.length))) +} + +export function tankPressureTargets(party: PartyMember[]) { + const living = party.filter((member) => member.health > 0) + const tanks = living.filter((member) => member.role === 'Tank') + if (tanks.length > 0) return { targets: tanks, multiplier: 1 } + const damageDealer = living + .filter((member) => member.role === 'Damage') + .sort((left, right) => right.health - left.health)[0] + return { + targets: damageDealer ? [damageDealer] : [], + multiplier: TANKLESS_DAMAGE_MULTIPLIER, + } +} + +export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) { + return party + .filter((member) => member.health > 0) + .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth)) + .slice(0, targetCount) +} diff --git a/src/input.tsx b/src/input.tsx index 61351f0..e4da847 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -121,7 +121,6 @@ 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 @@ -277,14 +276,6 @@ function hasUiOverlay() { ).some(isVisible) } -function isCombatTargetAction(action: InputAction) { - return action.startsWith('navigate') - || action.startsWith('targetParty') - || action === 'previousTarget' - || action === 'nextTarget' - || action === 'toggleTargetGroup' -} - const BUTTON_LABELS: Record = { 0: 'A / Cross', 1: 'B / Circle', @@ -398,7 +389,6 @@ export function InputProvider({ children }: { children: ReactNode }) { const keyboardInputRef = useRef(keyboardInput) const previousTokensRef = useRef(new Set()) const repeatRef = useRef>({}) - const lastCombatNavigationRef = useRef(0) useEffect(() => { bindingsRef.current = bindings @@ -445,11 +435,6 @@ export function InputProvider({ children }: { children: ReactNode }) { const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const uiOverlay = hasUiOverlay() const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) - if (combatActive && !uiOverlay && isCombatTargetAction(action)) { - const now = performance.now() - if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return - lastCombatNavigationRef.current = now - } setLastDevice(device) document.documentElement.dataset.inputDevice = device diff --git a/src/offline-starter-profile.json b/src/offline-starter-profile.json index 9546c77..ce790a4 100644 --- a/src/offline-starter-profile.json +++ b/src/offline-starter-profile.json @@ -62,7 +62,7 @@ "power": 18, "unlockLevel": 1, "glyph": "*", - "description": "Restores health to every living party member." + "description": "Restores health to up to 4 injured party members." }, { "id": 4, @@ -101,7 +101,7 @@ "power": 28, "unlockLevel": 5, "glyph": "D", - "description": "A brilliant wave of healing for the entire party." + "description": "A brilliant wave of healing for up to 4 injured allies." }, { "id": 7, @@ -140,7 +140,7 @@ "power": 48, "unlockLevel": 20, "glyph": "A", - "description": "Floods the party with the full strength of dawn." + "description": "Floods up to 4 injured allies with the full strength of dawn." } ], "talents": [ @@ -345,7 +345,7 @@ "power": 17, "unlockLevel": 1, "glyph": "*", - "description": "Restorative growth spreads through the party." + "description": "Restorative growth spreads to up to 4 injured allies." }, { "id": 23, @@ -589,7 +589,7 @@ "power": 18, "unlockLevel": 1, "glyph": "*", - "description": "Links the party through a shared healing pattern." + "description": "Links up to 4 injured allies through a shared healing pattern." }, { "id": 33,