1- import { CSSProperties , useState } from "react" ;
1+ import { CSSProperties , useLayoutEffect , useState } from "react" ;
22import { Move , Piece as PieceT , Square , squareName } from "../engine/board" ;
33import { findKing , inCheck } from "../engine/rules" ;
44import { 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+
1122interface 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 >
0 commit comments