Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbe42b6164 | |||
| c2888c287b | |||
| 4703017832 | |||
| c0f2daccb1 | |||
| abdf4cc654 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 74
|
versionCode 79
|
||||||
versionName "1.0.55"
|
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;
|
||||||
|
|||||||
+64
-5
@@ -2307,7 +2307,16 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
||||||
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? 'pvp-boss-quarter-level'
|
? 'pvp-boss-quarter-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
|
||||||
|
? 'pvp-fight-twelfth-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
? 'pvp-stadium-round-win-quarter-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
? 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
|
||||||
|
? 'pvp-stadium-match-half-level'
|
||||||
: 'default'
|
: 'default'
|
||||||
|
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
|
||||||
const resourceSpent = Number(runMetrics?.resourceSpent)
|
const resourceSpent = Number(runMetrics?.resourceSpent)
|
||||||
const durationSeconds = Number(runMetrics?.durationSeconds)
|
const durationSeconds = Number(runMetrics?.durationSeconds)
|
||||||
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
||||||
@@ -2322,6 +2331,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
||||||
throw new Error('The roguelike boss total is invalid.')
|
throw new Error('The roguelike boss total is invalid.')
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
|
||||||
|
throw new Error('The roguelike fight total is invalid.')
|
||||||
|
}
|
||||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
||||||
throw new Error('The run resource total is invalid.')
|
throw new Error('The run resource total is invalid.')
|
||||||
}
|
}
|
||||||
@@ -2374,14 +2386,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
`).get(maxLevel).experienceRequired
|
`).get(maxLevel).experienceRequired
|
||||||
let newExperience = character.experience
|
let newExperience = character.experience
|
||||||
let newLevel = character.level
|
let newLevel = character.level
|
||||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
|
||||||
const catchUpTargetLevel = database.prepare(`
|
const catchUpTargetLevel = database.prepare(`
|
||||||
SELECT COALESCE(MAX(level), 0) AS level
|
SELECT COALESCE(MAX(level), 0) AS level
|
||||||
FROM characters
|
FROM characters
|
||||||
WHERE account_id = ?
|
WHERE account_id = ?
|
||||||
AND id != ?
|
AND id != ?
|
||||||
`).get(accountId, characterId).level
|
`).get(accountId, characterId).level
|
||||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
|
||||||
|
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
|
||||||
const currentLevelFloor = database.prepare(`
|
const currentLevelFloor = database.prepare(`
|
||||||
SELECT experience_required AS experienceRequired
|
SELECT experience_required AS experienceRequired
|
||||||
FROM level_progression
|
FROM level_progression
|
||||||
@@ -2395,7 +2408,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(newLevel + 1).experienceRequired
|
`).get(newLevel + 1).experienceRequired
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
|
||||||
|
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
|
||||||
|
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
|
||||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
@@ -2403,6 +2418,35 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE experience_required <= ?
|
WHERE experience_required <= ?
|
||||||
`).get(newExperience).level
|
`).get(newExperience).level
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
|| experienceMode === 'pvp-stadium-match-half-level'
|
||||||
|
) {
|
||||||
|
const currentLevelFloor = database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(newLevel).experienceRequired
|
||||||
|
const nextLevelExperience = newLevel >= maxLevel
|
||||||
|
? maxExperience
|
||||||
|
: database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(newLevel + 1).experienceRequired
|
||||||
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
|
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
? 0.25
|
||||||
|
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
? 0.1
|
||||||
|
: 0.5
|
||||||
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
|
newLevel = database.prepare(`
|
||||||
|
SELECT MAX(level) AS level
|
||||||
|
FROM level_progression
|
||||||
|
WHERE experience_required <= ?
|
||||||
|
`).get(newExperience).level
|
||||||
} else {
|
} else {
|
||||||
const baseExperienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||||
@@ -2555,7 +2599,7 @@ function cleanupPvpMemory(now = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validatePvpContentType(value) {
|
function validatePvpContentType(value) {
|
||||||
if (value !== 'dungeon' && value !== 'raid') {
|
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
|
||||||
throw new Error('The PvP content type is invalid.')
|
throw new Error('The PvP content type is invalid.')
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
@@ -2727,7 +2771,7 @@ function requirePvpMatchForSession(session, matchId) {
|
|||||||
|
|
||||||
function updatePvpMatchState(session, matchId, payload) {
|
function updatePvpMatchState(session, matchId, payload) {
|
||||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
|
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
|
||||||
? payload.status
|
? payload.status
|
||||||
: 'playing'
|
: 'playing'
|
||||||
const progress = {
|
const progress = {
|
||||||
@@ -2738,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
|
||||||
@@ -2753,6 +2810,8 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
|
|||||||
encounterIndex,
|
encounterIndex,
|
||||||
buffId: String(payload.buffId ?? ''),
|
buffId: String(payload.buffId ?? ''),
|
||||||
debuffId: String(payload.debuffId ?? ''),
|
debuffId: String(payload.debuffId ?? ''),
|
||||||
|
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
|
||||||
|
shopReady: Boolean(payload.shopReady),
|
||||||
}
|
}
|
||||||
match.updatedAt = Date.now()
|
match.updatedAt = Date.now()
|
||||||
return pvpSnapshot(match)
|
return pvpSnapshot(match)
|
||||||
|
|||||||
+196
@@ -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 {
|
||||||
@@ -5888,6 +5900,33 @@ h2 {
|
|||||||
z-index: 10;
|
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,
|
.result-screen > div,
|
||||||
.pause-screen > div {
|
.pause-screen > div {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -6372,6 +6411,150 @@ h2 {
|
|||||||
box-shadow: inset 0 0 0 2px #6e5727;
|
box-shadow: inset 0 0 0 2px #6e5727;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stadium-screen {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
min-height: calc(100dvh - 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-header,
|
||||||
|
.stadium-pressure-panel {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
box-shadow: 4px 4px 0 #08090c;
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: space-between;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-header > strong {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-header p,
|
||||||
|
.stadium-header small,
|
||||||
|
.stadium-pressure-panel span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-pressure-panel strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-side {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-dialog {
|
||||||
|
max-width: 1180px !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
text-align: left !important;
|
||||||
|
width: min(1180px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-summary {
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-tabs {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-tabs button,
|
||||||
|
.stadium-shop-grid button {
|
||||||
|
background: #303545;
|
||||||
|
border: 2px solid #0b0c0f;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
outline: 2px solid #4d4c58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-tabs button {
|
||||||
|
font-size: 9px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-tabs button.active {
|
||||||
|
background: #303427;
|
||||||
|
outline-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-layout h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid button {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 128px;
|
||||||
|
padding: 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid strong {
|
||||||
|
color: #fff0b8;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid small,
|
||||||
|
.stadium-shop-layout p {
|
||||||
|
color: #f0e8d2;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress.stadium {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.result-screen button,
|
.result-screen button,
|
||||||
.pause-screen button {
|
.pause-screen button {
|
||||||
background: var(--gold);
|
background: var(--gold);
|
||||||
@@ -6884,11 +7067,24 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-board,
|
.pvp-board,
|
||||||
|
.stadium-board,
|
||||||
|
.stadium-shop-layout,
|
||||||
.pvp-choice-columns,
|
.pvp-choice-columns,
|
||||||
.pvp-choice-columns .upgrade-choice-grid {
|
.pvp-choice-columns .upgrade-choice-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stadium-header,
|
||||||
|
.stadium-pressure-panel,
|
||||||
|
.stadium-shop-summary {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stadium-shop-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.active-target-card,
|
.active-target-card,
|
||||||
.mana-wrap {
|
.mana-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+25
-2
@@ -5,6 +5,7 @@ import { AuthScreen } from './components/AuthScreen'
|
|||||||
import { CustomizeScreen } from './components/CustomizeScreen'
|
import { CustomizeScreen } from './components/CustomizeScreen'
|
||||||
import { EquipmentScreen } from './components/EquipmentScreen'
|
import { EquipmentScreen } from './components/EquipmentScreen'
|
||||||
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
|
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
|
||||||
|
import { PvpStadiumScreen } from './components/PvpStadiumScreen'
|
||||||
import { TalentScreen } from './components/TalentScreen'
|
import { TalentScreen } from './components/TalentScreen'
|
||||||
import { SettingsScreen } from './components/SettingsScreen'
|
import { SettingsScreen } from './components/SettingsScreen'
|
||||||
import {
|
import {
|
||||||
@@ -253,6 +254,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (screen === 'pvp') {
|
if (screen === 'pvp') {
|
||||||
|
if (pvpContentType === 'stadium') {
|
||||||
|
return (
|
||||||
|
<PvpStadiumScreen
|
||||||
|
gameMode={gameMode}
|
||||||
|
onExit={() => {
|
||||||
|
setRoguelikeVariant('pvp')
|
||||||
|
setScreen('roguelike')
|
||||||
|
}}
|
||||||
|
onProfileUpdated={setProfile}
|
||||||
|
profile={profile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
const pvpPool = profile.dungeons
|
const pvpPool = profile.dungeons
|
||||||
.filter((candidate) => candidate.contentType === pvpContentType)
|
.filter((candidate) => candidate.contentType === pvpContentType)
|
||||||
.flatMap((candidate) => candidate.encounters)
|
.flatMap((candidate) => candidate.encounters)
|
||||||
@@ -547,6 +561,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
Raid
|
Raid
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
|
||||||
|
onClick={() => setPvpContentType('stadium')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Stadium
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-card pvp-queue-panel">
|
<div className="menu-card pvp-queue-panel">
|
||||||
@@ -554,7 +575,9 @@ function App() {
|
|||||||
<div>
|
<div>
|
||||||
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
||||||
<small>
|
<small>
|
||||||
{gameMode === 'offline'
|
{pvpContentType === 'stadium'
|
||||||
|
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
|
||||||
|
: gameMode === 'offline'
|
||||||
? 'Offline mode always places you against a random CPU 1-5.'
|
? 'Offline mode always places you against a random CPU 1-5.'
|
||||||
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
||||||
</small>
|
</small>
|
||||||
@@ -572,7 +595,7 @@ function App() {
|
|||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">CPU Leaderboard</p>
|
<p className="eyebrow">CPU Leaderboard</p>
|
||||||
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="leaderboard-table">
|
<div className="leaderboard-table">
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ import {
|
|||||||
} from '../pvpRoguelike'
|
} from '../pvpRoguelike'
|
||||||
|
|
||||||
const TICK_MS = 700
|
const TICK_MS = 700
|
||||||
const UPGRADE_CHOICE_SECONDS = 10
|
const ROUND_START_SECONDS = 3
|
||||||
|
const UPGRADE_CHOICE_SECONDS = 15
|
||||||
|
|
||||||
type BossMechanic =
|
type BossMechanic =
|
||||||
| 'party-pulse'
|
| 'party-pulse'
|
||||||
@@ -57,24 +58,18 @@ type PvpEncounter = DungeonEncounter & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
|
type DraftSlotKey = Exclude<SlotKey, '6'>
|
||||||
type AbilityLabelMode = 'ability' | 'slot'
|
type AbilityLabelMode = 'ability' | 'slot'
|
||||||
|
|
||||||
type SelfBuffId =
|
type SelfBuffId =
|
||||||
| `slot${SlotKey}-extra-target`
|
| 'revive-party-members'
|
||||||
| `slot${SlotKey}-cost-down`
|
| `slot${DraftSlotKey}-extra-target`
|
||||||
| `slot${SlotKey}-cooldown-down`
|
| `slot${DraftSlotKey}-cost-down`
|
||||||
| 'fifth-cast-free'
|
| `slot${DraftSlotKey}-cooldown-down`
|
||||||
| 'group-heal-boost'
|
|
||||||
| 'shield-boost'
|
|
||||||
|
|
||||||
type OpponentDebuffId =
|
type OpponentDebuffId =
|
||||||
| `opp-slot${SlotKey}-cost-up`
|
| `opp-slot${DraftSlotKey}-cost-up`
|
||||||
| `opp-slot${SlotKey}-cooldown-up`
|
| `opp-slot${DraftSlotKey}-cooldown-up`
|
||||||
| 'opp-takes-more-damage'
|
|
||||||
| 'opp-healing-reduced'
|
|
||||||
| 'opp-resource-regen-down'
|
|
||||||
| 'opp-cleanse-cooldown-up'
|
|
||||||
| 'opp-purge-random-buff'
|
|
||||||
|
|
||||||
type Choice<T extends string> = {
|
type Choice<T extends string> = {
|
||||||
id: T
|
id: T
|
||||||
@@ -119,6 +114,12 @@ type LivePvpMatch = {
|
|||||||
opponentClassName: string
|
opponentClassName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
|
||||||
|
id: 'revive-party-members',
|
||||||
|
name: 'Revive Party Members',
|
||||||
|
description: 'Revive fallen party members before the next fight.',
|
||||||
|
}
|
||||||
|
|
||||||
const BOSS_MECHANICS: BossMechanic[] = [
|
const BOSS_MECHANICS: BossMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -184,7 +185,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
||||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => {
|
||||||
const label = slotLabel(slot, spells, labelMode)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -204,16 +205,10 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
return [
|
|
||||||
...slotChoices,
|
|
||||||
{ id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the next cast is free.' },
|
|
||||||
{ id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.' },
|
|
||||||
{ id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.' },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
||||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
return (['1', '2', '3', '4', '5'] as DraftSlotKey[]).flatMap((slot) => {
|
||||||
const label = slotLabel(slot, spells, labelMode)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -228,14 +223,6 @@ function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
return [
|
|
||||||
...slotChoices,
|
|
||||||
{ id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' },
|
|
||||||
{ id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' },
|
|
||||||
{ id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' },
|
|
||||||
{ id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' },
|
|
||||||
{ id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCombatSpell(ability: Ability, key: string): Spell {
|
function toCombatSpell(ability: Ability, key: string): Spell {
|
||||||
@@ -263,17 +250,10 @@ function effectiveMaxHealth(member: PartyMember) {
|
|||||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) {
|
|
||||||
return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage')
|
|
||||||
}
|
|
||||||
|
|
||||||
function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
|
||||||
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
|
||||||
}
|
|
||||||
|
|
||||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||||
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
||||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
|
void debuffs
|
||||||
|
return Math.round(amount * healingReduction * multiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||||
@@ -284,8 +264,7 @@ function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: Opponent
|
|||||||
const slot = spell.key as SlotKey
|
const slot = spell.key as SlotKey
|
||||||
const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
|
const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
|
||||||
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId)
|
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId)
|
||||||
const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0
|
return (0.75 ** downStacks) * (1.25 ** upStacks)
|
||||||
return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) {
|
function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) {
|
||||||
@@ -293,7 +272,8 @@ function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentD
|
|||||||
const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
|
const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
|
||||||
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
|
const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
|
||||||
const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks))
|
const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks))
|
||||||
return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost
|
void freeCastReady
|
||||||
|
return adjustedCost
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
||||||
@@ -350,10 +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<SelfBuffId>, spells: Spell[]) {
|
function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
||||||
if (buff.id === 'fifth-cast-free') return 8
|
if (buff.id === 'revive-party-members') return 10
|
||||||
if (buff.id === 'group-heal-boost') return 8
|
|
||||||
if (buff.id === 'shield-boost') return 6
|
|
||||||
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
||||||
const spell = spells.find((candidate) => candidate.key === slot)
|
const spell = spells.find((candidate) => candidate.key === slot)
|
||||||
if (!spell) return 5
|
if (!spell) return 5
|
||||||
@@ -367,11 +369,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
|
function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
|
||||||
if (debuff.id === 'opp-takes-more-damage') return 9
|
void opponentBuffCount
|
||||||
if (debuff.id === 'opp-healing-reduced') return 8
|
|
||||||
if (debuff.id === 'opp-resource-regen-down') return 7
|
|
||||||
if (debuff.id === 'opp-cleanse-cooldown-up') return 5
|
|
||||||
if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2
|
|
||||||
if (debuff.id.endsWith('cost-up')) return 7
|
if (debuff.id.endsWith('cost-up')) return 7
|
||||||
return 6
|
return 6
|
||||||
}
|
}
|
||||||
@@ -388,13 +386,6 @@ function selectCpuChoice<T extends string>(
|
|||||||
return ranked[0]
|
return ranked[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRandomBuff(side: SideState) {
|
|
||||||
if (side.buffs.length === 0) return side
|
|
||||||
const nextBuffs = [...side.buffs]
|
|
||||||
nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1)
|
|
||||||
return { ...side, buffs: nextBuffs }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
|
function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
|
||||||
return { id: nextLogId.current++, text, tone }
|
return { id: nextLogId.current++, text, tone }
|
||||||
}
|
}
|
||||||
@@ -458,7 +449,7 @@ export function PvPRoguelikeScreen({
|
|||||||
})),
|
})),
|
||||||
[contentType],
|
[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 [stage, setStage] = useState(startStage)
|
||||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
||||||
const [encounterIndex, setEncounterIndex] = useState(0)
|
const [encounterIndex, setEncounterIndex] = useState(0)
|
||||||
@@ -484,6 +475,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||||
|
const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS)
|
||||||
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
||||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
@@ -498,6 +490,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const queuedMatchRef = useRef(false)
|
const queuedMatchRef = useRef(false)
|
||||||
const upgradeChoiceEndsAtRef = useRef(0)
|
const upgradeChoiceEndsAtRef = useRef(0)
|
||||||
const autoSubmittedUpgradeRef = useRef(false)
|
const autoSubmittedUpgradeRef = useRef(false)
|
||||||
|
const roundCountdownTimerRef = useRef<number | null>(null)
|
||||||
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
||||||
const loggedOpponentDoneRef = useRef(false)
|
const loggedOpponentDoneRef = useRef(false)
|
||||||
const pendingLiveUpgradeRef = useRef<{
|
const pendingLiveUpgradeRef = useRef<{
|
||||||
@@ -560,6 +553,29 @@ export function PvPRoguelikeScreen({
|
|||||||
}, 900)
|
}, 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(() => {
|
useEffect(() => {
|
||||||
if (queuedMatchRef.current) return
|
if (queuedMatchRef.current) return
|
||||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
@@ -571,10 +587,11 @@ export function PvPRoguelikeScreen({
|
|||||||
encounterPoolRef.current = encounterPool
|
encounterPoolRef.current = encounterPool
|
||||||
}, [encounterPool])
|
}, [encounterPool])
|
||||||
|
|
||||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
const awardEncounterReward = useCallback((encounterIndexValue: number) => {
|
||||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||||
const rewardEncounter = encounters[encounterIndexValue]
|
const rewardEncounter = encounters[encounterIndexValue]
|
||||||
|
const isBossReward = Boolean(rewardEncounter?.isBoss)
|
||||||
completeRoguelike(
|
completeRoguelike(
|
||||||
rewardDungeon.id,
|
rewardDungeon.id,
|
||||||
rewardDifficulty.id,
|
rewardDifficulty.id,
|
||||||
@@ -582,10 +599,11 @@ export function PvPRoguelikeScreen({
|
|||||||
0,
|
0,
|
||||||
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
||||||
{
|
{
|
||||||
bossesCleared: 1,
|
bossesCleared: isBossReward ? 1 : 0,
|
||||||
experienceMode: 'pvp-boss-quarter-level',
|
fightsCleared: 1,
|
||||||
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
|
||||||
roguelikeStage: stage,
|
lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
|
||||||
|
roguelikeStage: isBossReward ? stage : undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -594,7 +612,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
|
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
|
||||||
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
|
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||||
return {
|
return {
|
||||||
bossesKilled: current.bossesKilled + 1,
|
bossesKilled: current.bossesKilled + (isBossReward ? 1 : 0),
|
||||||
experienceGained: current.experienceGained + result.experienceGained,
|
experienceGained: current.experienceGained + result.experienceGained,
|
||||||
previousLevel: current.previousLevel ?? result.previousLevel,
|
previousLevel: current.previousLevel ?? result.previousLevel,
|
||||||
newLevel: result.newLevel,
|
newLevel: result.newLevel,
|
||||||
@@ -605,6 +623,9 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
|
if (!isBossReward && result.experienceGained > 0) {
|
||||||
|
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||||
|
}
|
||||||
if (result.bonusItem) {
|
if (result.bonusItem) {
|
||||||
addLog(
|
addLog(
|
||||||
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||||
@@ -626,13 +647,15 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlayerBuffChoices((current) => current
|
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<SelfBuffId> => Boolean(choice)))
|
.filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
|
||||||
setPlayerDebuffChoices((current) => current
|
setPlayerDebuffChoices((current) => current
|
||||||
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
|
.map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
|
||||||
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
|
.filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
|
||||||
setSelectedBuff((current) => current
|
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)
|
: null)
|
||||||
setSelectedDebuff((current) => current
|
setSelectedDebuff((current) => current
|
||||||
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
|
? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
|
||||||
@@ -679,7 +702,6 @@ export function PvPRoguelikeScreen({
|
|||||||
setStartStage(matchStartStage)
|
setStartStage(matchStartStage)
|
||||||
setStage(matchStartStage)
|
setStage(matchStartStage)
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('playing')
|
|
||||||
setPlayerSide(basePlayer)
|
setPlayerSide(basePlayer)
|
||||||
setCpuSide(baseOpponent)
|
setCpuSide(baseOpponent)
|
||||||
setSelectedTargetId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
@@ -711,9 +733,11 @@ export function PvPRoguelikeScreen({
|
|||||||
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
|
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
|
||||||
setQueueMessage(logText)
|
setQueueMessage(logText)
|
||||||
setLog([{ id: 1, text: logText, tone: 'system' }])
|
setLog([{ id: 1, text: logText, tone: 'system' }])
|
||||||
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
|
beginRoundCountdown()
|
||||||
|
}, [beginRoundCountdown, contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
|
||||||
|
|
||||||
const startMatch = useCallback((nextStartStage?: number) => {
|
const startMatch = useCallback((nextStartStage?: number) => {
|
||||||
|
clearRoundCountdown()
|
||||||
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||||
const firstEncounter = firstSegment[0]
|
const firstEncounter = firstSegment[0]
|
||||||
@@ -769,8 +793,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(message)
|
setQueueMessage(message)
|
||||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||||
setStatus('playing')
|
beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
|
||||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
|
||||||
}
|
}
|
||||||
if (gameMode === 'offline') {
|
if (gameMode === 'offline') {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
@@ -839,7 +862,7 @@ export function PvPRoguelikeScreen({
|
|||||||
if (pollTimer) window.clearTimeout(pollTimer)
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
|
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])
|
useEffect(() => startMatch(), [startMatch])
|
||||||
|
|
||||||
@@ -849,7 +872,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const syncMatch = () => {
|
const syncMatch = () => {
|
||||||
publishPvpMatchState<SideState>(liveMatch.id, {
|
publishPvpMatchState<SideState>(liveMatch.id, {
|
||||||
state: playerRef.current,
|
state: playerRef.current,
|
||||||
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
|
status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'round-countdown' ? 'playing' : status,
|
||||||
stage,
|
stage,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encountersCleared,
|
encountersCleared,
|
||||||
@@ -949,7 +972,7 @@ export function PvPRoguelikeScreen({
|
|||||||
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 groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
const groupPower = spell.power
|
||||||
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
||||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
const nextShield = hasSpellEffect('radiance_applies_shield')
|
const nextShield = hasSpellEffect('radiance_applies_shield')
|
||||||
@@ -966,7 +989,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||||
if (spell.kind === 'shield') {
|
if (spell.kind === 'shield') {
|
||||||
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
const shieldPower = spell.power
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
shield: Math.max(member.shield, shieldPower),
|
shield: Math.max(member.shield, shieldPower),
|
||||||
@@ -1000,16 +1023,6 @@ export function PvPRoguelikeScreen({
|
|||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const freeCastStacks = buffStacks(buffs, 'fifth-cast-free')
|
|
||||||
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady
|
|
||||||
const nextCastsTowardFree = freeCastStacks > 0
|
|
||||||
? current.freeCastReady
|
|
||||||
? 0
|
|
||||||
: current.castsTowardFree + 1 >= 5
|
|
||||||
? 0
|
|
||||||
: current.castsTowardFree + 1
|
|
||||||
: current.castsTowardFree
|
|
||||||
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
|
||||||
const nextCooldowns = {
|
const nextCooldowns = {
|
||||||
...current.cooldowns,
|
...current.cooldowns,
|
||||||
}
|
}
|
||||||
@@ -1022,8 +1035,8 @@ export function PvPRoguelikeScreen({
|
|||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: current.resource - effectiveCost,
|
resource: current.resource - effectiveCost,
|
||||||
cooldowns: nextCooldowns,
|
cooldowns: nextCooldowns,
|
||||||
castsTowardFree: nextCastsTowardFree,
|
castsTowardFree: current.castsTowardFree,
|
||||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
freeCastReady: false,
|
||||||
}
|
}
|
||||||
setCurrent(nextState)
|
setCurrent(nextState)
|
||||||
return true
|
return true
|
||||||
@@ -1134,7 +1147,6 @@ export function PvPRoguelikeScreen({
|
|||||||
const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut')
|
const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut')
|
||||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
||||||
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
|
||||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||||
const tankPressure = tankPressureTargets(side.party)
|
const tankPressure = tankPressureTargets(side.party)
|
||||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
@@ -1150,7 +1162,6 @@ export function PvPRoguelikeScreen({
|
|||||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||||
: member.poisonStacks ?? 0
|
: member.poisonStacks ?? 0
|
||||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||||
damage = Math.round(damage * damageMultiplier)
|
|
||||||
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
|
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
|
||||||
damage = Math.round(damage * 0.8)
|
damage = Math.round(damage * 0.8)
|
||||||
}
|
}
|
||||||
@@ -1189,7 +1200,7 @@ export function PvPRoguelikeScreen({
|
|||||||
...side,
|
...side,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: clamp(
|
resource: clamp(
|
||||||
side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')),
|
side.resource + 2.4,
|
||||||
0,
|
0,
|
||||||
maxResource,
|
maxResource,
|
||||||
),
|
),
|
||||||
@@ -1203,10 +1214,11 @@ export function PvPRoguelikeScreen({
|
|||||||
const beginUpgradePhase = useCallback(() => {
|
const beginUpgradePhase = useCallback(() => {
|
||||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||||
autoSubmittedUpgradeRef.current = false
|
autoSubmittedUpgradeRef.current = false
|
||||||
|
const playerNeedsRevive = hasDeadPartyMembers(playerRef.current)
|
||||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
setPlayerBuffChoices(playerNeedsRevive ? [REVIVE_PARTY_CHOICE] : chooseRandom(selfBuffChoicesCatalog, 3))
|
||||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(playerNeedsRevive ? REVIVE_PARTY_CHOICE : null)
|
||||||
setSelectedDebuff(null)
|
setSelectedDebuff(null)
|
||||||
setStatus('upgrade-choice')
|
setStatus('upgrade-choice')
|
||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
@@ -1221,8 +1233,8 @@ export function PvPRoguelikeScreen({
|
|||||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||||
playerClearedEncounterRef.current = encounterIndex
|
playerClearedEncounterRef.current = encounterIndex
|
||||||
setEncountersCleared((value) => value + 1)
|
setEncountersCleared((value) => value + 1)
|
||||||
|
awardEncounterReward(encounterIndex)
|
||||||
if (encounter.isBoss) {
|
if (encounter.isBoss) {
|
||||||
awardBossReward(encounterIndex)
|
|
||||||
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
||||||
profile.character.id,
|
profile.character.id,
|
||||||
contentType,
|
contentType,
|
||||||
@@ -1266,7 +1278,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
}, TICK_MS / speedMultiplier)
|
}, TICK_MS / speedMultiplier)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
|
}, [addLog, advanceSide, awardEncounterReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -1346,13 +1358,16 @@ export function PvPRoguelikeScreen({
|
|||||||
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
|
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
|
||||||
let nextPlayer = {
|
let nextPlayer = {
|
||||||
...playerRef.current,
|
...playerRef.current,
|
||||||
buffs: [...playerRef.current.buffs, submittedBuff.id],
|
buffs: submittedBuff.id === REVIVE_PARTY_CHOICE.id
|
||||||
|
? playerRef.current.buffs
|
||||||
|
: [...playerRef.current.buffs, submittedBuff.id],
|
||||||
}
|
}
|
||||||
if (opponentChoice.debuffId === 'opp-purge-random-buff') {
|
if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
|
||||||
nextPlayer = removeRandomBuff(nextPlayer)
|
|
||||||
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
|
|
||||||
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
|
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
|
const clearedBoss = encounter.isBoss
|
||||||
if (clearedBoss && cpuDefeatedRef.current) {
|
if (clearedBoss && cpuDefeatedRef.current) {
|
||||||
@@ -1374,15 +1389,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
nextPlayer = {
|
nextPlayer = {
|
||||||
...nextPlayer,
|
...nextPlayer,
|
||||||
party: nextPlayer.party.map((member) => ({
|
party: recoverPartyForNextEncounter(nextPlayer.party, submittedBuff.id === REVIVE_PARTY_CHOICE.id),
|
||||||
...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,
|
|
||||||
})),
|
|
||||||
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
@@ -1397,7 +1404,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setLiveUpgradePending(false)
|
setLiveUpgradePending(false)
|
||||||
pendingLiveUpgradeRef.current = null
|
pendingLiveUpgradeRef.current = null
|
||||||
setStatus('playing')
|
beginRoundCountdown()
|
||||||
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
|
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
|
||||||
addLog(
|
addLog(
|
||||||
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
|
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
|
||||||
@@ -1443,30 +1450,34 @@ export function PvPRoguelikeScreen({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!cpuDifficulty) 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 cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
||||||
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
||||||
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
|
const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))
|
||||||
|
|
||||||
let nextPlayer = {
|
let nextPlayer = {
|
||||||
...playerRef.current,
|
...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 = {
|
let nextCpu = {
|
||||||
...cpuRef.current,
|
...cpuRef.current,
|
||||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
buffs: cpuBuff.id === REVIVE_PARTY_CHOICE.id
|
||||||
|
? cpuRef.current.buffs
|
||||||
|
: [...cpuRef.current.buffs, cpuBuff.id],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chosenDebuff.id === 'opp-purge-random-buff') {
|
|
||||||
nextCpu = removeRandomBuff(nextCpu)
|
|
||||||
} else {
|
|
||||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
||||||
}
|
|
||||||
|
|
||||||
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
|
||||||
nextPlayer = removeRandomBuff(nextPlayer)
|
|
||||||
} else {
|
|
||||||
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.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
|
const clearedBoss = encounter.isBoss
|
||||||
@@ -1488,30 +1499,14 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
nextPlayer = {
|
nextPlayer = {
|
||||||
...nextPlayer,
|
...nextPlayer,
|
||||||
party: nextPlayer.party.map((member) => ({
|
party: recoverPartyForNextEncounter(nextPlayer.party, chosenBuff.id === REVIVE_PARTY_CHOICE.id),
|
||||||
...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,
|
|
||||||
})),
|
|
||||||
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
}
|
}
|
||||||
nextCpu = {
|
nextCpu = {
|
||||||
...nextCpu,
|
...nextCpu,
|
||||||
party: nextCpu.party.map((member) => ({
|
party: recoverPartyForNextEncounter(nextCpu.party, cpuBuff.id === REVIVE_PARTY_CHOICE.id),
|
||||||
...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,
|
|
||||||
})),
|
|
||||||
resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
@@ -1527,9 +1522,9 @@ export function PvPRoguelikeScreen({
|
|||||||
playerRef.current = nextPlayer
|
playerRef.current = nextPlayer
|
||||||
cpuRef.current = nextCpu
|
cpuRef.current = nextCpu
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('playing')
|
beginRoundCountdown()
|
||||||
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
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(() => {
|
useEffect(() => {
|
||||||
if (status !== 'upgrade-choice' || liveUpgradePending) return
|
if (status !== 'upgrade-choice' || liveUpgradePending) return
|
||||||
@@ -1633,7 +1628,7 @@ export function PvPRoguelikeScreen({
|
|||||||
partySize: playerSide.party.length,
|
partySize: playerSide.party.length,
|
||||||
selectedId,
|
selectedId,
|
||||||
log,
|
log,
|
||||||
status: status === 'queueing' ? 'playing' : status,
|
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
|
||||||
resource: playerSide.resource,
|
resource: playerSide.resource,
|
||||||
maxResource,
|
maxResource,
|
||||||
resourceName: gameClass.resourceName,
|
resourceName: gameClass.resourceName,
|
||||||
@@ -1861,6 +1856,15 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === 'round-countdown' && (
|
||||||
|
<div className="pvp-round-countdown">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Round Starts</p>
|
||||||
|
<h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div className="pvp-upgrade-dialog">
|
<div className="pvp-upgrade-dialog">
|
||||||
@@ -1873,7 +1877,7 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pvp-choice-columns">
|
<div className="pvp-choice-columns">
|
||||||
<div>
|
<div>
|
||||||
<strong>Self Buff</strong>
|
<strong>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
|
||||||
<div className="upgrade-choice-grid">
|
<div className="upgrade-choice-grid">
|
||||||
{playerBuffChoices.map((choice) => (
|
{playerBuffChoices.map((choice) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -67,6 +67,14 @@ export type DualScreenCombatState = {
|
|||||||
paused: boolean
|
paused: boolean
|
||||||
targetGroup: 0 | 1 | 2
|
targetGroup: 0 | 1 | 2
|
||||||
speedMultiplier: 1 | 2
|
speedMultiplier: 1 | 2
|
||||||
|
stadium?: {
|
||||||
|
dampeningPercent: number
|
||||||
|
roundIndex: number
|
||||||
|
playerWins: number
|
||||||
|
opponentWins: number
|
||||||
|
survivalSeconds: number
|
||||||
|
opponentSurvivalSeconds: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DualScreenWorkshopState = {
|
export type DualScreenWorkshopState = {
|
||||||
@@ -138,6 +146,11 @@ function memberHotEffects(member: PartyMember) {
|
|||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDualTime(seconds: number) {
|
||||||
|
const total = Math.max(0, Math.floor(seconds))
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||||
const [enabled, setEnabledState] = useState(
|
const [enabled, setEnabledState] = useState(
|
||||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||||
@@ -446,12 +459,24 @@ export function DualScreenBottomDisplay() {
|
|||||||
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-progress">
|
<div className="dual-controls-progress">
|
||||||
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{state.opponentParty ? (
|
{state.opponentParty ? (
|
||||||
<>
|
<>
|
||||||
|
{state.stadium ? (
|
||||||
|
<section className="dual-opponent-progress stadium">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Dampening</p>
|
||||||
|
<strong>{state.stadium.dampeningPercent}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Survival</p>
|
||||||
|
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
<section className="dual-opponent-progress">
|
<section className="dual-opponent-progress">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Opponent Clear</p>
|
<p className="eyebrow">Opponent Clear</p>
|
||||||
@@ -461,6 +486,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||||
{state.opponentParty.map((member) => (
|
{state.opponentParty.map((member) => (
|
||||||
|
|||||||
+62
-1
@@ -37,7 +37,8 @@ export interface GameRepository {
|
|||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
@@ -503,6 +504,52 @@ function scaledPvpBossExperience(
|
|||||||
return { experience, level }
|
return { experience, level }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scaledPvpFightExperience(
|
||||||
|
startingExperience: number,
|
||||||
|
startingLevel: number,
|
||||||
|
fightsCleared: number,
|
||||||
|
maxLevel: number,
|
||||||
|
targetLevel = startingLevel,
|
||||||
|
) {
|
||||||
|
let experience = startingExperience
|
||||||
|
let level = startingLevel
|
||||||
|
const maxExperience = experienceForLevel(maxLevel)
|
||||||
|
for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) {
|
||||||
|
const currentLevelFloor = experienceForLevel(level)
|
||||||
|
const nextLevelExperience = level >= maxLevel
|
||||||
|
? maxExperience
|
||||||
|
: experienceForLevel(level + 1)
|
||||||
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
|
const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12
|
||||||
|
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||||
|
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||||
|
level += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { experience, level }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaledCurrentLevelExperience(
|
||||||
|
startingExperience: number,
|
||||||
|
startingLevel: number,
|
||||||
|
maxLevel: number,
|
||||||
|
rate: number,
|
||||||
|
) {
|
||||||
|
let experience = startingExperience
|
||||||
|
let level = startingLevel
|
||||||
|
const maxExperience = experienceForLevel(maxLevel)
|
||||||
|
const currentLevelFloor = experienceForLevel(level)
|
||||||
|
const nextLevelExperience = level >= maxLevel
|
||||||
|
? maxExperience
|
||||||
|
: experienceForLevel(level + 1)
|
||||||
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
|
experience = Math.min(maxExperience, experience + Math.round(levelBand * rate))
|
||||||
|
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||||
|
level += 1
|
||||||
|
}
|
||||||
|
return { experience, level }
|
||||||
|
}
|
||||||
|
|
||||||
function talentEffectCapacity(level: number) {
|
function talentEffectCapacity(level: number) {
|
||||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||||
}
|
}
|
||||||
@@ -1142,6 +1189,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||||
|
: options?.experienceMode === 'pvp-fight-twelfth-level'
|
||||||
|
? scaledPvpFightExperience(
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
|
||||||
|
profile.maxLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
|
)
|
||||||
|
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
|
||||||
|
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
|
||||||
|
: options?.experienceMode === 'pvp-stadium-match-half-level'
|
||||||
|
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
|
||||||
: null
|
: null
|
||||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||||
const newExperience = scaledReward
|
const newExperience = scaledReward
|
||||||
|
|||||||
+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)
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-1
@@ -349,7 +349,8 @@ export async function completeRoguelike(
|
|||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-3
@@ -1,8 +1,9 @@
|
|||||||
import { requestGameApiJson } from './gameRepository'
|
import { requestGameApiJson } from './gameRepository'
|
||||||
|
|
||||||
export type PvpContentType = 'dungeon' | 'raid'
|
export type PvpContentType = 'dungeon' | 'raid' | 'stadium'
|
||||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||||
export type PvpMatchSide = 'a' | 'b'
|
export type PvpMatchSide = 'a' | 'b'
|
||||||
|
export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost'
|
||||||
|
|
||||||
export type PvpPlayerInfo = {
|
export type PvpPlayerInfo = {
|
||||||
side: PvpMatchSide
|
side: PvpMatchSide
|
||||||
@@ -16,6 +17,8 @@ export type PvpUpgradeChoicePayload = {
|
|||||||
encounterIndex: number
|
encounterIndex: number
|
||||||
buffId: string
|
buffId: string
|
||||||
debuffId: string
|
debuffId: string
|
||||||
|
purchases?: string[]
|
||||||
|
shopReady?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PvpMatchSnapshot<TSideState = unknown> = {
|
export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||||
@@ -25,7 +28,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
players: Record<PvpMatchSide, PvpPlayerInfo>
|
players: Record<PvpMatchSide, PvpPlayerInfo>
|
||||||
states: Partial<Record<PvpMatchSide, TSideState>>
|
states: Partial<Record<PvpMatchSide, TSideState>>
|
||||||
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
|
statuses: Partial<Record<PvpMatchSide, PvpMatchStatus>>
|
||||||
progress: Partial<Record<PvpMatchSide, {
|
progress: Partial<Record<PvpMatchSide, {
|
||||||
stage: number
|
stage: number
|
||||||
encounterIndex: number
|
encounterIndex: number
|
||||||
@@ -143,7 +146,7 @@ export function publishPvpMatchState<TSideState>(
|
|||||||
matchId: string,
|
matchId: string,
|
||||||
payload: {
|
payload: {
|
||||||
state: TSideState
|
state: TSideState
|
||||||
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
status: PvpMatchStatus
|
||||||
stage: number
|
stage: number
|
||||||
encounterIndex: number
|
encounterIndex: number
|
||||||
encountersCleared: number
|
encountersCleared: number
|
||||||
|
|||||||
Reference in New Issue
Block a user