Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5449276521 | |||
| 787e2bbae9 |
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 72
|
versionCode 74
|
||||||
versionName "1.0.53"
|
versionName "1.0.55"
|
||||||
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.
|
||||||
|
|||||||
+57
-17
@@ -2589,10 +2589,30 @@ function pvpSnapshot(match) {
|
|||||||
statuses: match.statuses,
|
statuses: match.statuses,
|
||||||
progress: match.progress,
|
progress: match.progress,
|
||||||
upgradeChoices: match.upgradeChoices,
|
upgradeChoices: match.upgradeChoices,
|
||||||
|
rematchRequests: match.rematchRequests,
|
||||||
updatedAt: match.updatedAt,
|
updatedAt: match.updatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
|
||||||
|
const matchId = randomBytes(12).toString('base64url')
|
||||||
|
const match = {
|
||||||
|
id: matchId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
createdAt: now,
|
||||||
|
players,
|
||||||
|
states: {},
|
||||||
|
statuses: {},
|
||||||
|
progress: {},
|
||||||
|
upgradeChoices: {},
|
||||||
|
rematchRequests: {},
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
pvpMatches.set(matchId, match)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
function joinPvpQueue(session, payload) {
|
function joinPvpQueue(session, payload) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
cleanupPvpMemory(now)
|
cleanupPvpMemory(now)
|
||||||
@@ -2622,24 +2642,11 @@ function joinPvpQueue(session, payload) {
|
|||||||
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||||
const player = pvpPlayerInfo(session)
|
const player = pvpPlayerInfo(session)
|
||||||
if (opponent) {
|
if (opponent) {
|
||||||
const matchId = randomBytes(12).toString('base64url')
|
const match = createPvpMatch(contentType, startStage, {
|
||||||
const match = {
|
|
||||||
id: matchId,
|
|
||||||
contentType,
|
|
||||||
startStage,
|
|
||||||
createdAt: now,
|
|
||||||
players: {
|
|
||||||
a: { side: 'a', ...opponent.player },
|
a: { side: 'a', ...opponent.player },
|
||||||
b: { side: 'b', ...player },
|
b: { side: 'b', ...player },
|
||||||
},
|
}, now)
|
||||||
states: {},
|
opponent.matchId = match.id
|
||||||
statuses: {},
|
|
||||||
progress: {},
|
|
||||||
upgradeChoices: {},
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
pvpMatches.set(matchId, match)
|
|
||||||
opponent.matchId = matchId
|
|
||||||
opponent.updatedAt = now
|
opponent.updatedAt = now
|
||||||
const ticketId = randomBytes(12).toString('base64url')
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
pvpQueue.set(ticketId, {
|
pvpQueue.set(ticketId, {
|
||||||
@@ -2649,7 +2656,7 @@ function joinPvpQueue(session, payload) {
|
|||||||
contentType,
|
contentType,
|
||||||
startStage,
|
startStage,
|
||||||
player,
|
player,
|
||||||
matchId,
|
matchId: match.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -2751,6 +2758,33 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
|
|||||||
return pvpSnapshot(match)
|
return pvpSnapshot(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestPvpRematch(session, matchId) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
if (match.nextMatchId) {
|
||||||
|
const nextMatch = pvpMatches.get(match.nextMatchId)
|
||||||
|
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
match.rematchRequests = match.rematchRequests ?? {}
|
||||||
|
match.rematchRequests[side] = true
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
const opponentSide = side === 'a' ? 'b' : 'a'
|
||||||
|
if (!match.rematchRequests[opponentSide]) {
|
||||||
|
return { status: 'waiting', match: pvpSnapshot(match), side }
|
||||||
|
}
|
||||||
|
const nextMatch = createPvpMatch(
|
||||||
|
match.contentType,
|
||||||
|
match.startStage,
|
||||||
|
{
|
||||||
|
a: match.players.a,
|
||||||
|
b: match.players.b,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
)
|
||||||
|
match.nextMatchId = nextMatch.id
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
|
||||||
export function gameApiPlugin() {
|
export function gameApiPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'ashen-halls-game-api',
|
name: 'ashen-halls-game-api',
|
||||||
@@ -2971,6 +3005,12 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
|
||||||
|
if (pvpRematch && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||||
if (pvpMatch && request.method === 'GET') {
|
if (pvpMatch && request.method === 'GET') {
|
||||||
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||||
|
|||||||
+96
-25
@@ -6087,27 +6087,17 @@ h2 {
|
|||||||
.pvp-board {
|
.pvp-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-side,
|
.pvp-side {
|
||||||
.pvp-middle-panel {
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-vertical-spell-bar,
|
|
||||||
.pvp-vertical-spell-bar.six-slots {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-vertical-spell-bar .spell {
|
|
||||||
min-height: 58px;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-screen-tools {
|
.pvp-screen-tools {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6118,18 +6108,41 @@ h2 {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-resource-wrap {
|
.pvp-side-bars {
|
||||||
color: #82bfff;
|
display: grid;
|
||||||
min-width: 150px;
|
gap: 8px;
|
||||||
text-align: right;
|
min-width: min(320px, 45%);
|
||||||
width: min(170px, 100%);
|
width: min(360px, 48%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap,
|
||||||
|
.pvp-resource-wrap {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
text-align: right;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap > span,
|
||||||
.pvp-resource-wrap > span {
|
.pvp-resource-wrap > span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap .bar,
|
||||||
|
.pvp-resource-wrap .bar {
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap {
|
||||||
|
color: #ff8d9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-resource-wrap {
|
||||||
|
color: #82bfff;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-side .party-member,
|
.pvp-side .party-member,
|
||||||
.pvp-side .party-member > div,
|
.pvp-side .party-member > div,
|
||||||
.pvp-side .party-member > small {
|
.pvp-side .party-member > small {
|
||||||
@@ -6147,7 +6160,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-side .pvp-party-grid.raid .party-member {
|
.pvp-side .pvp-party-grid.raid .party-member {
|
||||||
min-height: 62px;
|
min-height: 96px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6184,6 +6197,29 @@ h2 {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-side .member-health {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-side .member-health .health-text {
|
||||||
|
align-items: center;
|
||||||
|
color: #fff3c7;
|
||||||
|
display: flex;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
font-style: normal;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-side .party-member .member-header small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-side .member-effects {
|
.pvp-side .member-effects {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -6202,22 +6238,57 @@ h2 {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-middle-panel .encounter-header h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-middle-panel .encounter-header small,
|
|
||||||
.pvp-enemy-race small {
|
.pvp-enemy-race small {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-middle-panel .roguelike-upgrade-list,
|
|
||||||
.pvp-side .roguelike-upgrade-list {
|
.pvp-side .roguelike-upgrade-list {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
box-shadow: 4px 4px 0 #08090c;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell-icon {
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell strong {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell small {
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-choice-columns {
|
.pvp-choice-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
joinPvpQueue,
|
joinPvpQueue,
|
||||||
loadPvpMatch,
|
loadPvpMatch,
|
||||||
publishPvpMatchState,
|
publishPvpMatchState,
|
||||||
|
requestPvpRematch,
|
||||||
randomCpuDifficulty,
|
randomCpuDifficulty,
|
||||||
recordCpuPvpLeaderboard,
|
recordCpuPvpLeaderboard,
|
||||||
recordPvpRoguelikeCheckpoint,
|
recordPvpRoguelikeCheckpoint,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
type CpuDifficulty,
|
type CpuDifficulty,
|
||||||
type PvpMatchSnapshot,
|
type PvpMatchSnapshot,
|
||||||
type PvpMatchSide,
|
type PvpMatchSide,
|
||||||
|
type PvpRematchResponse,
|
||||||
type PvpContentType,
|
type PvpContentType,
|
||||||
type PvpUpgradeChoicePayload,
|
type PvpUpgradeChoicePayload,
|
||||||
} from '../pvpRoguelike'
|
} from '../pvpRoguelike'
|
||||||
@@ -469,6 +471,8 @@ export function PvPRoguelikeScreen({
|
|||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
||||||
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
||||||
|
const [rematchRequested, setRematchRequested] = useState(false)
|
||||||
|
const [rematchMessage, setRematchMessage] = useState('')
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
@@ -635,6 +639,80 @@ export function PvPRoguelikeScreen({
|
|||||||
: null)
|
: null)
|
||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
|
|
||||||
|
const startLiveMatch = useCallback((
|
||||||
|
match: PvpMatchSnapshot<SideState>,
|
||||||
|
side: PvpMatchSide,
|
||||||
|
message?: string,
|
||||||
|
) => {
|
||||||
|
const matchStartStage = match.startStage
|
||||||
|
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||||
|
const firstEncounter = firstSegment[0]
|
||||||
|
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||||
|
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||||
|
const opponent = match.players[opponentSide]
|
||||||
|
const baseOpponent = starterSide(
|
||||||
|
cpuPartyTemplate.map((member) => ({
|
||||||
|
...member,
|
||||||
|
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||||
|
})),
|
||||||
|
maxResource,
|
||||||
|
)
|
||||||
|
basePlayer.enemyHealth = firstEncounter.maxHealth
|
||||||
|
baseOpponent.enemyHealth = firstEncounter.maxHealth
|
||||||
|
const nextLiveMatch: LivePvpMatch = {
|
||||||
|
id: match.id,
|
||||||
|
side,
|
||||||
|
opponentSide,
|
||||||
|
opponentName: opponent.characterName,
|
||||||
|
opponentClassName: opponent.className,
|
||||||
|
}
|
||||||
|
playerRef.current = basePlayer
|
||||||
|
cpuRef.current = baseOpponent
|
||||||
|
liveMatchRef.current = nextLiveMatch
|
||||||
|
nextLogId.current = 2
|
||||||
|
playerClearedEncounterRef.current = -1
|
||||||
|
queuedMatchRef.current = true
|
||||||
|
bossRewardClaimedRef.current = new Set()
|
||||||
|
setEncounters(firstSegment)
|
||||||
|
setEncounterIndex(0)
|
||||||
|
setCheckpointStage(matchStartStage)
|
||||||
|
setStartStage(matchStartStage)
|
||||||
|
setStage(matchStartStage)
|
||||||
|
setElapsedTicks(0)
|
||||||
|
setStatus('playing')
|
||||||
|
setPlayerSide(basePlayer)
|
||||||
|
setCpuSide(baseOpponent)
|
||||||
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
|
setPlayerBuffChoices([])
|
||||||
|
setPlayerDebuffChoices([])
|
||||||
|
setSelectedBuff(null)
|
||||||
|
setSelectedDebuff(null)
|
||||||
|
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||||
|
upgradeChoiceEndsAtRef.current = 0
|
||||||
|
autoSubmittedUpgradeRef.current = false
|
||||||
|
setEncountersCleared(0)
|
||||||
|
setPaused(false)
|
||||||
|
setTargetGroup(0)
|
||||||
|
setReward(null)
|
||||||
|
setRunSummary(createEmptyPvpRunSummary())
|
||||||
|
setRewardError('')
|
||||||
|
setShowEndLog(false)
|
||||||
|
setFloatingTexts([])
|
||||||
|
setCpuDifficulty(null)
|
||||||
|
setLiveMatch(nextLiveMatch)
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
pendingLiveUpgradeRef.current = null
|
||||||
|
loggedOpponentDoneRef.current = false
|
||||||
|
recordedRunRef.current = false
|
||||||
|
rewardClaimedRef.current = false
|
||||||
|
cpuDefeatedRef.current = false
|
||||||
|
setRematchRequested(false)
|
||||||
|
setRematchMessage('')
|
||||||
|
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
|
||||||
|
setQueueMessage(logText)
|
||||||
|
setLog([{ id: 1, text: logText, tone: 'system' }])
|
||||||
|
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
|
||||||
|
|
||||||
const startMatch = useCallback((nextStartStage?: number) => {
|
const startMatch = useCallback((nextStartStage?: number) => {
|
||||||
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)
|
||||||
@@ -680,6 +758,8 @@ export function PvPRoguelikeScreen({
|
|||||||
setLiveUpgradePending(false)
|
setLiveUpgradePending(false)
|
||||||
pendingLiveUpgradeRef.current = null
|
pendingLiveUpgradeRef.current = null
|
||||||
loggedOpponentDoneRef.current = false
|
loggedOpponentDoneRef.current = false
|
||||||
|
setRematchRequested(false)
|
||||||
|
setRematchMessage('')
|
||||||
recordedRunRef.current = false
|
recordedRunRef.current = false
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
cpuDefeatedRef.current = false
|
cpuDefeatedRef.current = false
|
||||||
@@ -709,29 +789,7 @@ export function PvPRoguelikeScreen({
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||||
const opponent = match.players[opponentSide]
|
const opponent = match.players[opponentSide]
|
||||||
const nextLiveMatch = {
|
startLiveMatch(match, side, `${opponent.characterName} found. Stage ${match.startStage} begins.`)
|
||||||
id: match.id,
|
|
||||||
side,
|
|
||||||
opponentSide,
|
|
||||||
opponentName: opponent.characterName,
|
|
||||||
opponentClassName: opponent.className,
|
|
||||||
}
|
|
||||||
liveMatchRef.current = nextLiveMatch
|
|
||||||
setLiveMatch(nextLiveMatch)
|
|
||||||
setCpuDifficulty(null)
|
|
||||||
const opponentBase = starterSide(
|
|
||||||
cpuPartyTemplate.map((member) => ({
|
|
||||||
...member,
|
|
||||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
|
||||||
})),
|
|
||||||
maxResource,
|
|
||||||
)
|
|
||||||
opponentBase.enemyHealth = firstEncounter.maxHealth
|
|
||||||
cpuRef.current = opponentBase
|
|
||||||
setCpuSide(opponentBase)
|
|
||||||
setQueueMessage(`${opponent.characterName} found. Match begins.`)
|
|
||||||
setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
|
|
||||||
setStatus('playing')
|
|
||||||
}
|
}
|
||||||
const fallbackTimer = window.setTimeout(() => {
|
const fallbackTimer = window.setTimeout(() => {
|
||||||
if (cancelled || liveMatchRef.current) return
|
if (cancelled || liveMatchRef.current) return
|
||||||
@@ -781,7 +839,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])
|
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId, startLiveMatch])
|
||||||
|
|
||||||
useEffect(() => startMatch(), [startMatch])
|
useEffect(() => startMatch(), [startMatch])
|
||||||
|
|
||||||
@@ -807,10 +865,13 @@ export function PvPRoguelikeScreen({
|
|||||||
setCpuSide(opponentState)
|
setCpuSide(opponentState)
|
||||||
}
|
}
|
||||||
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
|
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
|
||||||
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
|
const opponentAlive = snapshot.progress[liveMatch.opponentSide]?.alive
|
||||||
|
if ((opponentStatus === 'lost' || opponentAlive === false) && !loggedOpponentDoneRef.current && status !== 'won' && status !== 'lost') {
|
||||||
loggedOpponentDoneRef.current = true
|
loggedOpponentDoneRef.current = true
|
||||||
cpuDefeatedRef.current = true
|
cpuDefeatedRef.current = true
|
||||||
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
|
finishRoguelikeRun()
|
||||||
|
setStatus('won')
|
||||||
|
addLog(`${liveMatch.opponentName} fell. Match complete.`, 'loot')
|
||||||
}
|
}
|
||||||
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
|
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
|
||||||
finishRoguelikeRun()
|
finishRoguelikeRun()
|
||||||
@@ -1188,7 +1249,10 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
||||||
cpuDefeatedRef.current = true
|
cpuDefeatedRef.current = true
|
||||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
finishRoguelikeRun()
|
||||||
|
setStatus('won')
|
||||||
|
addLog(`CPU ${cpuDifficulty ?? 1} fell. Match complete.`, 'loot')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (nextPlayer.enemyHealth <= 0) {
|
if (nextPlayer.enemyHealth <= 0) {
|
||||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||||
@@ -1218,6 +1282,46 @@ export function PvPRoguelikeScreen({
|
|||||||
})
|
})
|
||||||
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
||||||
|
|
||||||
|
const handleRematch = useCallback(() => {
|
||||||
|
if (!liveMatch || rematchRequested) return
|
||||||
|
let cancelled = false
|
||||||
|
let attempts = 0
|
||||||
|
setRematchRequested(true)
|
||||||
|
setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`)
|
||||||
|
const handleResponse = (result: PvpRematchResponse<SideState>) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (result.status === 'matched' && result.match && result.side) {
|
||||||
|
startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts += 1
|
||||||
|
if (attempts >= 180) {
|
||||||
|
setRematchRequested(false)
|
||||||
|
setRematchMessage('Rematch expired.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.setTimeout(pollRematch, 700)
|
||||||
|
}
|
||||||
|
const pollRematch = () => {
|
||||||
|
requestPvpRematch<SideState>(liveMatch.id)
|
||||||
|
.then(handleResponse)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
attempts += 1
|
||||||
|
if (attempts >= 10) {
|
||||||
|
setRematchRequested(false)
|
||||||
|
setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.setTimeout(pollRematch, 900)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pollRematch()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [liveMatch, rematchRequested, startLiveMatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'upgrade-choice') return
|
if (status !== 'upgrade-choice') return
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
@@ -1618,8 +1722,15 @@ export function PvPRoguelikeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">You</p>
|
<p className="eyebrow">You</p>
|
||||||
<h2>{profile.character.name}</h2>
|
<h2>{profile.character.name}</h2>
|
||||||
|
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
||||||
|
</div>
|
||||||
|
<div className="pvp-side-bars">
|
||||||
|
<div className="pvp-clear-wrap">
|
||||||
|
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
|
<div className="bar enemy-health boss-bar">
|
||||||
|
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-row pvp-resource-row">
|
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
@@ -1643,6 +1754,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
|
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1665,69 +1777,20 @@ export function PvPRoguelikeScreen({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="combat-panel pvp-middle-panel">
|
|
||||||
<div className="encounter-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Encounter {encounterIndex + 1}</p>
|
|
||||||
<h2>{encounter.enemyName}</h2>
|
|
||||||
<small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pvp-enemy-race">
|
|
||||||
<div>
|
|
||||||
<strong>Your clear</strong>
|
|
||||||
<div className="bar enemy-health boss-bar">
|
|
||||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
|
|
||||||
<div className="bar enemy-health boss-bar">
|
|
||||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
<small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
|
|
||||||
{starterSpells.map((spell) => {
|
|
||||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
|
||||||
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="spell"
|
|
||||||
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
|
|
||||||
key={`middle-${spell.id}`}
|
|
||||||
onClick={() => castPlayerSpell(spell)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<kbd>
|
|
||||||
<ControllerBindingLabel
|
|
||||||
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
|
|
||||||
compact
|
|
||||||
iconStyle={controllerIconStyle}
|
|
||||||
/>
|
|
||||||
</kbd>
|
|
||||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
||||||
<strong>{spell.name}</strong>
|
|
||||||
<small>{cost} {gameClass.resourceName}</small>
|
|
||||||
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="roguelike-upgrade-list">
|
|
||||||
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="combat-panel pvp-side">
|
<section className="combat-panel pvp-side">
|
||||||
<div className="encounter-header">
|
<div className="encounter-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Opponent</p>
|
<p className="eyebrow">Opponent</p>
|
||||||
<h2>{opponentLabel}</h2>
|
<h2>{opponentLabel}</h2>
|
||||||
|
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
|
||||||
|
</div>
|
||||||
|
<div className="pvp-side-bars">
|
||||||
|
<div className="pvp-clear-wrap">
|
||||||
|
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
|
<div className="bar enemy-health boss-bar">
|
||||||
|
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-row pvp-resource-row">
|
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
||||||
@@ -1745,6 +1808,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
|
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1766,6 +1830,34 @@ export function PvPRoguelikeScreen({
|
|||||||
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
|
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="pvp-bottom-spell-bar" aria-label="Player abilities">
|
||||||
|
{starterSpells.map((spell) => {
|
||||||
|
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
||||||
|
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="spell"
|
||||||
|
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
|
||||||
|
key={`bottom-${spell.id}`}
|
||||||
|
onClick={() => castPlayerSpell(spell)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<kbd>
|
||||||
|
<ControllerBindingLabel
|
||||||
|
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
|
||||||
|
compact
|
||||||
|
iconStyle={controllerIconStyle}
|
||||||
|
/>
|
||||||
|
</kbd>
|
||||||
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||||
|
<strong>{spell.name}</strong>
|
||||||
|
<small>{cost} {gameClass.resourceName}</small>
|
||||||
|
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1910,6 +2002,14 @@ export function PvPRoguelikeScreen({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{liveMatch && (
|
||||||
|
<>
|
||||||
|
<button disabled={rematchRequested} onClick={handleRematch} type="button">
|
||||||
|
{rematchRequested ? 'Waiting for Rematch' : 'Rematch'}
|
||||||
|
</button>
|
||||||
|
{rematchMessage && <p>{rematchMessage}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
||||||
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type PvpMatchSnapshot<TSideState = unknown> = {
|
|||||||
elapsedTicks: number
|
elapsedTicks: number
|
||||||
}>>
|
}>>
|
||||||
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||||
|
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ export type PvpQueueResponse<TSideState = unknown> = {
|
|||||||
side?: PvpMatchSide
|
side?: PvpMatchSide
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PvpRematchResponse<TSideState = unknown> = {
|
||||||
|
status: 'waiting' | 'matched'
|
||||||
|
match?: PvpMatchSnapshot<TSideState>
|
||||||
|
side?: PvpMatchSide
|
||||||
|
}
|
||||||
|
|
||||||
export type CpuPvpLeaderboardEntry = {
|
export type CpuPvpLeaderboardEntry = {
|
||||||
characterName: string
|
characterName: string
|
||||||
className: string
|
className: string
|
||||||
@@ -166,3 +173,9 @@ export function submitPvpUpgradeChoice(
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user