Android build v1.0.53
This commit is contained in:
+283
-24
@@ -774,7 +774,7 @@ textarea:focus-visible,
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: calc(100dvh - 20px);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -804,6 +804,84 @@ textarea:focus-visible,
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.dual-top-spell-strip {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(6, 54px) minmax(180px, 1fr);
|
||||
min-height: 64px;
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.dual-top-spell {
|
||||
align-items: center;
|
||||
background: #20232c;
|
||||
border: 2px solid #08090c;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
outline: 2px solid #4d4c58;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dual-top-spell:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dual-top-spell:disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.dual-top-spell .spell-icon {
|
||||
font-size: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.dual-top-spell > i {
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dual-top-spell > small {
|
||||
color: #fff4a8;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dual-top-resource {
|
||||
align-self: center;
|
||||
color: #82bfff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
justify-self: end;
|
||||
min-width: 220px;
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.dual-top-resource strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dual-top-resource .bar {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dual-bottom-display {
|
||||
background:
|
||||
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
||||
@@ -816,6 +894,10 @@ textarea:focus-visible,
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pvp-opponent-bottom-display {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-controls-header,
|
||||
.dual-controls-resource,
|
||||
.dual-controls-targets,
|
||||
@@ -838,6 +920,109 @@ textarea:focus-visible,
|
||||
font-size: clamp(14px, 2.2vw, 23px);
|
||||
}
|
||||
|
||||
.dual-controls-header small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress,
|
||||
.dual-opponent-effects {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
outline: 2px solid var(--edge);
|
||||
}
|
||||
|
||||
.dual-opponent-progress {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(130px, 0.45fr) minmax(0, 1fr);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress strong {
|
||||
color: var(--ink);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .bar {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 0;
|
||||
outline: 2px solid var(--edge);
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dual-opponent-member {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #0a0b0e;
|
||||
min-width: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.dual-opponent-member.dead {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .bar {
|
||||
height: 14px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .dual-opponent-member {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .member-header strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .dual-opponent-member .bar {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.dual-opponent-effects {
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dual-controls-progress {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
@@ -950,6 +1135,10 @@ textarea:focus-visible,
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dual-controls-header small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-controls-progress {
|
||||
font-size: 6px;
|
||||
}
|
||||
@@ -1023,6 +1212,67 @@ textarea:focus-visible,
|
||||
.dual-controls-spells .spell small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pvp-opponent-bottom-display {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-opponent-progress {
|
||||
border-width: 2px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .eyebrow {
|
||||
font-size: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .bar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid {
|
||||
border-width: 2px;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-opponent-member {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .bar {
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-effects {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dual-opponent-effects {
|
||||
border-width: 2px;
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dual-bottom-waiting {
|
||||
@@ -1686,7 +1936,7 @@ h2 {
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1713,7 +1963,8 @@ h2 {
|
||||
.customize-screen > .embedded-screen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.customize-screen .loadout-editor {
|
||||
@@ -3520,6 +3771,7 @@ h2 {
|
||||
|
||||
.spell-effect-layout {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 14px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
@@ -3538,6 +3790,7 @@ h2 {
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-auto-rows: minmax(76px, auto);
|
||||
@@ -3641,12 +3894,12 @@ h2 {
|
||||
.effect-pool {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 62px);
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
min-height: 134px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4704,7 +4957,7 @@ h2 {
|
||||
.customize-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -5253,23 +5506,6 @@ h2 {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.member-health .health-text {
|
||||
color: var(--ink);
|
||||
display: none;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
left: 50%;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px #08090c;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.raid-party-grid .party-member {
|
||||
min-height: 66px;
|
||||
padding: 7px;
|
||||
@@ -5985,7 +6221,7 @@ h2 {
|
||||
.pvp-choice-columns {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -6020,6 +6256,29 @@ h2 {
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header > strong {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header > strong.danger {
|
||||
color: #ff8190;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
|
||||
@@ -1523,7 +1523,6 @@ export function CombatScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1597,6 +1596,7 @@ export function CombatScreen({
|
||||
{dualScreenEnabled && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onCastSpell={castSpell}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,14 +23,24 @@ import {
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
loadPvpRoguelikeCheckpoint,
|
||||
cancelPvpQueue,
|
||||
checkPvpQueue,
|
||||
joinPvpQueue,
|
||||
loadPvpMatch,
|
||||
publishPvpMatchState,
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
recordPvpRoguelikeCheckpoint,
|
||||
submitPvpUpgradeChoice,
|
||||
type CpuDifficulty,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
type PvpContentType,
|
||||
type PvpUpgradeChoicePayload,
|
||||
} from '../pvpRoguelike'
|
||||
|
||||
const TICK_MS = 700
|
||||
const UPGRADE_CHOICE_SECONDS = 10
|
||||
|
||||
type BossMechanic =
|
||||
| 'party-pulse'
|
||||
@@ -99,6 +109,14 @@ type PvpRunSummary = {
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
type LivePvpMatch = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
const BOSS_MECHANICS: BossMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -449,6 +467,8 @@ export function PvPRoguelikeScreen({
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
||||
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
@@ -460,6 +480,7 @@ export function PvPRoguelikeScreen({
|
||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
@@ -471,6 +492,15 @@ export function PvPRoguelikeScreen({
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const queuedMatchRef = useRef(false)
|
||||
const upgradeChoiceEndsAtRef = useRef(0)
|
||||
const autoSubmittedUpgradeRef = useRef(false)
|
||||
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
||||
const loggedOpponentDoneRef = useRef(false)
|
||||
const pendingLiveUpgradeRef = useRef<{
|
||||
encounterIndex: number
|
||||
buff: Choice<SelfBuffId>
|
||||
debuff: Choice<OpponentDebuffId>
|
||||
} | null>(null)
|
||||
const encounterPoolRef = useRef(encounterPool)
|
||||
const playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
@@ -484,6 +514,7 @@ export function PvPRoguelikeScreen({
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||
const activeSpellEffects = useMemo(
|
||||
() => new Set(
|
||||
gameClass.talents
|
||||
@@ -632,6 +663,9 @@ export function PvPRoguelikeScreen({
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
upgradeChoiceEndsAtRef.current = 0
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
@@ -641,34 +675,159 @@ export function PvPRoguelikeScreen({
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
setCpuDifficulty(null)
|
||||
setLiveMatch(null)
|
||||
liveMatchRef.current = null
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||
liveMatchRef.current = null
|
||||
setLiveMatch(null)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(message)
|
||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
|
||||
let cancelled = false
|
||||
let ticketId = ''
|
||||
let pollTimer: number | undefined
|
||||
setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const beginLiveMatch = (match: PvpMatchSnapshot<SideState>, side: PvpMatchSide) => {
|
||||
if (cancelled) return
|
||||
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSide]
|
||||
const nextLiveMatch = {
|
||||
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')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
if (cancelled || liveMatchRef.current) return
|
||||
cancelled = true
|
||||
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
|
||||
}, 5000)
|
||||
const pollQueue = () => {
|
||||
if (!ticketId || cancelled) return
|
||||
checkPvpQueue<SideState>(ticketId)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
beginLiveMatch(result.match, result.side)
|
||||
return
|
||||
}
|
||||
pollTimer = window.setTimeout(pollQueue, 500)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
|
||||
})
|
||||
}
|
||||
joinPvpQueue<SideState>(contentType, matchStartStage)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
ticketId = result.ticketId
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
beginLiveMatch(result.match, result.side)
|
||||
return
|
||||
}
|
||||
pollTimer = window.setTimeout(pollQueue, 500)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
window.clearTimeout(fallbackTimer)
|
||||
cancelled = true
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(fallbackTimer)
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
}
|
||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||
|
||||
useEffect(() => startMatch(), [startMatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveMatch || status === 'queueing') return
|
||||
let stopped = false
|
||||
const syncMatch = () => {
|
||||
publishPvpMatchState<SideState>(liveMatch.id, {
|
||||
state: playerRef.current,
|
||||
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
|
||||
stage,
|
||||
encounterIndex,
|
||||
encountersCleared,
|
||||
enemyHealth: playerRef.current.enemyHealth,
|
||||
alive: playerRef.current.party.some((member) => member.health > 0),
|
||||
elapsedTicks,
|
||||
})
|
||||
.then((snapshot) => {
|
||||
if (stopped) return
|
||||
const opponentState = snapshot.states[liveMatch.opponentSide]
|
||||
if (opponentState) {
|
||||
cpuRef.current = opponentState
|
||||
setCpuSide(opponentState)
|
||||
}
|
||||
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
|
||||
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
|
||||
loggedOpponentDoneRef.current = true
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
|
||||
finishRoguelikeRun()
|
||||
setStatus('lost')
|
||||
addLog(`${liveMatch.opponentName} finished first.`, 'danger')
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
syncMatch()
|
||||
const timer = window.setInterval(syncMatch, 700)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
||||
@@ -981,6 +1140,9 @@ export function PvPRoguelikeScreen({
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||
setSelectedBuff(null)
|
||||
@@ -992,9 +1154,9 @@ export function PvPRoguelikeScreen({
|
||||
if (status !== 'playing' || paused || !encounter) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
cpuTakeTurn()
|
||||
if (!liveMatch) cpuTakeTurn()
|
||||
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
||||
const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter)
|
||||
const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
|
||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||
playerClearedEncounterRef.current = encounterIndex
|
||||
setEncountersCleared((value) => value + 1)
|
||||
@@ -1024,7 +1186,7 @@ export function PvPRoguelikeScreen({
|
||||
addLog('Your party fell first.', 'danger')
|
||||
return
|
||||
}
|
||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
@@ -1032,7 +1194,7 @@ export function PvPRoguelikeScreen({
|
||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
@@ -1040,7 +1202,7 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
}, TICK_MS / speedMultiplier)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -1066,8 +1228,117 @@ export function PvPRoguelikeScreen({
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
}, [paused])
|
||||
|
||||
const confirmUpgradeChoices = useCallback(() => {
|
||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||
const confirmUpgradeChoices = useCallback((
|
||||
forcedBuff?: Choice<SelfBuffId>,
|
||||
forcedDebuff?: Choice<OpponentDebuffId>,
|
||||
) => {
|
||||
const chosenBuff = forcedBuff ?? selectedBuff
|
||||
const chosenDebuff = forcedDebuff ?? selectedDebuff
|
||||
if (!chosenBuff || !chosenDebuff) return
|
||||
if (liveMatch) {
|
||||
const submittedBuff = chosenBuff
|
||||
const submittedDebuff = chosenDebuff
|
||||
const clearedEncounterIndex = encounterIndex
|
||||
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
|
||||
let nextPlayer = {
|
||||
...playerRef.current,
|
||||
buffs: [...playerRef.current.buffs, submittedBuff.id],
|
||||
}
|
||||
if (opponentChoice.debuffId === 'opp-purge-random-buff') {
|
||||
nextPlayer = removeRandomBuff(nextPlayer)
|
||||
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
|
||||
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
|
||||
}
|
||||
|
||||
const clearedBoss = encounter.isBoss
|
||||
if (clearedBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
setLiveUpgradePending(false)
|
||||
addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
setLiveUpgradePending(false)
|
||||
addLog('No further encounters remain.', 'loot')
|
||||
return
|
||||
}
|
||||
nextPlayer = {
|
||||
...nextPlayer,
|
||||
party: nextPlayer.party.map((member) => ({
|
||||
...member,
|
||||
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
})),
|
||||
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
cooldowns: {},
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
}
|
||||
if (clearedBoss) {
|
||||
setStage(nextStage)
|
||||
setEncounters((current) => [...current, ...nextSegment])
|
||||
}
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setPlayerSide(nextPlayer)
|
||||
playerRef.current = nextPlayer
|
||||
setElapsedTicks(0)
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
setStatus('playing')
|
||||
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
|
||||
addLog(
|
||||
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
|
||||
'system',
|
||||
)
|
||||
}
|
||||
setLiveUpgradePending(true)
|
||||
pendingLiveUpgradeRef.current = {
|
||||
encounterIndex: clearedEncounterIndex,
|
||||
buff: submittedBuff,
|
||||
debuff: submittedDebuff,
|
||||
}
|
||||
addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
|
||||
submitPvpUpgradeChoice(liveMatch.id, {
|
||||
encounterIndex: clearedEncounterIndex,
|
||||
buffId: submittedBuff.id,
|
||||
debuffId: submittedDebuff.id,
|
||||
}).catch((reason: unknown) => {
|
||||
setLiveUpgradePending(false)
|
||||
addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
|
||||
})
|
||||
let attempts = 0
|
||||
const waitForOpponent = () => {
|
||||
attempts += 1
|
||||
loadPvpMatch<SideState>(liveMatch.id)
|
||||
.then((snapshot) => {
|
||||
const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
|
||||
if (opponentChoice) {
|
||||
applyLiveUpgrade(opponentChoice)
|
||||
return
|
||||
}
|
||||
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
||||
window.setTimeout(waitForOpponent, 500)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
||||
window.setTimeout(waitForOpponent, 700)
|
||||
}
|
||||
})
|
||||
}
|
||||
window.setTimeout(waitForOpponent, 250)
|
||||
return
|
||||
}
|
||||
if (!cpuDifficulty) return
|
||||
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
||||
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
||||
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
||||
@@ -1075,17 +1346,17 @@ export function PvPRoguelikeScreen({
|
||||
|
||||
let nextPlayer = {
|
||||
...playerRef.current,
|
||||
buffs: [...playerRef.current.buffs, selectedBuff.id],
|
||||
buffs: [...playerRef.current.buffs, chosenBuff.id],
|
||||
}
|
||||
let nextCpu = {
|
||||
...cpuRef.current,
|
||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
||||
}
|
||||
|
||||
if (selectedDebuff.id === 'opp-purge-random-buff') {
|
||||
if (chosenDebuff.id === 'opp-purge-random-buff') {
|
||||
nextCpu = removeRandomBuff(nextCpu)
|
||||
} else {
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
||||
}
|
||||
|
||||
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
||||
@@ -1153,8 +1424,41 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = nextCpu
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'upgrade-choice' || liveUpgradePending) return
|
||||
if (upgradeChoiceEndsAtRef.current <= 0) {
|
||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||
}
|
||||
const updateTimer = () => {
|
||||
const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
|
||||
setUpgradeTimeLeft(remaining)
|
||||
if (remaining > 0 || autoSubmittedUpgradeRef.current) return
|
||||
autoSubmittedUpgradeRef.current = true
|
||||
const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
|
||||
const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
|
||||
if (autoBuff) setSelectedBuff(autoBuff)
|
||||
if (autoDebuff) setSelectedDebuff(autoDebuff)
|
||||
if (autoBuff && autoDebuff) {
|
||||
addLog('Upgrade timer expired. Random choices selected.', 'system')
|
||||
confirmUpgradeChoices(autoBuff, autoDebuff)
|
||||
}
|
||||
}
|
||||
updateTimer()
|
||||
const timer = window.setInterval(updateTimer, 100)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [
|
||||
addLog,
|
||||
confirmUpgradeChoices,
|
||||
liveUpgradePending,
|
||||
playerBuffChoices,
|
||||
playerDebuffChoices,
|
||||
selectedBuff,
|
||||
selectedDebuff,
|
||||
status,
|
||||
])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
@@ -1212,6 +1516,13 @@ export function PvPRoguelikeScreen({
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
party: playerSide.party,
|
||||
opponentName: opponentLabel,
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||
opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||
opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
|
||||
floatingTexts: floatingTexts
|
||||
.filter((entry) => entry.side === 'player')
|
||||
.map(({ id, memberId, value }) => ({ id, memberId, value })),
|
||||
@@ -1239,6 +1550,12 @@ export function PvPRoguelikeScreen({
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuSide.buffs,
|
||||
cpuSide.debuffs,
|
||||
cpuSide.enemyHealth,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
directPartyTargeting,
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
@@ -1249,8 +1566,12 @@ export function PvPRoguelikeScreen({
|
||||
floatingTexts,
|
||||
gameClass.resourceName,
|
||||
lastDevice,
|
||||
liveMatch?.opponentClassName,
|
||||
log,
|
||||
maxResource,
|
||||
opponentDebuffChoicesCatalog,
|
||||
opponentLabel,
|
||||
selfBuffChoicesCatalog,
|
||||
paused,
|
||||
playerAlive,
|
||||
playerSide.buffs,
|
||||
@@ -1285,6 +1606,7 @@ export function PvPRoguelikeScreen({
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onCastSpell={castPlayerSpell}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
@@ -1321,7 +1643,6 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1361,7 +1682,7 @@ export function PvPRoguelikeScreen({
|
||||
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>CPU clear</strong>
|
||||
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
@@ -1395,14 +1716,16 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="roguelike-upgrade-list">CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}</p>
|
||||
<p className="roguelike-upgrade-list">
|
||||
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="combat-panel pvp-side">
|
||||
<div className="encounter-header">
|
||||
<div>
|
||||
<p className="eyebrow">Opponent</p>
|
||||
<h2>CPU {cpuDifficulty}</h2>
|
||||
<h2>{opponentLabel}</h2>
|
||||
</div>
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
@@ -1422,7 +1745,6 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1450,6 +1772,13 @@ export function PvPRoguelikeScreen({
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div className="pvp-upgrade-dialog">
|
||||
<div className="pvp-upgrade-header">
|
||||
<div>
|
||||
<p className="eyebrow">Choose Edge</p>
|
||||
<h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
|
||||
</div>
|
||||
<strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
|
||||
</div>
|
||||
<div className="pvp-choice-columns">
|
||||
<div>
|
||||
<strong>Self Buff</strong>
|
||||
@@ -1484,8 +1813,9 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button">
|
||||
Continue
|
||||
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
||||
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1506,7 +1836,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<h2>{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
|
||||
+94
-5
@@ -39,6 +39,13 @@ export type DualScreenCombatState = {
|
||||
encounterIndex: number
|
||||
encounterCount: number
|
||||
party: PartyMember[]
|
||||
opponentName?: string
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
opponentResource?: number
|
||||
opponentEnemyHealth?: number
|
||||
opponentBuffSummary?: string
|
||||
opponentDebuffSummary?: string
|
||||
floatingTexts: Array<{
|
||||
id: number
|
||||
memberId: string
|
||||
@@ -431,17 +438,62 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dual-bottom-display">
|
||||
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
||||
<h1>{state.dungeonName}</h1>
|
||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
||||
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
|
||||
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.opponentParty ? (
|
||||
<>
|
||||
<section className="dual-opponent-progress">
|
||||
<div>
|
||||
<p className="eyebrow">Opponent Clear</p>
|
||||
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||
</div>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||
{state.opponentParty.map((member) => (
|
||||
<article className={`dual-opponent-member ${member.health <= 0 ? 'dead' : ''}`} key={member.id}>
|
||||
<div className="member-header">
|
||||
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||
<strong>{member.name}</strong>
|
||||
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
|
||||
</div>
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
|
||||
{member.shield > 0 && (
|
||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="member-effects">
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||
))}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dual-opponent-effects">
|
||||
<span>Buffs: {state.opponentBuffSummary || 'none'}</span>
|
||||
<span>Debuffs: {state.opponentDebuffSummary || 'none'}</span>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<section className="dual-controls-resource">
|
||||
<div>
|
||||
<p className="eyebrow">Active Target</p>
|
||||
@@ -542,6 +594,8 @@ export function DualScreenBottomDisplay() {
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -549,9 +603,11 @@ export function DualScreenBottomDisplay() {
|
||||
export function DualScreenTopCombat({
|
||||
state,
|
||||
onSelectTarget,
|
||||
onCastSpell,
|
||||
}: {
|
||||
state: DualScreenCombatState
|
||||
onSelectTarget: (id: string) => void
|
||||
onCastSpell?: (spell: Spell) => void
|
||||
}) {
|
||||
const enemyPercent = Math.max(
|
||||
0,
|
||||
@@ -602,7 +658,6 @@ export function DualScreenTopCombat({
|
||||
{member.shield > 0 && (
|
||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||
)}
|
||||
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
{state.floatingTexts
|
||||
@@ -629,6 +684,40 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dual-top-spell-strip">
|
||||
{state.spells.map((spell, slotIndex) => {
|
||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||
const percent = spell.remaining > 0
|
||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|| spell.remaining > 0
|
||||
|| state.status !== 'playing'
|
||||
|| state.paused
|
||||
}
|
||||
key={spell.id}
|
||||
onClick={() => onCastSpell?.(spell)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -671,7 +671,7 @@ function getApiBaseUrl(path: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export async function requestGameApiJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const baseUrl = getApiBaseUrl(path)
|
||||
const url = baseUrl ? `${baseUrl}${path}` : path
|
||||
const headers = new Headers(init?.headers)
|
||||
@@ -696,6 +696,10 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestGameApiJson(path, init)
|
||||
}
|
||||
|
||||
function isNetworkError(reason: unknown): reason is NetworkError {
|
||||
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
import { requestGameApiJson } from './gameRepository'
|
||||
|
||||
export type PvpContentType = 'dungeon' | 'raid'
|
||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||
export type PvpMatchSide = 'a' | 'b'
|
||||
|
||||
export type PvpPlayerInfo = {
|
||||
side: PvpMatchSide
|
||||
accountId: number
|
||||
characterId: number
|
||||
characterName: string
|
||||
className: string
|
||||
}
|
||||
|
||||
export type PvpUpgradeChoicePayload = {
|
||||
encounterIndex: number
|
||||
buffId: string
|
||||
debuffId: string
|
||||
}
|
||||
|
||||
export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||
id: string
|
||||
contentType: PvpContentType
|
||||
startStage: number
|
||||
createdAt: number
|
||||
players: Record<PvpMatchSide, PvpPlayerInfo>
|
||||
states: Partial<Record<PvpMatchSide, TSideState>>
|
||||
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
|
||||
progress: Partial<Record<PvpMatchSide, {
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
}>>
|
||||
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type PvpQueueResponse<TSideState = unknown> = {
|
||||
ticketId: string
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type CpuPvpLeaderboardEntry = {
|
||||
characterName: string
|
||||
@@ -66,3 +110,59 @@ export function recordPvpRoguelikeCheckpoint(
|
||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
export function joinPvpQueue<TSideState>(
|
||||
contentType: PvpContentType,
|
||||
startStage: number,
|
||||
): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson('/api/pvp/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contentType, startStage }),
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||
}
|
||||
|
||||
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function publishPvpMatchState<TSideState>(
|
||||
matchId: string,
|
||||
payload: {
|
||||
state: TSideState
|
||||
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
},
|
||||
): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
||||
}
|
||||
|
||||
export function submitPvpUpgradeChoice(
|
||||
matchId: string,
|
||||
payload: PvpUpgradeChoicePayload,
|
||||
): Promise<PvpMatchSnapshot> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user