diff --git a/IWantToHeal-Thor-v1.0.57.apk b/IWantToHeal-Thor-v1.0.57.apk new file mode 100644 index 0000000..e39d571 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.57.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 67ae82f..cdf232a 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 75 - versionName "1.0.56" + versionCode 76 + versionName "1.0.57" 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/src/App.css b/src/App.css index fb4bfa9..a7fc71d 100644 --- a/src/App.css +++ b/src/App.css @@ -5888,6 +5888,33 @@ h2 { z-index: 10; } +.pvp-round-countdown { + align-items: center; + background: rgba(5, 5, 8, 0.55); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 9; +} + +.pvp-round-countdown > div { + background: var(--panel); + border: 3px solid #0b0c0f; + box-shadow: 8px 8px 0 #050507; + min-width: 220px; + outline: 2px solid var(--gold); + padding: 28px; + text-align: center; +} + +.pvp-round-countdown h2 { + color: var(--gold); + font-size: clamp(48px, 8vw, 92px); + line-height: 1; + margin-top: 8px; +} + .result-screen > div, .pause-screen > div { background: var(--panel); diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index fa9bb17..5b69888 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -42,7 +42,8 @@ import { } from '../pvpRoguelike' const TICK_MS = 700 -const UPGRADE_CHOICE_SECONDS = 10 +const ROUND_START_SECONDS = 3 +const UPGRADE_CHOICE_SECONDS = 15 type BossMechanic = | 'party-pulse' @@ -61,6 +62,7 @@ type DraftSlotKey = Exclude type AbilityLabelMode = 'ability' | 'slot' type SelfBuffId = + | 'revive-party-members' | `slot${DraftSlotKey}-extra-target` | `slot${DraftSlotKey}-cost-down` | `slot${DraftSlotKey}-cooldown-down` @@ -112,6 +114,12 @@ type LivePvpMatch = { opponentClassName: string } +const REVIVE_PARTY_CHOICE: Choice = { + id: 'revive-party-members', + name: 'Revive Party Members', + description: 'Revive fallen party members before the next fight.', +} + const BOSS_MECHANICS: BossMechanic[] = [ 'party-pulse', 'searing-mark', @@ -322,7 +330,32 @@ function starterSide(partyTemplate: PartyMember[], maxResource: number): SideSta } } +function hasDeadPartyMembers(side: SideState) { + return side.party.some((member) => member.health <= 0) +} + +function recoverPartyForNextEncounter(party: PartyMember[], reviveDead: boolean) { + return party.map((member) => ({ + ...member, + health: member.health <= 0 + ? (reviveDead ? Math.max(1, Math.round(member.maxHealth * 0.3)) : 0) + : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), + debuff: undefined, + debuffTicks: undefined, + poisonStacks: undefined, + maxHealthPenaltyTicks: undefined, + healingReductionTicks: undefined, + })) +} + +function removeRandomDebuff(debuffs: OpponentDebuffId[]) { + if (debuffs.length === 0) return debuffs + const removedIndex = Math.floor(Math.random() * debuffs.length) + return debuffs.filter((_, index) => index !== removedIndex) +} + function scoreSelfBuff(buff: Choice, spells: Spell[]) { + if (buff.id === 'revive-party-members') return 10 const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined const spell = spells.find((candidate) => candidate.key === slot) if (!spell) return 5 @@ -416,7 +449,7 @@ export function PvPRoguelikeScreen({ })), [contentType], ) - const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing') + const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing') const [stage, setStage] = useState(startStage) const [encounters, setEncounters] = useState(() => buildEncounterSegment(encounterPool, startStage, contentType)) const [encounterIndex, setEncounterIndex] = useState(0) @@ -442,6 +475,7 @@ export function PvPRoguelikeScreen({ const [playerDebuffChoices, setPlayerDebuffChoices] = useState>>([]) const [selectedBuff, setSelectedBuff] = useState | null>(null) const [selectedDebuff, setSelectedDebuff] = useState | null>(null) + const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS) const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS) const [encountersCleared, setEncountersCleared] = useState(0) const [paused, setPaused] = useState(false) @@ -456,6 +490,7 @@ export function PvPRoguelikeScreen({ const queuedMatchRef = useRef(false) const upgradeChoiceEndsAtRef = useRef(0) const autoSubmittedUpgradeRef = useRef(false) + const roundCountdownTimerRef = useRef(null) const liveMatchRef = useRef(null) const loggedOpponentDoneRef = useRef(false) const pendingLiveUpgradeRef = useRef<{ @@ -518,6 +553,29 @@ export function PvPRoguelikeScreen({ }, 900) }, []) + const clearRoundCountdown = useCallback(() => { + if (roundCountdownTimerRef.current === null) return + window.clearInterval(roundCountdownTimerRef.current) + roundCountdownTimerRef.current = null + }, []) + + const beginRoundCountdown = useCallback((message?: string) => { + clearRoundCountdown() + setRoundCountdown(ROUND_START_SECONDS) + setStatus('round-countdown') + if (message) addLog(message, 'system') + const startedAt = Date.now() + roundCountdownTimerRef.current = window.setInterval(() => { + const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000) + setRoundCountdown(remaining) + if (remaining > 0) return + clearRoundCountdown() + setStatus((current) => current === 'round-countdown' ? 'playing' : current) + }, 100) + }, [addLog, clearRoundCountdown]) + + useEffect(() => () => clearRoundCountdown(), [clearRoundCountdown]) + useEffect(() => { if (queuedMatchRef.current) return const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) @@ -589,13 +647,15 @@ export function PvPRoguelikeScreen({ useEffect(() => { setPlayerBuffChoices((current) => current - .map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) + .map((choice) => choice.id === REVIVE_PARTY_CHOICE.id + ? REVIVE_PARTY_CHOICE + : selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .filter((choice): choice is Choice => Boolean(choice))) setPlayerDebuffChoices((current) => current .map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .filter((choice): choice is Choice => Boolean(choice))) setSelectedBuff((current) => current - ? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current + ? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current) : null) setSelectedDebuff((current) => current ? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current @@ -642,7 +702,6 @@ export function PvPRoguelikeScreen({ setStartStage(matchStartStage) setStage(matchStartStage) setElapsedTicks(0) - setStatus('playing') setPlayerSide(basePlayer) setCpuSide(baseOpponent) setSelectedTargetId(partyTemplate[0].id) @@ -674,9 +733,11 @@ export function PvPRoguelikeScreen({ const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.` setQueueMessage(logText) setLog([{ id: 1, text: logText, tone: 'system' }]) - }, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId]) + beginRoundCountdown() + }, [beginRoundCountdown, contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId]) const startMatch = useCallback((nextStartStage?: number) => { + clearRoundCountdown() const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType) const firstEncounter = firstSegment[0] @@ -732,8 +793,7 @@ export function PvPRoguelikeScreen({ setCpuDifficulty(randomCpu) setQueueMessage(message) setLog([{ id: 1, text: message, tone: 'system' }]) - setStatus('playing') - addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system') + beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`) } if (gameMode === 'offline') { const randomCpu = randomCpuDifficulty() @@ -802,7 +862,7 @@ export function PvPRoguelikeScreen({ if (pollTimer) window.clearTimeout(pollTimer) if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined) } - }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch]) + }, [beginRoundCountdown, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch]) useEffect(() => startMatch(), [startMatch]) @@ -812,7 +872,7 @@ export function PvPRoguelikeScreen({ const syncMatch = () => { publishPvpMatchState(liveMatch.id, { state: playerRef.current, - status: status === 'upgrade-choice' ? 'upgrade-choice' : status, + status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'round-countdown' ? 'playing' : status, stage, encounterIndex, encountersCleared, @@ -1154,10 +1214,11 @@ export function PvPRoguelikeScreen({ const beginUpgradePhase = useCallback(() => { upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000 autoSubmittedUpgradeRef.current = false + const playerNeedsRevive = hasDeadPartyMembers(playerRef.current) setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS) - setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) + setPlayerBuffChoices(playerNeedsRevive ? [REVIVE_PARTY_CHOICE] : chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3)) - setSelectedBuff(null) + setSelectedBuff(playerNeedsRevive ? REVIVE_PARTY_CHOICE : null) setSelectedDebuff(null) setStatus('upgrade-choice') }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) @@ -1297,11 +1358,16 @@ export function PvPRoguelikeScreen({ const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => { let nextPlayer = { ...playerRef.current, - buffs: [...playerRef.current.buffs, submittedBuff.id], + buffs: submittedBuff.id === REVIVE_PARTY_CHOICE.id + ? playerRef.current.buffs + : [...playerRef.current.buffs, submittedBuff.id], } if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) { nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] } } + if (submittedBuff.id === REVIVE_PARTY_CHOICE.id) { + nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) } + } const clearedBoss = encounter.isBoss if (clearedBoss && cpuDefeatedRef.current) { @@ -1323,15 +1389,7 @@ export function PvPRoguelikeScreen({ } nextPlayer = { ...nextPlayer, - party: nextPlayer.party.map((member) => ({ - ...member, - health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - })), + party: recoverPartyForNextEncounter(nextPlayer.party, submittedBuff.id === REVIVE_PARTY_CHOICE.id), resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource), cooldowns: {}, enemyHealth: nextEncounter.maxHealth, @@ -1346,7 +1404,7 @@ export function PvPRoguelikeScreen({ setElapsedTicks(0) setLiveUpgradePending(false) pendingLiveUpgradeRef.current = null - setStatus('playing') + beginRoundCountdown() const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId) addLog( `You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`, @@ -1392,23 +1450,35 @@ export function PvPRoguelikeScreen({ return } if (!cpuDifficulty) return - const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3) + const cpuBuffChoices = hasDeadPartyMembers(cpuRef.current) + ? [REVIVE_PARTY_CHOICE] + : chooseRandom(selfBuffChoicesCatalog, 3) const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3) const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells)) const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length)) let nextPlayer = { ...playerRef.current, - buffs: [...playerRef.current.buffs, chosenBuff.id], + buffs: chosenBuff.id === REVIVE_PARTY_CHOICE.id + ? playerRef.current.buffs + : [...playerRef.current.buffs, chosenBuff.id], } let nextCpu = { ...cpuRef.current, - buffs: [...cpuRef.current.buffs, cpuBuff.id], + buffs: cpuBuff.id === REVIVE_PARTY_CHOICE.id + ? cpuRef.current.buffs + : [...cpuRef.current.buffs, cpuBuff.id], } nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] } nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] } + if (chosenBuff.id === REVIVE_PARTY_CHOICE.id) { + nextPlayer = { ...nextPlayer, debuffs: removeRandomDebuff(nextPlayer.debuffs) } + } + if (cpuBuff.id === REVIVE_PARTY_CHOICE.id) { + nextCpu = { ...nextCpu, debuffs: removeRandomDebuff(nextCpu.debuffs) } + } const clearedBoss = encounter.isBoss if (clearedBoss && cpuDefeatedRef.current) { @@ -1429,30 +1499,14 @@ export function PvPRoguelikeScreen({ nextPlayer = { ...nextPlayer, - party: nextPlayer.party.map((member) => ({ - ...member, - health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - })), + party: recoverPartyForNextEncounter(nextPlayer.party, chosenBuff.id === REVIVE_PARTY_CHOICE.id), resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource), cooldowns: {}, enemyHealth: nextEncounter.maxHealth, } nextCpu = { ...nextCpu, - party: nextCpu.party.map((member) => ({ - ...member, - health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth), - debuff: undefined, - debuffTicks: undefined, - poisonStacks: undefined, - maxHealthPenaltyTicks: undefined, - healingReductionTicks: undefined, - })), + party: recoverPartyForNextEncounter(nextCpu.party, cpuBuff.id === REVIVE_PARTY_CHOICE.id), resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource), cooldowns: {}, enemyHealth: nextEncounter.maxHealth, @@ -1468,9 +1522,9 @@ export function PvPRoguelikeScreen({ playerRef.current = nextPlayer cpuRef.current = nextCpu setElapsedTicks(0) - setStatus('playing') + beginRoundCountdown() addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') - }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) + }, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) useEffect(() => { if (status !== 'upgrade-choice' || liveUpgradePending) return @@ -1574,7 +1628,7 @@ export function PvPRoguelikeScreen({ partySize: playerSide.party.length, selectedId, log, - status: status === 'queueing' ? 'playing' : status, + status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status, resource: playerSide.resource, maxResource, resourceName: gameClass.resourceName, @@ -1802,6 +1856,15 @@ export function PvPRoguelikeScreen({ )} + {status === 'round-countdown' && ( +
+
+

Round Starts

+

{Math.max(1, Math.ceil(roundCountdown))}

+
+
+ )} + {status === 'upgrade-choice' && (
@@ -1814,7 +1877,7 @@ export function PvPRoguelikeScreen({
- Self Buff + {playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}
{playerBuffChoices.map((choice) => (