@@ -69,6 +69,7 @@ const UI_TICK_MS = 120;
6969const INTRO_COUNTDOWN_START = 3 ;
7070const HOVER_SFX_MIN_GAP_MS = 120 ;
7171const P2_MOVE_SFX_MIN_GAP_MS = 70 ;
72+ const RESOLVED_MOVE_HIGHLIGHT_MS = 1200 ;
7273const PERSIST_MAX_RETRIES = 3 ;
7374const PERSIST_BASE_BACKOFF_MS = 200 ;
7475const 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" /> }
0 commit comments