Android build v1.0.53

This commit is contained in:
Warren H
2026-06-21 20:07:26 -04:00
parent 1e24aecad8
commit 421540c52b
14 changed files with 1329 additions and 69 deletions
+283 -24
View File
@@ -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;
+1 -1
View File
@@ -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}
/>
)}
+364 -34
View File
@@ -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
View File
@@ -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>
)
}
+5 -1
View File
@@ -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)
}
+100
View File
@@ -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),
})
}