Compare commits

...

1 Commits

Author SHA1 Message Date
Warren H c2888c287b Android build v1.0.59 stadium updated 2026-06-22 23:38:12 -04:00
4 changed files with 322 additions and 43 deletions
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 77 versionCode 78
versionName "1.0.58" versionName "1.0.59"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+24 -11
View File
@@ -18,6 +18,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
.sr-only {
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
button { button {
font: inherit; font: inherit;
} }
@@ -778,6 +786,10 @@ textarea:focus-visible,
min-height: 0; min-height: 0;
} }
.dual-top-main.stadium-dual-top {
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.dual-top-main .dual-top-enemy, .dual-top-main .dual-top-enemy,
.dual-top-main .dual-top-party, .dual-top-main .dual-top-party,
.dual-top-main .dual-top-log { .dual-top-main .dual-top-log {
@@ -6480,7 +6492,7 @@ h2 {
.stadium-shop-tabs button, .stadium-shop-tabs button,
.stadium-shop-grid button { .stadium-shop-grid button {
background: #252833; background: #303545;
border: 2px solid #0b0c0f; border: 2px solid #0b0c0f;
color: var(--ink); color: var(--ink);
cursor: pointer; cursor: pointer;
@@ -6513,29 +6525,30 @@ h2 {
.stadium-shop-grid button { .stadium-shop-grid button {
display: grid; display: grid;
gap: 8px; gap: 10px;
margin: 0; margin: 0;
min-height: 110px; min-height: 128px;
padding: 12px; padding: 14px;
text-align: left; text-align: left;
} }
.stadium-shop-grid button:disabled { .stadium-shop-grid button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.45; opacity: 0.58;
} }
.stadium-shop-grid strong { .stadium-shop-grid strong {
color: #ffe8a5; color: #fff0b8;
font-size: 10px; font-size: 11px;
line-height: 1.25; line-height: 1.35;
} }
.stadium-shop-grid small, .stadium-shop-grid small,
.stadium-shop-layout p { .stadium-shop-layout p {
color: #d3d9e6; color: #f0e8d2;
font-size: 13px; font-family: 'VT323', monospace;
line-height: 1.2; font-size: 22px;
line-height: 1.05;
} }
.dual-opponent-progress.stadium { .dual-opponent-progress.stadium {
+296 -30
View File
@@ -33,12 +33,22 @@ const ROUND_START_SECONDS = 3
const SHOP_SECONDS = 60 const SHOP_SECONDS = 60
const WIN_ROUNDS = 3 const WIN_ROUNDS = 3
const MAX_RESOURCE = 100 const MAX_RESOURCE = 100
const RESOURCE_REGEN_PER_TICK = 0.8
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5'
type StadiumBuffId = type StadiumBuffId =
| `slot${SlotKey}-extra-target` | `slot${SlotKey}-extra-target`
| `slot${SlotKey}-cost-down` | `slot${SlotKey}-cost-down`
| `slot${SlotKey}-cooldown-down` | `slot${SlotKey}-cooldown-down`
| 'slot1-applies-renew'
| 'slot1-applies-shield'
| 'slot2-applies-shield'
| 'slot2-double-duration'
| 'slot3-applies-shield'
| 'slot3-applies-renew'
| 'slot4-applies-renew'
| 'slot5-applies-renew'
| 'slot5-applies-shield'
| 'fifth-cast-free' | 'fifth-cast-free'
| 'group-heal-boost' | 'group-heal-boost'
| 'shield-boost' | 'shield-boost'
@@ -134,10 +144,19 @@ function slotLabel(slot: SlotKey, spells: Spell[]) {
return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}` return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}`
} }
function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
}
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] { function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
const directName = slotSpellName('1', spells, 'Mend')
const sustainName = slotSpellName('2', spells, 'Renew')
const groupName = slotSpellName('3', spells, 'Radiance')
const shieldName = slotSpellName('4', spells, 'Sun Ward')
const cleanseName = slotSpellName('5', spells, 'Purify')
const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells) const label = slotLabel(slot, spells)
return [ const baseBuffs: StadiumBuff[] = [
{ {
id: `slot${slot}-extra-target` as StadiumBuffId, id: `slot${slot}-extra-target` as StadiumBuffId,
name: '+1 target', name: '+1 target',
@@ -159,7 +178,83 @@ function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
category: slot, category: slot,
cost: 1, cost: 1,
}, },
] satisfies StadiumBuff[] ]
const specialBuffs: Partial<Record<SlotKey, StadiumBuff[]>> = {
1: [
{
id: 'slot1-applies-renew',
name: `Applies ${sustainName}`,
description: `${directName} also applies ${sustainName} to the target.`,
category: slot,
cost: 2,
},
{
id: 'slot1-applies-shield',
name: `Applies ${shieldName}`,
description: `${directName} also applies a ${shieldName} barrier to the target.`,
category: slot,
cost: 2,
},
],
2: [
{
id: 'slot2-applies-shield',
name: `Applies ${shieldName}`,
description: `${sustainName} also applies a ${shieldName} barrier to the target.`,
category: slot,
cost: 2,
},
{
id: 'slot2-double-duration',
name: 'Double Duration',
description: `${sustainName} lasts twice as long or gains extra charges.`,
category: slot,
cost: 2,
},
],
3: [
{
id: 'slot3-applies-shield',
name: `Applies 50% ${shieldName}`,
description: `${groupName} applies a ${shieldName} barrier at 50% strength to affected targets.`,
category: slot,
cost: 2,
},
{
id: 'slot3-applies-renew',
name: `Applies ${sustainName}`,
description: `${groupName} applies ${sustainName} to affected targets.`,
category: slot,
cost: 2,
},
],
4: [
{
id: 'slot4-applies-renew',
name: `Applies ${sustainName}`,
description: `${shieldName} also applies ${sustainName} to the target.`,
category: slot,
cost: 2,
},
],
5: [
{
id: 'slot5-applies-renew',
name: `Applies ${sustainName}`,
description: `${cleanseName} also applies ${sustainName} to the target.`,
category: slot,
cost: 2,
},
{
id: 'slot5-applies-shield',
name: `Applies ${shieldName}`,
description: `${cleanseName} also applies a ${shieldName} barrier to the target.`,
category: slot,
cost: 2,
},
],
}
return [...baseBuffs, ...(specialBuffs[slot] ?? [])]
}) })
return [ return [
...slotBuffs, ...slotBuffs,
@@ -211,9 +306,14 @@ function spellResourceCost(spell: Spell, buffs: StadiumBuffId[], freeCastReady:
function toCombatSpell(ability: Ability, key: string): Spell { function toCombatSpell(ability: Ability, key: string): Spell {
const kinds: Record<string, Spell['kind']> = { const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct', direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot', heal_over_time: 'hot',
bounce_heal: 'bounce_heal',
party_heal: 'group', party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield', absorb: 'shield',
damage_reduction: 'damage_reduction',
cleanse: 'cleanse', cleanse: 'cleanse',
} }
return { return {
@@ -226,6 +326,7 @@ function toCombatSpell(ability: Ability, key: string): Spell {
power: ability.power, power: ability.power,
glyph: ability.glyph, glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct', kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
} }
} }
@@ -241,6 +342,8 @@ function resetParty(partyTemplate: PartyMember[]) {
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
damageReductionTicks: undefined,
bounceHeals: undefined,
})) }))
} }
@@ -339,6 +442,7 @@ export function PvpStadiumScreen({
const submittedShopRef = useRef(false) const submittedShopRef = useRef(false)
const awardedXpRef = useRef(new Set<string>()) const awardedXpRef = useRef(new Set<string>())
const queuedMatchRef = useRef(false) const queuedMatchRef = useRef(false)
const roundResolvedRef = useRef(false)
const loggedOpponentRoundRef = useRef('') const loggedOpponentRoundRef = useRef('')
const { const {
bindings, bindings,
@@ -376,6 +480,7 @@ export function PvpStadiumScreen({
const beginRoundCountdown = useCallback(() => { const beginRoundCountdown = useCallback(() => {
clearRoundCountdown() clearRoundCountdown()
roundResolvedRef.current = false
setRoundCountdown(ROUND_START_SECONDS) setRoundCountdown(ROUND_START_SECONDS)
setStatus('round-countdown') setStatus('round-countdown')
const startedAt = Date.now() const startedAt = Date.now()
@@ -443,6 +548,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
roundResolvedRef.current = false
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseOpponent) setCpuSide(baseOpponent)
setRoundIndex(1) setRoundIndex(1)
@@ -477,6 +583,7 @@ export function PvpStadiumScreen({
queuedMatchRef.current = true queuedMatchRef.current = true
nextLogId.current = 2 nextLogId.current = 2
awardedXpRef.current = new Set() awardedXpRef.current = new Set()
roundResolvedRef.current = false
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseCpu) setCpuSide(baseCpu)
setRoundIndex(1) setRoundIndex(1)
@@ -588,18 +695,34 @@ export function PvpStadiumScreen({
const extraTarget = (blockedIds: string[]) => livingTargets const extraTarget = (blockedIds: string[]) => livingTargets
.filter((member) => !blockedIds.includes(member.id)) .filter((member) => !blockedIds.includes(member.id))
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
const directTargets = new Set([targetId]) const directTargets = new Set<string>(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : [])
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) const hotTargets = new Set<string>(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : [])
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) const shieldTargets = new Set<string>(spell.kind === 'shield' ? [targetId] : [])
const damageReductionTargets = new Set<string>(spell.kind === 'damage_reduction' ? [targetId] : [])
const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId) const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId)
const groupTargets = new Set( const groupTargets = new Set(
spell.kind === 'group' spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [], : [],
) )
const renewDuration = buffStacks(current.buffs, 'slot2-double-duration') > 0 && spell.key === '2' ? 10 : 5
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
sourcePower
* strength
* (1.25 ** buffStacks(current.buffs, 'shield-boost'))
* dampenMultiplier,
)
if (spell.effectType === 'direct_hot') hotTargets.add(targetId)
if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-renew') > 0) hotTargets.add(targetId)
if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-shield') > 0) shieldTargets.add(targetId)
if (spell.key === '2' && buffStacks(current.buffs, 'slot2-applies-shield') > 0) shieldTargets.add(targetId)
if (spell.key === '4' && buffStacks(current.buffs, 'slot4-applies-renew') > 0) hotTargets.add(targetId)
if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-renew') > 0) hotTargets.add(targetId)
if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-shield') > 0) shieldTargets.add(targetId)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot' || spell.kind === 'bounce_heal') {
const extra = extraTarget([...hotTargets]) const extra = extraTarget([...hotTargets])
if (extra) hotTargets.add(extra.id) if (extra) hotTargets.add(extra.id)
continue continue
@@ -609,25 +732,54 @@ export function PvpStadiumScreen({
if (extra) shieldTargets.add(extra.id) if (extra) shieldTargets.add(extra.id)
continue continue
} }
if (spell.kind === 'damage_reduction') {
const extra = extraTarget([...damageReductionTargets])
if (extra) damageReductionTargets.add(extra.id)
continue
}
const extra = extraTarget([...directTargets]) const extra = extraTarget([...directTargets])
if (extra) directTargets.add(extra.id) if (extra) directTargets.add(extra.id)
} }
if (spell.effectType === 'direct_hot') directTargets.forEach((id) => hotTargets.add(id))
const nextParty = current.party.map((member) => { const nextParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
if (spell.kind === 'group') { if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member if (!groupTargets.has(member.id)) return member
const power = Math.round(spell.power * (1.25 ** buffStacks(current.buffs, 'group-heal-boost')) * dampenMultiplier) const isGroupAbsorb = spell.effectType === 'party_absorb'
const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member)) const isGroupHot = spell.effectType === 'party_hot'
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) const boost = isGroupAbsorb ? buffStacks(current.buffs, 'shield-boost') : buffStacks(current.buffs, 'group-heal-boost')
return { ...member, health: nextHealth } const power = Math.round(spell.power * (1.25 ** boost) * dampenMultiplier)
} const nextHealth = isGroupAbsorb || isGroupHot ? member.health : clamp(member.health + power, 0, effectiveMaxHealth(member))
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
if (spell.kind === 'shield') { const appliesShield = isGroupAbsorb || buffStacks(current.buffs, 'slot3-applies-shield') > 0
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(current.buffs, 'shield-boost')) * dampenMultiplier) const appliesHot = isGroupHot || buffStacks(current.buffs, 'slot3-applies-renew') > 0
return { return {
...member, ...member,
shield: Math.max(member.shield, shieldPower), health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, shield: appliesShield
? Math.max(member.shield, isGroupAbsorb ? power : shieldPower(shieldEffect?.power ?? spell.power, 0.5))
: member.shield,
hotTicks: appliesHot ? Math.max(member.hotTicks, 5) : member.hotTicks,
}
}
if (
!directTargets.has(member.id)
&& !hotTargets.has(member.id)
&& !shieldTargets.has(member.id)
&& !damageReductionTargets.has(member.id)
) return member
if (spell.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, shieldPower(spell.power)),
hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
}
}
if (spell.kind === 'damage_reduction') {
return {
...member,
damageReductionTicks: Math.max(member.damageReductionTicks ?? 0, 12),
hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
} }
} }
if (spell.kind === 'cleanse') { if (spell.kind === 'cleanse') {
@@ -642,6 +794,10 @@ export function PvpStadiumScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
shield: shieldTargets.has(member.id)
? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power))
: member.shield,
hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
} }
} }
const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0 const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0
@@ -650,7 +806,10 @@ export function PvpStadiumScreen({
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, shield: shieldTargets.has(member.id)
? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power))
: member.shield,
hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, renewDuration) : member.hotTicks,
} }
}) })
const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0 const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0
@@ -669,7 +828,7 @@ export function PvpStadiumScreen({
} }
setCurrent(nextState) setCurrent(nextState)
return true return true
}, [addFloatingHeal]) }, [addFloatingHeal, starterSpells])
const castPlayerSpell = useCallback((spell: Spell) => { const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || !playerAlive) return if (status !== 'playing' || !playerAlive) return
@@ -695,13 +854,14 @@ export function PvpStadiumScreen({
const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0) const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0)
const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold) const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold)
const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold) const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold)
const spellBySlot = (slot: SlotKey) => starterSpells.find((candidate) => candidate.key === slot)
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [ const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
{ spell: cleanseTarget ? starterSpells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null }, { spell: cleanseTarget ? spellBySlot('5') : undefined, targetId: cleanseTarget?.id ?? null },
{ spell: averageHealth < behavior.groupHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null }, { spell: averageHealth < behavior.groupHealThreshold ? spellBySlot('3') : undefined, targetId: lowest?.id ?? null },
{ spell: shieldTarget ? starterSpells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null }, { spell: shieldTarget ? spellBySlot('4') : undefined, targetId: shieldTarget?.id ?? null },
{ spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest.id }, { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? spellBySlot('1') : undefined, targetId: lowest.id },
{ spell: renewTarget ? starterSpells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null }, { spell: renewTarget ? spellBySlot('2') : undefined, targetId: renewTarget?.id ?? null },
{ spell: tank ? starterSpells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null }, { spell: tank ? spellBySlot('1') : undefined, targetId: tank?.id ?? null },
] ]
for (const action of ordered) { for (const action of ordered) {
if (!action.spell || !action.targetId) continue if (!action.spell || !action.targetId) continue
@@ -730,14 +890,18 @@ export function PvpStadiumScreen({
let damage = tankIds.has(member.id) ? 8 : 0 let damage = tankIds.has(member.id) ? 8 : 0
if (pulse) damage += 9 if (pulse) damage += 9
if (spike && member.id === spikeTarget.id) damage += 22 if (spike && member.id === spikeTarget.id) damage += 22
const mitigatedDamage = member.damageReductionTicks && member.damageReductionTicks > 0
? Math.ceil(damage * 0.5)
: damage
const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0 const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0
const absorbed = Math.min(member.shield, damage) const absorbed = Math.min(member.shield, mitigatedDamage)
const nextHealth = clamp(member.health - damage + absorbed + hotHealing, 0, effectiveMaxHealth(member)) const nextHealth = clamp(member.health - mitigatedDamage + absorbed + hotHealing, 0, effectiveMaxHealth(member))
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
shield: Math.max(0, member.shield - damage), shield: Math.max(0, member.shield - mitigatedDamage),
hotTicks: Math.max(0, member.hotTicks - 1), hotTicks: Math.max(0, member.hotTicks - 1),
damageReductionTicks: member.damageReductionTicks ? Math.max(0, member.damageReductionTicks - 1) : undefined,
debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff, debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff,
debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined, debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined,
} }
@@ -745,7 +909,7 @@ export function PvpStadiumScreen({
return { return {
...side, ...side,
party: nextParty, party: nextParty,
resource: clamp(side.resource + 2.4, 0, MAX_RESOURCE), resource: clamp(side.resource + RESOURCE_REGEN_PER_TICK, 0, MAX_RESOURCE),
cooldowns: Object.fromEntries( cooldowns: Object.fromEntries(
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
), ),
@@ -788,6 +952,8 @@ export function PvpStadiumScreen({
const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => { const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => {
if (status !== 'playing') return if (status !== 'playing') return
if (roundResolvedRef.current) return
roundResolvedRef.current = true
const key = `round-${roundIndex}-${outcome}` const key = `round-${roundIndex}-${outcome}`
if (outcome === 'win' || outcome === 'tie') { if (outcome === 'win' || outcome === 'tie') {
awardXp(key, 'pvp-stadium-round-win-quarter-level') awardXp(key, 'pvp-stadium-round-win-quarter-level')
@@ -856,6 +1022,8 @@ export function PvpStadiumScreen({
const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent) const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent)
playerRef.current = nextPlayer playerRef.current = nextPlayer
cpuRef.current = nextCpu cpuRef.current = nextCpu
roundResolvedRef.current = false
loggedOpponentRoundRef.current = ''
setRoundIndex(nextRound) setRoundIndex(nextRound)
setPlayerSide(nextPlayer) setPlayerSide(nextPlayer)
setCpuSide(nextCpu) setCpuSide(nextCpu)
@@ -863,8 +1031,9 @@ export function PvpStadiumScreen({
setElapsedTicks(0) setElapsedTicks(0)
setShopReady(false) setShopReady(false)
setShopPoints(0) setShopPoints(0)
addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system')
beginRoundCountdown() beginRoundCountdown()
}, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) }, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId])
const finishShop = useCallback(() => { const finishShop = useCallback(() => {
if (shopReady || status !== 'shop') return if (shopReady || status !== 'shop') return
@@ -1124,7 +1293,104 @@ export function PvpStadiumScreen({
</div> </div>
)} )}
{status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<div className="dual-top-main stadium-dual-top">
<header className="stadium-header">
<div>
<p className="eyebrow">Stadium</p>
<h2>Round {roundIndex} / Best of 5</h2>
</div>
<strong>{roundWins.player} - {roundWins.opponent}</strong>
<div>
<p>Dampening {playerSide.dampeningPercent}%</p>
<small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
</div>
</header>
<section className="stadium-pressure-panel">
<strong>Iron Arbiter</strong>
<span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
</section>
<section className="dual-top-party">
<div className="dual-top-party-grid">
{playerSide.party.map((member, index) => {
const action = `targetParty${index + 1}` as InputAction
const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null
return (
<button
className={`dual-top-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => setSelectedTargetId(member.id)}
type="button"
>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
<small>{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
.filter((entry) => entry.side === 'player' && entry.memberId === member.id)
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
{targetBinding && (
<div className="member-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={controllerIconStyle}
/>
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
)
})}
</div>
</section>
<section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady)
const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
className="dual-top-spell"
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
key={spell.id}
onClick={() => castPlayerSpell(spell)}
type="button"
>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
{remaining > 0 && <i style={{ height: `${percent}%` }} />}
{remaining > 0 && <small>{remaining.toFixed(0)}</small>}
<span className="sr-only">{slotIndex + 1}. {spell.name}</span>
</button>
)
})}
<div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
</div>
</div>
</section>
</div>
)}
{!dualScreenEnabled && status !== 'queueing' && (
<div className="stadium-board"> <div className="stadium-board">
<header className="stadium-header"> <header className="stadium-header">
<div> <div>