Skip to content

Commit d05f526

Browse files
committed
perf(ui): improve interaction smoothness and cache warmup
1 parent e15391b commit d05f526

4 files changed

Lines changed: 152 additions & 38 deletions

File tree

web/src/pages/match/MatchPage.tsx

Lines changed: 113 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const UI_TICK_MS = 120;
6969
const INTRO_COUNTDOWN_START = 3;
7070
const HOVER_SFX_MIN_GAP_MS = 120;
7171
const P2_MOVE_SFX_MIN_GAP_MS = 70;
72+
const RESOLVED_MOVE_HIGHLIGHT_MS = 1200;
7273
const PERSIST_MAX_RETRIES = 3;
7374
const PERSIST_BASE_BACKOFF_MS = 200;
7475
const PERSIST_SNAPSHOT_KEY = "ataxx.persist.snapshot.v1";
@@ -370,6 +371,10 @@ export function MatchPage(): JSX.Element {
370371
const [selectedP2BotId, setSelectedP2BotId] = useState<string>("");
371372
const lastHoverSfxAtRef = useRef(0);
372373
const lastP2MoveSfxAtRef = useRef(0);
374+
const previewHalfMovesRef = useRef<number | null>(null);
375+
const boardTiltRafRef = useRef<number | null>(null);
376+
const boardTiltPendingRef = useRef({ x: 0, y: 0 });
377+
const lastResolvedMoveTimerRef = useRef<number | null>(null);
373378
const gameplayWsRef = useRef<WebSocket | null>(null);
374379
const lastWsPlyRef = useRef(-1);
375380
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
@@ -407,6 +412,32 @@ export function MatchPage(): JSX.Element {
407412
primeSfxOnFirstInteraction(sfxPaths, 4);
408413
}, []);
409414

415+
const setResolvedMoveHighlight = useCallback((move: Move | null) => {
416+
if (lastResolvedMoveTimerRef.current !== null) {
417+
window.clearTimeout(lastResolvedMoveTimerRef.current);
418+
lastResolvedMoveTimerRef.current = null;
419+
}
420+
setLastResolvedMove(move);
421+
if (move === null) {
422+
return;
423+
}
424+
lastResolvedMoveTimerRef.current = window.setTimeout(() => {
425+
setLastResolvedMove(null);
426+
lastResolvedMoveTimerRef.current = null;
427+
}, RESOLVED_MOVE_HIGHLIGHT_MS);
428+
}, []);
429+
430+
useEffect(() => {
431+
return () => {
432+
if (lastResolvedMoveTimerRef.current !== null) {
433+
window.clearTimeout(lastResolvedMoveTimerRef.current);
434+
}
435+
if (boardTiltRafRef.current !== null) {
436+
window.cancelAnimationFrame(boardTiltRafRef.current);
437+
}
438+
};
439+
}, []);
440+
410441
useEffect(() => {
411442
if (accessToken !== null) {
412443
lastAccessTokenRef.current = accessToken;
@@ -1295,9 +1326,23 @@ export function MatchPage(): JSX.Element {
12951326
useEffect(() => {
12961327
if (previewMove !== null && nowMs >= previewUntil) {
12971328
setPreviewMove(null);
1329+
previewHalfMovesRef.current = null;
12981330
}
12991331
}, [nowMs, previewMove, previewUntil]);
13001332

1333+
useEffect(() => {
1334+
if (previewMove === null) {
1335+
return;
1336+
}
1337+
const sourceHalfMoves = previewHalfMovesRef.current;
1338+
if (sourceHalfMoves !== null && board.half_moves > sourceHalfMoves) {
1339+
// If board advanced from any source (WS/local), stale preview lines must disappear.
1340+
setPreviewMove(null);
1341+
setPreviewUntil(0);
1342+
previewHalfMovesRef.current = null;
1343+
}
1344+
}, [board.half_moves, previewMove]);
1345+
13011346
const resetGame = useCallback(() => {
13021347
setBoard(createInitialBoard());
13031348
setSelected(null);
@@ -1310,9 +1355,10 @@ export function MatchPage(): JSX.Element {
13101355
clearPendingQueue(null);
13111356
setPreviewMove(null);
13121357
setPreviewUntil(0);
1358+
previewHalfMovesRef.current = null;
13131359
setInfectionMask({});
13141360
setInfectionBursts([]);
1315-
setLastResolvedMove(null);
1361+
setResolvedMoveHighlight(null);
13161362
setMatchStarted(false);
13171363
setShowIntro(false);
13181364
setIntroCountdown(INTRO_COUNTDOWN_START);
@@ -1338,7 +1384,7 @@ export function MatchPage(): JSX.Element {
13381384
setLoadingFinishRewards(false);
13391385
setAnimatedLpDelta(null);
13401386
setAnimatedRatingDelta(null);
1341-
}, [clearPendingQueue]);
1387+
}, [clearPendingQueue, setResolvedMoveHighlight]);
13421388

13431389
const consumeQueuedMatchFromSession = useCallback(async (): Promise<void> => {
13441390
try {
@@ -1432,7 +1478,7 @@ export function MatchPage(): JSX.Element {
14321478
// WS notifications arrive after persistence, not before the move execution;
14331479
// drawing "preview" for them feels like phantom/late intent lines.
14341480
setResolvedMoves((prev) => Math.max(prev, event.move.ply + 1));
1435-
setLastResolvedMove(remoteMove);
1481+
setResolvedMoveHighlight(remoteMove);
14361482

14371483
if (event.game.status === "finished") {
14381484
setMatchEndMs(Date.now());
@@ -1457,7 +1503,7 @@ export function MatchPage(): JSX.Element {
14571503
}
14581504
socket.close();
14591505
};
1460-
}, [accessToken, exitingMatch, isAuthenticated, navigate, persistedGameId, resetGame]);
1506+
}, [accessToken, exitingMatch, isAuthenticated, navigate, persistedGameId, resetGame, setResolvedMoveHighlight]);
14611507

14621508
const leaveMatch = useCallback(
14631509
async (options?: { redirectTo?: string; logoutAfter?: boolean }) => {
@@ -1703,9 +1749,10 @@ export function MatchPage(): JSX.Element {
17031749
clearPendingQueue(null);
17041750
setPreviewMove(null);
17051751
setPreviewUntil(0);
1752+
previewHalfMovesRef.current = null;
17061753
setInfectionMask({});
17071754
setInfectionBursts([]);
1708-
setLastResolvedMove(null);
1755+
setResolvedMoveHighlight(null);
17091756
setIntroCountdown(INTRO_COUNTDOWN_START);
17101757
setShowIntro(true);
17111758
setMatchStarted(true);
@@ -1744,6 +1791,7 @@ export function MatchPage(): JSX.Element {
17441791
selectedRivalIsHuman,
17451792
selectedP1Bot,
17461793
selectedP2Player,
1794+
setResolvedMoveHighlight,
17471795
user?.id,
17481796
]);
17491797

@@ -1843,14 +1891,14 @@ export function MatchPage(): JSX.Element {
18431891
}
18441892
}
18451893
setInfectionMask(mask);
1846-
setLastResolvedMove(move);
1894+
setResolvedMoveHighlight(move);
18471895
if (move !== null) {
18481896
setResolvedMoves((prev) => prev + 1);
18491897
}
18501898
const normalized = normalizeForcedPasses(next);
18511899
applyBoardUpdate(normalized.board, normalized.passes);
18521900
},
1853-
[applyBoardUpdate],
1901+
[applyBoardUpdate, setResolvedMoveHighlight],
18541902
);
18551903

18561904
const persistMoveWithRetry = useCallback(
@@ -1978,6 +2026,7 @@ export function MatchPage(): JSX.Element {
19782026
setEvalValue(prediction.value);
19792027

19802028
if (plannedMove !== null) {
2029+
previewHalfMovesRef.current = board.half_moves;
19812030
setPreviewMove(plannedMove);
19822031
setPreviewUntil(Date.now() + AI_PREVIEW_MS);
19832032
setStatus(`IA (${controller}) confirma ataque...`);
@@ -2000,6 +2049,7 @@ export function MatchPage(): JSX.Element {
20002049

20012050
setPreviewMove(null);
20022051
setPreviewUntil(0);
2052+
previewHalfMovesRef.current = null;
20032053
animateTransition(before, nextBoard, plannedMove, side);
20042054
} catch (error) {
20052055
const message = error instanceof Error ? error.message : "Error desconocido de IA";
@@ -2171,13 +2221,25 @@ export function MatchPage(): JSX.Element {
21712221
const rect = event.currentTarget.getBoundingClientRect();
21722222
const nx = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
21732223
const ny = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
2174-
setBoardTilt({
2224+
boardTiltPendingRef.current = {
21752225
x: -(ny * 2.1),
21762226
y: nx * 2.1,
2227+
};
2228+
if (boardTiltRafRef.current !== null) {
2229+
return;
2230+
}
2231+
boardTiltRafRef.current = window.requestAnimationFrame(() => {
2232+
boardTiltRafRef.current = null;
2233+
setBoardTilt(boardTiltPendingRef.current);
21772234
});
21782235
}, []);
21792236

21802237
const onBoardMouseLeave = useCallback(() => {
2238+
if (boardTiltRafRef.current !== null) {
2239+
window.cancelAnimationFrame(boardTiltRafRef.current);
2240+
boardTiltRafRef.current = null;
2241+
}
2242+
boardTiltPendingRef.current = { x: 0, y: 0 };
21812243
setBoardTilt({ x: 0, y: 0 });
21822244
}, []);
21832245

@@ -2760,6 +2822,11 @@ export function MatchPage(): JSX.Element {
27602822
const isPreviewTarget = previewMove !== null && previewMove.r2 === r && previewMove.c2 === c;
27612823
const isRecentOrigin = lastOrigin !== null && lastOrigin.row === r && lastOrigin.col === c;
27622824
const isRecentTarget = lastTarget !== null && lastTarget.row === r && lastTarget.col === c;
2825+
const isAnimatedPiece = isPreviewOrigin;
2826+
const basePieceShadow =
2827+
cell === PLAYER_1
2828+
? "0 0 9px rgba(255,255,255,0.3)"
2829+
: "0 0 12px rgba(132,204,22,0.42)";
27632830

27642831
return (
27652832
<button
@@ -2789,30 +2856,45 @@ export function MatchPage(): JSX.Element {
27892856
} ${
27902857
isPreviewOrigin || isRecentTarget ? "scale-110" : ""
27912858
}`}
2859+
style={!isAnimatedPiece ? { boxShadow: basePieceShadow } : undefined}
27922860
initial={{ scale: 0.8, opacity: 0.8 }}
2793-
animate={{
2794-
scale: isPreviewOrigin ? 1.1 : 1,
2795-
opacity: 1,
2796-
y: isPreviewOrigin ? [-1, 1, -1] : 0,
2797-
boxShadow:
2798-
cell === PLAYER_2
2799-
? [
2800-
"0 0 8px rgba(132,204,22,0.32)",
2801-
"0 0 18px rgba(132,204,22,0.58)",
2802-
"0 0 8px rgba(132,204,22,0.32)",
2803-
]
2804-
: [
2805-
"0 0 7px rgba(255,255,255,0.22)",
2806-
"0 0 13px rgba(255,255,255,0.36)",
2807-
"0 0 7px rgba(255,255,255,0.22)",
2808-
],
2809-
}}
2810-
transition={{
2811-
scale: { type: "spring", stiffness: 360, damping: 24 },
2812-
opacity: { duration: 0.22 },
2813-
y: { duration: 1.2, repeat: Infinity, ease: "easeInOut" },
2814-
boxShadow: { duration: 1.8, repeat: Infinity, ease: "easeInOut" },
2815-
}}
2861+
animate={
2862+
isAnimatedPiece
2863+
? {
2864+
scale: 1.1,
2865+
opacity: 1,
2866+
y: [-1, 1, -1],
2867+
boxShadow:
2868+
cell === PLAYER_2
2869+
? [
2870+
"0 0 8px rgba(132,204,22,0.32)",
2871+
"0 0 18px rgba(132,204,22,0.58)",
2872+
"0 0 8px rgba(132,204,22,0.32)",
2873+
]
2874+
: [
2875+
"0 0 7px rgba(255,255,255,0.22)",
2876+
"0 0 13px rgba(255,255,255,0.36)",
2877+
"0 0 7px rgba(255,255,255,0.22)",
2878+
],
2879+
}
2880+
: {
2881+
scale: 1,
2882+
opacity: 1,
2883+
}
2884+
}
2885+
transition={
2886+
isAnimatedPiece
2887+
? {
2888+
scale: { type: "spring", stiffness: 360, damping: 24 },
2889+
opacity: { duration: 0.22 },
2890+
y: { duration: 1.2, repeat: Infinity, ease: "easeInOut" },
2891+
boxShadow: { duration: 1.8, repeat: Infinity, ease: "easeInOut" },
2892+
}
2893+
: {
2894+
scale: { type: "spring", stiffness: 360, damping: 24 },
2895+
opacity: { duration: 0.22 },
2896+
}
2897+
}
28162898
/>
28172899
)}
28182900
{isTarget && <span className="absolute h-2.5 w-2.5 rounded-full bg-zinc-200" />}

web/src/pages/profile/ProfilePage.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
3-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
44
import { AnimatePresence, motion } from "framer-motion";
55
import {
66
ArrowUpRight,
@@ -214,6 +214,8 @@ export function ProfilePage(): JSX.Element {
214214
queryKey: ["profile-games", offset, accessToken],
215215
queryFn: () => fetchMyGames(accessToken!, PAGE_SIZE, offset, ["finished"]),
216216
enabled: Boolean(accessToken),
217+
placeholderData: keepPreviousData,
218+
staleTime: 30_000,
217219
});
218220
const {
219221
invitations: invitationItems,
@@ -240,12 +242,16 @@ export function ProfilePage(): JSX.Element {
240242
queryKey: ["my-rating", user?.id, activeSeasonQuery.data?.id],
241243
queryFn: () => fetchUserRating(user!.id, activeSeasonQuery.data!.id),
242244
enabled: Boolean(user?.id && activeSeasonQuery.data?.id),
245+
placeholderData: keepPreviousData,
246+
staleTime: 30_000,
243247
});
244248

245249
const eventsQuery = useQuery({
246250
queryKey: ["profile-rating-events", user?.id, activeSeasonQuery.data?.id],
247251
queryFn: () => fetchRatingEvents(user!.id, activeSeasonQuery.data!.id, 6, 0),
248252
enabled: Boolean(user?.id && activeSeasonQuery.data?.id),
253+
placeholderData: keepPreviousData,
254+
staleTime: 30_000,
249255
});
250256

251257
useEffect(() => {
@@ -465,7 +471,9 @@ export function ProfilePage(): JSX.Element {
465471
<CardDescription>Lectura rapida de tu progreso en temporada activa.</CardDescription>
466472
</CardHeader>
467473
<CardContent className="space-y-3">
468-
{activeSeasonQuery.isLoading || ratingQuery.isLoading ? <p className="text-sm text-textDim">Sincronizando ladder...</p> : null}
474+
{ratingQuery.data === undefined && (activeSeasonQuery.isLoading || ratingQuery.isLoading) ? (
475+
<p className="text-sm text-textDim">Sincronizando ladder...</p>
476+
) : null}
469477
{activeSeasonQuery.isError || ratingQuery.isError ? <p className="text-sm text-redGlow">No se pudo cargar el estado competitivo.</p> : null}
470478

471479
{ratingQuery.data ? (
@@ -567,9 +575,11 @@ export function ProfilePage(): JSX.Element {
567575
<CardDescription>Ultimos cambios de rating en temporada activa.</CardDescription>
568576
</CardHeader>
569577
<CardContent>
570-
{eventsQuery.isLoading ? <p className="text-sm text-textDim">Cargando actividad...</p> : null}
578+
{eventsQuery.isLoading && eventsQuery.data === undefined ? (
579+
<p className="text-sm text-textDim">Cargando actividad...</p>
580+
) : null}
571581
{eventsQuery.isError ? <p className="text-sm text-redGlow">No se pudo cargar la actividad de ladder.</p> : null}
572-
{!eventsQuery.isLoading && (eventsQuery.data?.items.length ?? 0) === 0 ? (
582+
{!eventsQuery.isLoading && !eventsQuery.isError && (eventsQuery.data?.items.length ?? 0) === 0 ? (
573583
<p className="text-sm text-textDim">Aun no hay eventos competitivos para mostrar.</p>
574584
) : null}
575585

@@ -636,7 +646,9 @@ export function ProfilePage(): JSX.Element {
636646
<CardDescription>Solo partidas finalizadas de tu cuenta autenticada.</CardDescription>
637647
</CardHeader>
638648
<CardContent className="space-y-3">
639-
{gamesQuery.isLoading ? <p className="text-sm text-textDim">Cargando partidas...</p> : null}
649+
{gamesQuery.isLoading && gamesQuery.data === undefined ? (
650+
<p className="text-sm text-textDim">Cargando partidas...</p>
651+
) : null}
640652
{gamesQuery.isError ? <p className="text-sm text-redGlow">No se pudo cargar el historial de partidas.</p> : null}
641653
{deleteError ? <p className="text-sm text-redGlow">{deleteError}</p> : null}
642654
{!gamesQuery.isLoading && !gamesQuery.isError && (gamesQuery.data?.items.length ?? 0) === 0 ? (

web/src/shared/lib/sfx.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,13 @@ export function primeSfxOnFirstInteraction(paths: readonly string[], poolSize =
137137
}
138138
primeSfx(paths, poolSize);
139139
window.removeEventListener("pointerdown", primeOnce);
140+
window.removeEventListener("pointermove", primeOnce);
140141
window.removeEventListener("touchstart", primeOnce);
141142
window.removeEventListener("keydown", primeOnce);
142143
};
143144
primedOnInteraction = true;
144145
window.addEventListener("pointerdown", primeOnce, { once: true });
146+
window.addEventListener("pointermove", primeOnce, { once: true, passive: true });
145147
window.addEventListener("touchstart", primeOnce, { once: true });
146148
window.addEventListener("keydown", primeOnce, { once: true });
147149
}

0 commit comments

Comments
 (0)