Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbe42b6164 | |||
| c2888c287b |
Binary file not shown.
Binary file not shown.
@@ -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 79
|
||||||
versionName "1.0.58"
|
versionName "1.0.60"
|
||||||
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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Intent;
|
|||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.SystemClock;
|
import android.os.SystemClock;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -37,6 +38,18 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
if (hasFocus) enableImmersiveMode();
|
if (hasFocus) enableImmersiveMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchTouchEvent(MotionEvent event) {
|
||||||
|
if (
|
||||||
|
event.getActionMasked() == MotionEvent.ACTION_DOWN
|
||||||
|
&& bridge != null
|
||||||
|
&& bridge.getWebView() != null
|
||||||
|
) {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
}
|
||||||
|
return super.dispatchTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
private void loadIntentUrl() {
|
private void loadIntentUrl() {
|
||||||
if (bridge == null || getIntent() == null) return;
|
if (bridge == null || getIntent() == null) return;
|
||||||
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL);
|
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL);
|
||||||
@@ -86,7 +99,10 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||||
bridge.getWebView().post(
|
bridge.getWebView().post(
|
||||||
() -> bridge.getWebView().evaluateJavascript(script, null)
|
() -> {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
bridge.getWebView().evaluateJavascript(script, null);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2782,6 +2782,19 @@ function updatePvpMatchState(session, matchId, payload) {
|
|||||||
alive: Boolean(payload.alive),
|
alive: Boolean(payload.alive),
|
||||||
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
||||||
}
|
}
|
||||||
|
const currentProgress = match.progress[side]
|
||||||
|
if (
|
||||||
|
currentProgress
|
||||||
|
&& (
|
||||||
|
progress.stage < currentProgress.stage
|
||||||
|
|| (
|
||||||
|
progress.stage === currentProgress.stage
|
||||||
|
&& progress.encounterIndex < currentProgress.encounterIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
match.states[side] = payload.state ?? null
|
match.states[side] = payload.state ?? null
|
||||||
match.statuses[side] = status
|
match.statuses[side] = status
|
||||||
match.progress[side] = progress
|
match.progress[side] = progress
|
||||||
|
|||||||
+24
-11
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -349,6 +453,7 @@ export function PvpStadiumScreen({
|
|||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
|
const partyColumns = 3
|
||||||
|
|
||||||
const setSelectedTargetId = useCallback((id: string) => {
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
selectedIdRef.current = id
|
selectedIdRef.current = id
|
||||||
@@ -376,6 +481,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 +549,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 +584,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 +696,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 +733,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 +795,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 +807,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 +829,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
|
||||||
@@ -682,6 +842,55 @@ export function PvpStadiumScreen({
|
|||||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||||
}, [addLog, applySpell, playerAlive, status])
|
}, [addLog, applySpell, playerAlive, 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 === selectedIdRef.current)
|
||||||
|
const nextIndex = currentIndex < 0
|
||||||
|
? 0
|
||||||
|
: (currentIndex + direction + living.length) % living.length
|
||||||
|
setSelectedTargetId(living[nextIndex].id)
|
||||||
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||||
|
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) setSelectedTargetId(firstLiving.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||||
|
const currentColumn = currentIndex % partyColumns
|
||||||
|
const candidates = playerRef.current.party
|
||||||
|
.map((member, index) => ({
|
||||||
|
member,
|
||||||
|
index,
|
||||||
|
row: Math.floor(index / partyColumns),
|
||||||
|
column: index % partyColumns,
|
||||||
|
}))
|
||||||
|
.filter(({ member, index, row, column }) => {
|
||||||
|
if (member.health <= 0 || index === currentIndex) return false
|
||||||
|
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||||
|
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||||
|
if (action === 'navigateUp') return row < currentRow
|
||||||
|
return row > currentRow
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
|
||||||
|
const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
|
||||||
|
const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
|
||||||
|
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||||
|
})
|
||||||
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||||
|
}, [partyColumns, setSelectedTargetId])
|
||||||
|
|
||||||
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
|
const member = playerRef.current.party[slot]
|
||||||
|
if (member?.health > 0) setSelectedTargetId(member.id)
|
||||||
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
const cpuTakeTurn = useCallback(() => {
|
const cpuTakeTurn = useCallback(() => {
|
||||||
if (!cpuDifficulty || status !== 'playing') return
|
if (!cpuDifficulty || status !== 'playing') return
|
||||||
const behavior = CPU_BEHAVIOR[cpuDifficulty]
|
const behavior = CPU_BEHAVIOR[cpuDifficulty]
|
||||||
@@ -695,13 +904,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 +940,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 +959,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 +1002,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 +1072,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 +1081,21 @@ export function PvpStadiumScreen({
|
|||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setShopReady(false)
|
setShopReady(false)
|
||||||
setShopPoints(0)
|
setShopPoints(0)
|
||||||
|
addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system')
|
||||||
|
if (liveMatchRef.current) {
|
||||||
|
publishPvpMatchState<StadiumSideState>(liveMatchRef.current.id, {
|
||||||
|
state: nextPlayer,
|
||||||
|
status: 'playing',
|
||||||
|
stage: nextRound,
|
||||||
|
encounterIndex: nextRound,
|
||||||
|
encountersCleared: roundWins.player,
|
||||||
|
enemyHealth: 0,
|
||||||
|
alive: true,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
}).catch(() => undefined)
|
||||||
|
}
|
||||||
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
|
||||||
@@ -917,7 +1148,7 @@ export function PvpStadiumScreen({
|
|||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
if (stopped) return
|
if (stopped) return
|
||||||
const opponentState = snapshot.states[liveMatch.opponentSide]
|
const opponentState = snapshot.states[liveMatch.opponentSide]
|
||||||
if (opponentState) {
|
if (opponentState && opponentState.roundIndex >= roundIndex) {
|
||||||
cpuRef.current = opponentState
|
cpuRef.current = opponentState
|
||||||
setCpuSide(opponentState)
|
setCpuSide(opponentState)
|
||||||
}
|
}
|
||||||
@@ -925,6 +1156,7 @@ export function PvpStadiumScreen({
|
|||||||
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost')
|
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost')
|
||||||
if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won')
|
if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won')
|
||||||
if (!opponentState) return
|
if (!opponentState) return
|
||||||
|
if (opponentState.roundIndex < roundIndex) return
|
||||||
if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) {
|
if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) {
|
||||||
const key = `${roundIndex}-${opponentState.lastRoundOutcome}`
|
const key = `${roundIndex}-${opponentState.lastRoundOutcome}`
|
||||||
if (loggedOpponentRoundRef.current === key) return
|
if (loggedOpponentRoundRef.current === key) return
|
||||||
@@ -933,7 +1165,14 @@ export function PvpStadiumScreen({
|
|||||||
else if (opponentState.lastRoundOutcome === 'win') finishRound('loss')
|
else if (opponentState.lastRoundOutcome === 'win') finishRound('loss')
|
||||||
else finishRound('tie')
|
else finishRound('tie')
|
||||||
}
|
}
|
||||||
if (status === 'shop' && shopReady && opponentState.roundIndex === roundIndex && opponentState.shopReady) {
|
if (
|
||||||
|
status === 'shop'
|
||||||
|
&& shopReady
|
||||||
|
&& (
|
||||||
|
(opponentState.roundIndex === roundIndex && opponentState.shopReady)
|
||||||
|
|| opponentState.roundIndex > roundIndex
|
||||||
|
)
|
||||||
|
) {
|
||||||
startNextRound()
|
startNextRound()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1009,15 +1248,31 @@ export function PvpStadiumScreen({
|
|||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
}, [status])
|
}, [status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paused) return
|
||||||
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
|
}, [paused])
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (action === 'pause') {
|
if (action === 'pause' || action === 'back') {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (paused || status !== 'playing') return
|
||||||
|
if (action.startsWith('navigate')) {
|
||||||
|
selectDirectionalTarget(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'previousTarget') {
|
||||||
|
selectRelativeTarget(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'nextTarget') {
|
||||||
|
selectRelativeTarget(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.startsWith('targetParty')) {
|
if (action.startsWith('targetParty')) {
|
||||||
const index = Number(action.slice('targetParty'.length)) - 1
|
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
||||||
const member = playerRef.current.party[index]
|
|
||||||
if (member?.health > 0) setSelectedTargetId(member.id)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action.startsWith('ability')) {
|
if (action.startsWith('ability')) {
|
||||||
@@ -1124,7 +1379,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>
|
||||||
|
|||||||
+59
-16
@@ -125,6 +125,9 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
|||||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||||
|
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
let lastControllerFocus: HTMLElement | null = null
|
||||||
|
|
||||||
type CaptureState = {
|
type CaptureState = {
|
||||||
device: InputDevice
|
device: InputDevice
|
||||||
@@ -234,25 +237,41 @@ function focusableElements() {
|
|||||||
).find(isVisible)
|
).find(isVisible)
|
||||||
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
||||||
return Array.from(
|
return Array.from(
|
||||||
scope.querySelectorAll<HTMLElement>(
|
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
|
||||||
),
|
|
||||||
).filter(isVisible)
|
).filter(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberFocusableControl(element: HTMLElement) {
|
||||||
|
lastControllerFocus = element
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusControl(element: HTMLElement) {
|
||||||
|
rememberFocusableControl(element)
|
||||||
|
element.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFocusableControl(candidates = focusableElements()) {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active instanceof HTMLElement && candidates.includes(active)) {
|
||||||
|
rememberFocusableControl(active)
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
||||||
|
return lastControllerFocus
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function focusFirstControl() {
|
export function focusFirstControl() {
|
||||||
const first = focusableElements()[0]
|
const first = focusableElements()[0]
|
||||||
first?.focus({ preventScroll: true })
|
if (first) focusControl(first)
|
||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveFocus(action: InputAction) {
|
function moveFocus(action: InputAction) {
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
const current = document.activeElement instanceof HTMLElement
|
const current = currentFocusableControl(candidates)
|
||||||
&& candidates.includes(document.activeElement)
|
|
||||||
? document.activeElement
|
|
||||||
: null
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
return
|
return
|
||||||
@@ -279,7 +298,7 @@ function moveFocus(action: InputAction) {
|
|||||||
|
|
||||||
const next = ranked[0]?.candidate
|
const next = ranked[0]?.candidate
|
||||||
if (!next) return
|
if (!next) return
|
||||||
next.focus({ preventScroll: true })
|
focusControl(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasUiOverlay() {
|
function hasUiOverlay() {
|
||||||
@@ -456,12 +475,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
if (action.startsWith('navigate')) {
|
if (action.startsWith('navigate')) {
|
||||||
if (uiOverlay || !combatActive) moveFocus(action)
|
if (uiOverlay || !combatActive) moveFocus(action)
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = document.activeElement
|
const active = currentFocusableControl()
|
||||||
if (isTextInput(active)) {
|
if (isTextInput(active)) {
|
||||||
setKeyboardInput(active)
|
setKeyboardInput(active)
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
} else if (
|
} else if (
|
||||||
active instanceof HTMLElement
|
active
|
||||||
&& active.matches('button:not(:disabled), [role="button"]')
|
&& active.matches('button:not(:disabled), [role="button"]')
|
||||||
&& isVisible(active)
|
&& isVisible(active)
|
||||||
) {
|
) {
|
||||||
@@ -581,6 +600,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [assignBinding, dispatchAction])
|
}, [assignBinding, dispatchAction])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
||||||
|
rememberFocusableControl(target)
|
||||||
|
}
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
document.documentElement.dataset.inputDevice = 'pc'
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Element)) return
|
||||||
|
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
|
if (!control || !isVisible(control)) return
|
||||||
|
rememberFocusableControl(control)
|
||||||
|
}
|
||||||
|
document.addEventListener('focusin', onFocusIn)
|
||||||
|
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('focusin', onFocusIn)
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown, { capture: true })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event: Event) => {
|
const listener = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
|
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
|
||||||
@@ -595,18 +637,19 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||||
if (combatActive) return
|
if (combatActive) return
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
const active = document.activeElement
|
const activeControl = currentFocusableControl(candidates)
|
||||||
const activeIsUsable = active instanceof HTMLElement
|
|
||||||
&& candidates.includes(active)
|
|
||||||
&& isVisible(active)
|
|
||||||
if (
|
if (
|
||||||
(!activeIsUsable || document.activeElement === document.body)
|
(!activeControl || document.activeElement === document.body)
|
||||||
&& !keyboardInputRef.current
|
&& !keyboardInputRef.current
|
||||||
&& !captureRef.current
|
&& !captureRef.current
|
||||||
) {
|
) {
|
||||||
|
if (activeControl) {
|
||||||
|
focusControl(activeControl)
|
||||||
|
} else {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
window.requestAnimationFrame(ensureFocus)
|
window.requestAnimationFrame(ensureFocus)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user