Skip to content

Commit 685fd53

Browse files
committed
Fix iPhone move animations and replay behavior
1 parent ff8fc22 commit 685fd53

4 files changed

Lines changed: 162 additions & 57 deletions

File tree

src/GameContext.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,15 +254,13 @@ export function GameProvider({ children }: { children: ReactNode }) {
254254
// human's own animation has time to play before the board re-renders
255255
// with the bot's response. Delay scales with animation-speed setting
256256
// so replay/slow modes remain visually readable.
257-
const prevMove = state.history[state.history.length - 1];
258257
const speed = store.settings.animationSpeed;
259-
const thinkDelays =
258+
const minThinkMs =
260259
speed === "very-slow"
261-
? { slide: 2300, teleport: 6200 }
260+
? 1900
262261
: speed === "slow"
263-
? { slide: 1600, teleport: 4800 }
264-
: { slide: 1200, teleport: 3700 };
265-
const minThinkMs = prevMove?.isPortalEntry ? thinkDelays.teleport : thinkDelays.slide;
262+
? 1200
263+
: 800;
266264
const t0 = performance.now();
267265
const move = await chooseBotMove(state, lvl, { allowExternal: mode.kind === "bot" });
268266
const elapsed = performance.now() - t0;

src/components/Board.tsx

Lines changed: 83 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CSSProperties, useState } from "react";
1+
import { CSSProperties, useLayoutEffect, useState } from "react";
22
import { Move, Piece as PieceT, Square, squareName } from "../engine/board";
33
import { findKing, inCheck } from "../engine/rules";
44
import { useGame } from "../GameContext";
@@ -8,6 +8,17 @@ function isLegalTarget(legal: Move[], sq: Square): Move | undefined {
88
return legal.find((m) => m.to.file === sq.file && m.to.rank === sq.rank);
99
}
1010

11+
function slideDurationMs(speed: "normal" | "slow" | "very-slow", isMobile: boolean): number {
12+
if (isMobile) {
13+
if (speed === "very-slow") return 1700;
14+
if (speed === "slow") return 1100;
15+
return 700;
16+
}
17+
if (speed === "very-slow") return 1300;
18+
if (speed === "slow") return 800;
19+
return 500;
20+
}
21+
1122
interface Props {
1223
flipped?: boolean;
1324
}
@@ -35,6 +46,8 @@ export function Board({ flipped = false }: Props) {
3546
const pieceSet = store.settings.pieceSet;
3647
const animationSpeed = store.settings.animationSpeed;
3748
const [pending, setPending] = useState<PendingPromo | null>(null);
49+
const [isAnimatingLastMove, setIsAnimatingLastMove] = useState(false);
50+
const [showCaptureBoom, setShowCaptureBoom] = useState(false);
3851

3952
const ranks = flipped ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0];
4053
const files = flipped ? [7, 6, 5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5, 6, 7];
@@ -68,12 +81,51 @@ export function Board({ flipped = false }: Props) {
6881
: null;
6982
const wPortals = state.portals?.w ?? [];
7083
const bPortals = state.portals?.b ?? [];
71-
const isTeleportMove = !!lastMove?.isPortalEntry;
7284
const rotateBlackForFixedBoard =
7385
mode.kind === "two-player" &&
7486
!store.settings.autoFlip &&
7587
store.settings.rotateBlackPiecesFixedBoard;
7688

89+
useLayoutEffect(() => {
90+
const current = state.history[state.history.length - 1];
91+
setShowCaptureBoom(false);
92+
93+
if (!current || current.from.file < 0 || current.to.file < 0) {
94+
setIsAnimatingLastMove(false);
95+
return;
96+
}
97+
98+
const isMobile =
99+
typeof window !== "undefined" &&
100+
window.matchMedia("(max-width: 640px)").matches;
101+
const durationMs = slideDurationMs(animationSpeed, isMobile);
102+
103+
if (durationMs <= 0) {
104+
setIsAnimatingLastMove(false);
105+
if (current.captured && store.settings.explodeOnCapture) {
106+
setShowCaptureBoom(true);
107+
const boomId = window.setTimeout(() => setShowCaptureBoom(false), 620);
108+
return () => window.clearTimeout(boomId);
109+
}
110+
return;
111+
}
112+
113+
setIsAnimatingLastMove(true);
114+
let boomId: number | undefined;
115+
const finishId = window.setTimeout(() => {
116+
setIsAnimatingLastMove(false);
117+
if (current.captured && store.settings.explodeOnCapture) {
118+
setShowCaptureBoom(true);
119+
boomId = window.setTimeout(() => setShowCaptureBoom(false), 620);
120+
}
121+
}, durationMs);
122+
123+
return () => {
124+
window.clearTimeout(finishId);
125+
if (boomId !== undefined) window.clearTimeout(boomId);
126+
};
127+
}, [state.history.length, lastMoveReplayNonce, animationSpeed, store.settings.explodeOnCapture]);
128+
77129
return (
78130
<>
79131
<div className={`board board-theme-${theme} piece-set-${pieceSet} anim-speed-${animationSpeed}`}>
@@ -94,6 +146,9 @@ export function Board({ flipped = false }: Props) {
94146
const isHintTo = hint && hint.to.file === f && hint.to.rank === r;
95147
const isPortalW = wPortals.some((p) => p.file === f && p.rank === r);
96148
const isPortalB = bPortals.some((p) => p.file === f && p.rank === r);
149+
const slideEnd = lastMove?.portalTo ?? lastMove?.to;
150+
const isSlideDestination =
151+
!!slideEnd && slideEnd.file === f && slideEnd.rank === r;
97152
const isTeleportTarget =
98153
!!legal && !!legal.isPortalEntry;
99154
const classes = [
@@ -103,6 +158,7 @@ export function Board({ flipped = false }: Props) {
103158
legal && !isTeleportTarget ? (piece ? "legal-capture" : "legal-move") : "",
104159
isTeleportTarget ? "legal-teleport" : "",
105160
(isLastFrom || isLastTo || isLastTeleport) ? "last-move" : "",
161+
isSlideDestination && isAnimatingLastMove ? "animating-destination" : "",
106162
isChecked ? "in-check" : "",
107163
isHintFrom ? "hint-from" : "",
108164
isHintTo ? "hint-to" : ""
@@ -113,12 +169,11 @@ export function Board({ flipped = false }: Props) {
113169
// landing (portalTo). Otherwise slide to `to` as usual.
114170
let slideStyle: CSSProperties | undefined;
115171
let slideKey: string | undefined;
116-
const slideEnd = lastMove?.portalTo ?? lastMove?.to;
117172
const slideHere =
118173
slideEnd && slideEnd.file === f && slideEnd.rank === r && lastMove && piece;
119-
// Suppress the slide animation for teleport moves; we use a
120-
// dematerialise/rematerialise effect instead.
121-
if (slideHere && lastMove && slideEnd && !isTeleportMove) {
174+
// Animate every move as a continuous linear slide. For portal
175+
// entries, slide from `from` directly to the final `portalTo`.
176+
if (slideHere && lastMove && slideEnd) {
122177
const df = lastMove.from.file - slideEnd.file;
123178
const dr = slideEnd.rank - lastMove.from.rank;
124179
const sign = flipped ? -1 : 1;
@@ -128,17 +183,13 @@ export function Board({ flipped = false }: Props) {
128183
};
129184
slideKey = `slide-${moveAnimIndex}`;
130185
}
131-
const isRematerializeHere =
132-
isTeleportMove &&
133-
lastMove?.portalTo &&
134-
lastMove.portalTo.file === f &&
135-
lastMove.portalTo.rank === r &&
136-
piece;
137-
const isDematerializeHere =
138-
isTeleportMove &&
139-
lastMove &&
140-
lastMove.from.file === f &&
141-
lastMove.from.rank === r;
186+
const capturedColor =
187+
lastMove?.color === "w" ? "b" : "w";
188+
const showCapturedGhost =
189+
isLastTo &&
190+
isAnimatingLastMove &&
191+
!!lastMove?.captured &&
192+
!lastMove?.isEnPassant;
142193

143194
return (
144195
<button
@@ -153,15 +204,23 @@ export function Board({ flipped = false }: Props) {
153204
aria-hidden="true"
154205
/>
155206
)}
207+
{showCapturedGhost && lastMove?.captured && (
208+
<span className="captured-ghost" aria-hidden="true">
209+
<Piece
210+
color={capturedColor}
211+
type={lastMove.captured}
212+
set={pieceSet}
213+
rotate={rotateBlackForFixedBoard && capturedColor === "b"}
214+
/>
215+
</span>
216+
)}
156217
{piece && (
157218
<span
158-
key={isRematerializeHere ? `remat-${moveAnimIndex}` : slideKey}
219+
key={slideKey}
159220
className={
160-
isRematerializeHere
161-
? "piece-wrap piece-rematerialize"
162-
: slideKey
163-
? "piece-wrap piece-sliding"
164-
: "piece-wrap"
221+
slideKey && isAnimatingLastMove
222+
? "piece-wrap piece-sliding"
223+
: "piece-wrap"
165224
}
166225
style={slideStyle}
167226
>
@@ -173,21 +232,7 @@ export function Board({ flipped = false }: Props) {
173232
/>
174233
</span>
175234
)}
176-
{isDematerializeHere && lastMove && (
177-
<span
178-
key={`demat-${moveAnimIndex}`}
179-
className="piece-wrap piece-dematerialize"
180-
aria-hidden="true"
181-
>
182-
<Piece
183-
color={lastMove.color}
184-
type={lastMove.piece}
185-
set={pieceSet}
186-
rotate={rotateBlackForFixedBoard && lastMove.color === "b"}
187-
/>
188-
</span>
189-
)}
190-
{isLastTo && lastMove?.captured && store.settings.explodeOnCapture && (
235+
{isLastTo && showCaptureBoom && lastMove?.captured && !lastMove?.isEnPassant && (
191236
<span key={`boom-${moveAnimIndex}`} className="boom" aria-hidden="true">
192237
<span className="boom-core">💥</span>
193238
<span className="boom-bit b1"></span>

src/screens/GameScreen.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect, useRef, useState } from "react";
12
import { Link } from "react-router-dom";
23
import { Board } from "../components/Board";
34
import { Clock } from "../components/Clock";
@@ -7,10 +8,54 @@ import { LookPicker } from "../components/LookPicker";
78
import { MoveList } from "../components/MoveList";
89
import { useGame } from "../GameContext";
910

11+
function slideDurationMs(speed: "normal" | "slow" | "very-slow", isMobile: boolean): number {
12+
if (isMobile) {
13+
if (speed === "very-slow") return 1700;
14+
if (speed === "slow") return 1100;
15+
return 700;
16+
}
17+
if (speed === "very-slow") return 1300;
18+
if (speed === "slow") return 800;
19+
return 500;
20+
}
21+
1022
export function GameScreen() {
1123
const { mode, state, store, paused, togglePause } = useGame();
12-
const flipped =
13-
mode.kind === "two-player" && store.settings.autoFlip && state.turn === "b";
24+
const shouldAutoFlip = mode.kind === "two-player" && store.settings.autoFlip;
25+
const targetFlipped = shouldAutoFlip && state.turn === "b";
26+
const [flipped, setFlipped] = useState(targetFlipped);
27+
const prevHistLenRef = useRef(state.history.length);
28+
29+
useEffect(() => {
30+
const prevHistLen = prevHistLenRef.current;
31+
const historyAdvanced = state.history.length > prevHistLen;
32+
prevHistLenRef.current = state.history.length;
33+
34+
if (!shouldAutoFlip) {
35+
setFlipped(false);
36+
return;
37+
}
38+
39+
if (!historyAdvanced) {
40+
setFlipped(targetFlipped);
41+
return;
42+
}
43+
44+
const lastMove = state.history[state.history.length - 1];
45+
const isForfeitMove = !!lastMove && lastMove.from.file < 0;
46+
47+
if (isForfeitMove) {
48+
setFlipped(targetFlipped);
49+
return;
50+
}
51+
52+
const isMobile =
53+
typeof window !== "undefined" &&
54+
window.matchMedia("(max-width: 640px)").matches;
55+
const delayMs = slideDurationMs(store.settings.animationSpeed, isMobile) + 40;
56+
const id = window.setTimeout(() => setFlipped(targetFlipped), delayMs);
57+
return () => window.clearTimeout(id);
58+
}, [shouldAutoFlip, targetFlipped, state.history, state.history.length, store.settings.animationSpeed]);
1459

1560
return (
1661
<div className="screen game">

0 commit comments

Comments
 (0)