@scriptonita/chess-football-ui 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -149,6 +149,7 @@ var Football = ({ className }) => {
149
149
  var STATIC_I18N_KEYS = [
150
150
  "actionPointsAriaLabel",
151
151
  "actionPointsShort",
152
+ "ballHolder",
152
153
  "endTurn",
153
154
  "endTurnConfirm",
154
155
  "endTurnConfirmDescription",
@@ -171,6 +172,13 @@ var STATIC_I18N_KEYS = [
171
172
  "selectedPiece.atSquare",
172
173
  "selectedPiece.empty",
173
174
  "selectedPiece.hasBall",
175
+ "shortcuts.arrows",
176
+ "shortcuts.buttonLabel",
177
+ "shortcuts.cancel",
178
+ "shortcuts.move",
179
+ "shortcuts.pass",
180
+ "shortcuts.select",
181
+ "shortcuts.title",
174
182
  "teamBlack",
175
183
  "teamWhite",
176
184
  "waitingRival",
@@ -202,6 +210,7 @@ var ROWS = 12;
202
210
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
203
211
  function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
204
212
  const t = useGameT();
213
+ const prefersReducedMotion = framerMotion.useReducedMotion();
205
214
  const {
206
215
  boardState,
207
216
  selectedPieceId,
@@ -212,11 +221,62 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
212
221
  } = chunkLTFNSTAQ_cjs.useGameStore();
213
222
  const [cursor, setCursor] = React.useState(null);
214
223
  const [disambiguateAt, setDisambiguateAt] = React.useState(null);
224
+ const [shortcutsOpen, setShortcutsOpen] = React.useState(false);
225
+ const shortcutsRef = React.useRef(null);
226
+ React.useEffect(() => {
227
+ if (!shortcutsOpen) return;
228
+ const onPointerDown = (e) => {
229
+ if (shortcutsRef.current && !shortcutsRef.current.contains(e.target)) {
230
+ setShortcutsOpen(false);
231
+ }
232
+ };
233
+ document.addEventListener("mousedown", onPointerDown);
234
+ return () => document.removeEventListener("mousedown", onPointerDown);
235
+ }, [shortcutsOpen]);
236
+ const [invalidClickAt, setInvalidClickAt] = React.useState(null);
237
+ const invalidClickNonceRef = React.useRef(0);
238
+ const triggerInvalidClickShake = (x, y) => {
239
+ invalidClickNonceRef.current += 1;
240
+ setInvalidClickAt({ x, y, nonce: invalidClickNonceRef.current });
241
+ };
242
+ const [hoveredPassAt, setHoveredPassAt] = React.useState(null);
243
+ React.useEffect(() => {
244
+ if (!invalidClickAt) return;
245
+ const id = setTimeout(() => setInvalidClickAt(null), 100);
246
+ return () => clearTimeout(id);
247
+ }, [invalidClickAt]);
215
248
  const prevBallRef = React.useRef(boardState?.ball);
216
249
  const prevBall = prevBallRef.current;
217
250
  React.useEffect(() => {
218
251
  prevBallRef.current = boardState?.ball;
219
252
  });
253
+ const prevTurnRef = React.useRef(boardState?.turn);
254
+ const [replay, setReplay] = React.useState(null);
255
+ React.useEffect(() => {
256
+ const prevTurn = prevTurnRef.current;
257
+ prevTurnRef.current = boardState?.turn;
258
+ if (!boardState || prevTurn === boardState.turn) return;
259
+ if (boardState.turn !== userSide) {
260
+ setReplay(null);
261
+ return;
262
+ }
263
+ if (prefersReducedMotion || userSide === null) return;
264
+ const lastEntry = boardState.moveHistory.at(-1);
265
+ if (!lastEntry) return;
266
+ const steps = boardState.moveHistory.filter(
267
+ (m) => m.turnNumber === lastEntry.turnNumber && m.pieceSide !== userSide
268
+ );
269
+ if (steps.length > 1) setReplay({ steps, index: 0 });
270
+ }, [boardState, userSide, prefersReducedMotion]);
271
+ React.useEffect(() => {
272
+ if (!replay) return;
273
+ if (replay.index >= replay.steps.length - 1) {
274
+ const id2 = setTimeout(() => setReplay(null), 350);
275
+ return () => clearTimeout(id2);
276
+ }
277
+ const id = setTimeout(() => setReplay((r) => r ? { ...r, index: r.index + 1 } : null), 350);
278
+ return () => clearTimeout(id);
279
+ }, [replay]);
220
280
  const validMoves = React.useMemo(() => {
221
281
  if (!boardState) return [];
222
282
  const sp = boardState.pieces.find((p) => p.id === selectedPieceId);
@@ -234,6 +294,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
234
294
  return [];
235
295
  }, [boardState, selectedPieceId]);
236
296
  if (!boardState) return null;
297
+ const ballHolderPiece = boardState.pieces.find((p) => p.id === boardState.ball.holderId) ?? null;
237
298
  const lastMove = boardState.lastMove;
238
299
  const ball = boardState.ball;
239
300
  const ballMoved = !!prevBall && (prevBall.pos.x !== ball.pos.x || prevBall.pos.y !== ball.pos.y);
@@ -254,7 +315,11 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
254
315
  }
255
316
  const ballCarrier = ball.holderId ? boardState.pieces.find((p) => p.id === ball.holderId) ?? null : null;
256
317
  const isOffsideRisk = !!ballCarrier && ballCarrier.type !== "king" && userSide !== null && ballCarrier.side === userSide && boardState.turn === userSide && chessFootballEngine.isInEnemyArea(ballCarrier.pos, ballCarrier.side);
318
+ const passCarrier = boardState.pieces.find((p) => p.id === selectedPieceId && boardState.ball.holderId === p.id) ?? null;
319
+ const trajectoryPath = hoveredPassAt && passCarrier ? passCarrier.type === "knight" ? [hoveredPassAt] : chessFootballEngine.getPath(passCarrier.pos, hoveredPassAt) : null;
320
+ const trajectoryIntercepted = !!(trajectoryPath && passCarrier && trajectoryPath.some((sq) => boardState.pieces.some((p) => p.pos.x === sq.x && p.pos.y === sq.y && p.side !== passCarrier.side)));
257
321
  const handleSquareClick = (x, y) => {
322
+ if (replay) return;
258
323
  setCursor(null);
259
324
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
260
325
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
@@ -265,15 +330,21 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
265
330
  if (isValidMove && selectedPieceId) {
266
331
  movePiece(selectedPieceId, { x, y });
267
332
  setDisambiguateAt(null);
333
+ setInvalidClickAt(null);
268
334
  } else if (isValidPass) {
269
335
  passBall({ x, y });
270
336
  setDisambiguateAt(null);
337
+ setInvalidClickAt(null);
271
338
  } else {
339
+ if (selectedPieceId) {
340
+ triggerInvalidClickShake(x, y);
341
+ }
272
342
  setDisambiguateAt(null);
273
343
  }
274
344
  };
275
345
  const handlePieceClick = (pieceId, x, y, e) => {
276
346
  e.stopPropagation();
347
+ if (replay) return;
277
348
  const pieceAt = boardState.pieces.find((p) => p.id === pieceId);
278
349
  if (!pieceAt) return;
279
350
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
@@ -287,6 +358,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
287
358
  setSelectedPieceId(pieceAt.id);
288
359
  }
289
360
  setDisambiguateAt(null);
361
+ setInvalidClickAt(null);
362
+ } else if (selectedPieceId) {
363
+ triggerInvalidClickShake(x, y);
364
+ setDisambiguateAt(null);
290
365
  }
291
366
  };
292
367
  const defaultCursor = () => {
@@ -303,7 +378,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
303
378
  }
304
379
  };
305
380
  const handleKeyDown = (e) => {
306
- if (!keyboardNav) return;
381
+ if (!keyboardNav || replay) return;
307
382
  const step = (dx, dy) => {
308
383
  e.preventDefault();
309
384
  setCursor((c) => {
@@ -347,7 +422,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
347
422
  }
348
423
  break;
349
424
  case "Escape":
350
- if (disambiguateAt) {
425
+ if (shortcutsOpen) {
426
+ setShortcutsOpen(false);
427
+ } else if (disambiguateAt) {
351
428
  setDisambiguateAt(null);
352
429
  } else {
353
430
  setSelectedPieceId(null);
@@ -365,8 +442,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
365
442
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
366
443
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
367
444
  const isAmbiguous = isValidMove && isValidPass;
368
- const isLastMoveOrigin = lastMove?.from && lastMove.from.x === x && lastMove.from.y === y;
369
- const isLastMoveDest = lastMove?.to && lastMove.to.x === x && lastMove.to.y === y;
445
+ const displayedLastMove = replay ? replay.steps[replay.index] : lastMove;
446
+ const isLastMoveOrigin = displayedLastMove?.from && displayedLastMove.from.x === x && displayedLastMove.from.y === y;
447
+ const isLastMoveDest = displayedLastMove?.to && displayedLastMove.to.x === x && displayedLastMove.to.y === y;
370
448
  const isLastMove = isLastMoveOrigin || isLastMoveDest;
371
449
  const isEnemyGoalArea = userSide !== null && (x >= 2 && x <= 6) && (userSide === "white" ? y >= 10 && y <= 11 : y >= 0 && y <= 1);
372
450
  const pieceAt = boardState.pieces.find((p) => p.pos.x === x && p.pos.y === y);
@@ -377,6 +455,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
377
455
  "div",
378
456
  {
379
457
  onClick: () => handleSquareClick(x, y),
458
+ onMouseEnter: () => {
459
+ if (isValidPass) setHoveredPassAt({ x, y });
460
+ },
461
+ onMouseLeave: () => setHoveredPassAt(null),
380
462
  className: cn(
381
463
  "relative aspect-square w-full flex items-center justify-center cursor-pointer",
382
464
  isEven ? "bg-field-green-1" : "bg-field-green-2",
@@ -449,7 +531,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
449
531
  }
450
532
  return squares;
451
533
  };
452
- const board = /* @__PURE__ */ jsxRuntime.jsx(
534
+ const board = /* @__PURE__ */ jsxRuntime.jsxs(
453
535
  "div",
454
536
  {
455
537
  className: cn(
@@ -466,104 +548,220 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
466
548
  "aria-label": "Game board \u2014 arrow keys to move the cursor, Enter to act",
467
549
  onKeyDown: handleKeyDown
468
550
  } : {},
469
- children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative", style: { containerType: "inline-size" }, children: [
470
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-9 gap-[1px]", children: renderSquares() }),
471
- /* @__PURE__ */ jsxRuntime.jsxs(
472
- "svg",
473
- {
474
- viewBox: "0 0 9 12",
475
- preserveAspectRatio: "none",
476
- className: "absolute inset-0 w-full h-full pointer-events-none",
477
- "aria-hidden": "true",
478
- children: [
479
- /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "2", y: "0", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
480
- /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "2", y: "10", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
481
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "0", y1: "6", x2: "9", y2: "6", stroke: "white", strokeOpacity: "0.35", strokeWidth: "0.05" }),
482
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "6", r: "1.5", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
483
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "6", r: "0.12", fill: "white", fillOpacity: "0.5" }),
484
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "1.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
485
- /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "10.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
486
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M 3.086,2 A 1.5,1.5 0 0 0 5.914,2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
487
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M 3.086,10 A 1.5,1.5 0 0 1 5.914,10", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" })
488
- ]
489
- }
490
- ),
491
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 pointer-events-none", children: [
492
- /* @__PURE__ */ jsxRuntime.jsx(framerMotion.AnimatePresence, { mode: "sync", children: boardState.pieces.map((piece) => {
493
- const isPickupCarrier = pickupCarrierId === piece.id;
494
- const pieceTransition = isPickupCarrier ? { duration: PICKUP_DURATION, ease: "linear" } : { type: "spring", stiffness: 200, damping: 25, mass: 1 };
495
- return /* @__PURE__ */ jsxRuntime.jsx(
496
- framerMotion.motion.div,
551
+ children: [
552
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "hidden md:flex items-center justify-between gap-2 px-2.5 py-1", children: [
553
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1.5 min-h-[1.75rem]", "aria-live": "polite", children: ballHolderPiece && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
554
+ /* @__PURE__ */ jsxRuntime.jsxs(
555
+ "div",
497
556
  {
498
- layout: true,
499
- initial: false,
500
- animate: {
501
- left: `${piece.pos.x / COLS * 100}%`,
502
- top: `${(ROWS - 1 - piece.pos.y) / ROWS * 100}%`
503
- },
504
- transition: pieceTransition,
505
- style: {
506
- position: "absolute",
507
- width: `${100 / COLS}%`,
508
- height: `${100 / ROWS}%`
509
- },
510
- className: "flex items-center justify-center pointer-events-auto",
511
- children: /* @__PURE__ */ jsxRuntime.jsx(
512
- GamePiece,
557
+ style: ballHolderPiece.side === "white" ? WHITE_PIECE_STYLE : BLACK_PIECE_STYLE,
558
+ className: "relative w-7 h-7 flex items-center justify-center rounded-full shrink-0",
559
+ children: [
560
+ /* @__PURE__ */ jsxRuntime.jsx(PieceIcon, { type: ballHolderPiece.type, side: ballHolderPiece.side }),
561
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute -bottom-1 -right-1 w-3.5 h-3.5 flex items-center justify-center rounded-full bg-bg-secondary border border-border-subtle", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-2.5 h-2.5", children: /* @__PURE__ */ jsxRuntime.jsx(Football, {}) }) })
562
+ ]
563
+ }
564
+ ),
565
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "font-inter text-[11px] text-fg-secondary", children: [
566
+ t("ballHolder"),
567
+ ": ",
568
+ t(`pieces.${ballHolderPiece.type}`)
569
+ ] })
570
+ ] }) }),
571
+ keyboardNav && /* @__PURE__ */ jsxRuntime.jsxs(
572
+ "div",
573
+ {
574
+ ref: shortcutsRef,
575
+ className: "relative",
576
+ onKeyDown: (e) => e.stopPropagation(),
577
+ children: [
578
+ /* @__PURE__ */ jsxRuntime.jsx(
579
+ "button",
513
580
  {
514
- piece,
515
- isSelected: selectedPieceId === piece.id,
516
- hasBall: boardState.ball.holderId === piece.id,
517
- onClick: (e) => handlePieceClick(piece.id, piece.pos.x, piece.pos.y, e)
581
+ type: "button",
582
+ onClick: (e) => {
583
+ e.stopPropagation();
584
+ setShortcutsOpen((o) => !o);
585
+ },
586
+ "aria-label": t("shortcuts.buttonLabel"),
587
+ "aria-expanded": shortcutsOpen,
588
+ className: "w-8 h-8 flex items-center justify-center rounded-full bg-bg-secondary/80 border border-border-subtle text-fg-muted hover:text-fg-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-green",
589
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.HelpCircle, { size: 16, strokeWidth: 2, "aria-hidden": "true" })
590
+ }
591
+ ),
592
+ shortcutsOpen && /* @__PURE__ */ jsxRuntime.jsxs(
593
+ "div",
594
+ {
595
+ className: "absolute top-full right-0 mt-1 z-50 w-max max-w-[220px] bg-bg-secondary border border-border-subtle rounded-md shadow-xl p-2.5 pointer-events-auto",
596
+ onClick: (e) => e.stopPropagation(),
597
+ children: [
598
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "font-inter text-[11px] font-semibold text-fg-primary mb-1.5", children: t("shortcuts.title") }),
599
+ /* @__PURE__ */ jsxRuntime.jsxs("ul", { className: "font-mono text-[10px] text-fg-secondary leading-relaxed", children: [
600
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
601
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "\u2191\u2193\u2190\u2192" }),
602
+ " ",
603
+ t("shortcuts.arrows")
604
+ ] }),
605
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
606
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "Enter" }),
607
+ " ",
608
+ t("shortcuts.select")
609
+ ] }),
610
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
611
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "M" }),
612
+ " ",
613
+ t("shortcuts.move")
614
+ ] }),
615
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
616
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "P" }),
617
+ " ",
618
+ t("shortcuts.pass")
619
+ ] }),
620
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
621
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "Esc" }),
622
+ " ",
623
+ t("shortcuts.cancel")
624
+ ] })
625
+ ] })
626
+ ]
518
627
  }
519
628
  )
520
- },
521
- piece.id
522
- );
523
- }) }),
524
- (() => {
525
- const targetX = ball.pos.x / COLS * 100;
526
- const targetY = (ROWS - 1 - ball.pos.y) / ROWS * 100;
527
- let animate;
528
- let transition;
529
- if (isPickupOnMove && prevBall) {
530
- const prevX = prevBall.pos.x / COLS * 100;
531
- const prevY = (ROWS - 1 - prevBall.pos.y) / ROWS * 100;
532
- const pulseStart = Math.max(0.05, pickupProportion - 0.06);
533
- animate = {
534
- left: [`${prevX}%`, `${prevX}%`, `${prevX}%`, `${targetX}%`],
535
- top: [`${prevY}%`, `${prevY}%`, `${prevY}%`, `${targetY}%`],
536
- scale: [1, 1, 1.35, 1]
537
- };
538
- transition = { duration: PICKUP_DURATION, times: [0, pulseStart, pickupProportion, 1], ease: "linear" };
539
- } else {
540
- animate = { left: `${targetX}%`, top: `${targetY}%`, scale: 1 };
541
- transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.5 };
629
+ ]
542
630
  }
543
- return /* @__PURE__ */ jsxRuntime.jsx(
631
+ )
632
+ ] }),
633
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative", style: { containerType: "inline-size" }, children: [
634
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-9 gap-[1px]", children: renderSquares() }),
635
+ /* @__PURE__ */ jsxRuntime.jsxs(
636
+ "svg",
637
+ {
638
+ viewBox: "0 0 9 12",
639
+ preserveAspectRatio: "none",
640
+ className: "absolute inset-0 w-full h-full pointer-events-none",
641
+ "aria-hidden": "true",
642
+ children: [
643
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "2", y: "0", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
644
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "2", y: "10", width: "5", height: "2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
645
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "0", y1: "6", x2: "9", y2: "6", stroke: "white", strokeOpacity: "0.35", strokeWidth: "0.05" }),
646
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "6", r: "1.5", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
647
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "6", r: "0.12", fill: "white", fillOpacity: "0.5" }),
648
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "1.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
649
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "10.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
650
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M 3.086,2 A 1.5,1.5 0 0 0 5.914,2", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
651
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M 3.086,10 A 1.5,1.5 0 0 1 5.914,10", fill: "none", stroke: "white", strokeOpacity: "0.3", strokeWidth: "0.05" }),
652
+ hoveredPassAt && passCarrier && /* @__PURE__ */ jsxRuntime.jsx(
653
+ "line",
654
+ {
655
+ "data-testid": "pass-trajectory-line",
656
+ x1: passCarrier.pos.x + 0.5,
657
+ y1: ROWS - 1 - passCarrier.pos.y + 0.5,
658
+ x2: hoveredPassAt.x + 0.5,
659
+ y2: ROWS - 1 - hoveredPassAt.y + 0.5,
660
+ stroke: trajectoryIntercepted ? "var(--danger)" : "var(--pass-highlight)",
661
+ strokeWidth: "0.08",
662
+ strokeDasharray: "0.15 0.1",
663
+ strokeLinecap: "round"
664
+ }
665
+ )
666
+ ]
667
+ }
668
+ ),
669
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 pointer-events-none", children: [
670
+ /* @__PURE__ */ jsxRuntime.jsx(framerMotion.AnimatePresence, { mode: "sync", children: boardState.pieces.map((piece) => {
671
+ const isPickupCarrier = pickupCarrierId === piece.id;
672
+ const pieceTransition = isPickupCarrier ? { duration: PICKUP_DURATION, ease: "linear" } : { type: "spring", stiffness: 200, damping: 25, mass: 1 };
673
+ return /* @__PURE__ */ jsxRuntime.jsx(
674
+ framerMotion.motion.div,
675
+ {
676
+ layout: true,
677
+ initial: false,
678
+ animate: {
679
+ left: `${piece.pos.x / COLS * 100}%`,
680
+ top: `${(ROWS - 1 - piece.pos.y) / ROWS * 100}%`
681
+ },
682
+ transition: pieceTransition,
683
+ style: {
684
+ position: "absolute",
685
+ width: `${100 / COLS}%`,
686
+ height: `${100 / ROWS}%`
687
+ },
688
+ className: "flex items-center justify-center pointer-events-auto",
689
+ children: /* @__PURE__ */ jsxRuntime.jsx(
690
+ GamePiece,
691
+ {
692
+ piece,
693
+ isSelected: selectedPieceId === piece.id,
694
+ hasBall: boardState.ball.holderId === piece.id,
695
+ onClick: (e) => handlePieceClick(piece.id, piece.pos.x, piece.pos.y, e)
696
+ }
697
+ )
698
+ },
699
+ piece.id
700
+ );
701
+ }) }),
702
+ (() => {
703
+ const targetX = ball.pos.x / COLS * 100;
704
+ const targetY = (ROWS - 1 - ball.pos.y) / ROWS * 100;
705
+ let animate;
706
+ let transition;
707
+ if (isPickupOnMove && prevBall) {
708
+ const prevX = prevBall.pos.x / COLS * 100;
709
+ const prevY = (ROWS - 1 - prevBall.pos.y) / ROWS * 100;
710
+ const pulseStart = Math.max(0.05, pickupProportion - 0.06);
711
+ animate = {
712
+ left: [`${prevX}%`, `${prevX}%`, `${prevX}%`, `${targetX}%`],
713
+ top: [`${prevY}%`, `${prevY}%`, `${prevY}%`, `${targetY}%`],
714
+ scale: [1, 1, 1.35, 1]
715
+ };
716
+ transition = { duration: PICKUP_DURATION, times: [0, pulseStart, pickupProportion, 1], ease: "linear" };
717
+ } else {
718
+ animate = { left: `${targetX}%`, top: `${targetY}%`, scale: 1 };
719
+ transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.5 };
720
+ }
721
+ return /* @__PURE__ */ jsxRuntime.jsx(
722
+ framerMotion.motion.div,
723
+ {
724
+ initial: false,
725
+ animate,
726
+ transition,
727
+ style: {
728
+ position: "absolute",
729
+ width: `${100 / COLS}%`,
730
+ height: `${100 / ROWS}%`,
731
+ zIndex: 50
732
+ },
733
+ className: "flex items-center justify-center pointer-events-none",
734
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-[60%] h-[60%]", children: /* @__PURE__ */ jsxRuntime.jsx(Football, {}) })
735
+ },
736
+ "ball"
737
+ );
738
+ })(),
739
+ invalidClickAt && /* @__PURE__ */ jsxRuntime.jsx(
544
740
  framerMotion.motion.div,
545
741
  {
742
+ "data-testid": "invalid-action-shake",
743
+ "aria-hidden": "true",
546
744
  initial: false,
547
- animate,
548
- transition,
745
+ animate: prefersReducedMotion ? { opacity: [0.9, 0] } : { x: [0, -3, 3, -3, 3, 0] },
746
+ transition: { duration: 0.08 },
549
747
  style: {
550
748
  position: "absolute",
749
+ left: `${invalidClickAt.x / COLS * 100}%`,
750
+ top: `${(ROWS - 1 - invalidClickAt.y) / ROWS * 100}%`,
551
751
  width: `${100 / COLS}%`,
552
- height: `${100 / ROWS}%`,
553
- zIndex: 50
752
+ height: `${100 / ROWS}%`
554
753
  },
555
- className: "flex items-center justify-center pointer-events-none",
556
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-[60%] h-[60%]", children: /* @__PURE__ */ jsxRuntime.jsx(Football, {}) })
754
+ className: "ring-2 ring-inset ring-danger/70 rounded-sm"
557
755
  },
558
- "ball"
559
- );
560
- })()
561
- ] }),
562
- isOffsideRisk && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1.5 bg-warning/20 border border-warning/50 rounded-full font-inter text-xs font-semibold text-warning pointer-events-none whitespace-nowrap backdrop-blur-sm", children: [
563
- "\u26A0 ",
564
- t("offsideWarning")
756
+ invalidClickAt.nonce
757
+ )
758
+ ] }),
759
+ isOffsideRisk && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1.5 bg-warning/20 border border-warning/50 rounded-full font-inter text-xs font-semibold text-warning pointer-events-none whitespace-nowrap backdrop-blur-sm", children: [
760
+ "\u26A0 ",
761
+ t("offsideWarning")
762
+ ] })
565
763
  ] })
566
- ] })
764
+ ]
567
765
  }
568
766
  );
569
767
  if (!showCoordinates) return board;
@@ -608,31 +806,50 @@ function AvatarFallback({ className, children }) {
608
806
  }
609
807
  function ActionPoints({ total, remaining, size = 20, className, kingMustRelease }) {
610
808
  const t = useGameT();
809
+ const prevRemainingRef = React.useRef(remaining);
810
+ const [spentRange, setSpentRange] = React.useState(null);
811
+ React.useEffect(() => {
812
+ const prev = prevRemainingRef.current;
813
+ prevRemainingRef.current = remaining;
814
+ if (remaining < prev) {
815
+ setSpentRange([remaining, prev - 1]);
816
+ const id = setTimeout(() => setSpentRange(null), 300);
817
+ return () => clearTimeout(id);
818
+ }
819
+ setSpentRange(null);
820
+ }, [remaining]);
611
821
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: cn("flex items-center gap-1.5", className), "aria-label": t("actionPointsAriaLabel", { remaining, total }), children: Array.from({ length: total }).map((_, i) => {
612
822
  const isActive = i < remaining;
613
823
  const showCrown = kingMustRelease && i === total - 1;
614
- if (showCrown) {
615
- return /* @__PURE__ */ jsxRuntime.jsx(
616
- lucideReact.Crown,
617
- {
618
- size,
619
- strokeWidth: 2,
620
- className: "text-yellow-400 fill-yellow-400 transition-colors duration-150",
621
- "aria-hidden": "true"
622
- },
623
- i
624
- );
625
- }
824
+ const justSpent = !!spentRange && i >= spentRange[0] && i <= spentRange[1];
626
825
  return /* @__PURE__ */ jsxRuntime.jsx(
627
- lucideReact.Zap,
826
+ framerMotion.motion.span,
628
827
  {
629
- size,
630
- strokeWidth: 2,
631
- className: cn(
632
- "transition-colors duration-150",
633
- isActive ? "text-accent-green fill-accent-green" : "text-fg-muted fill-none"
634
- ),
635
- "aria-hidden": "true"
828
+ "data-testid": `ap-pip-${i}`,
829
+ "data-spent": justSpent || void 0,
830
+ className: "inline-flex",
831
+ animate: justSpent ? { scale: [1.5, 1] } : void 0,
832
+ transition: { duration: 0.3 },
833
+ children: showCrown ? /* @__PURE__ */ jsxRuntime.jsx(
834
+ lucideReact.Crown,
835
+ {
836
+ size,
837
+ strokeWidth: 2,
838
+ className: "text-yellow-400 fill-yellow-400 transition-colors duration-150",
839
+ "aria-hidden": "true"
840
+ }
841
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
842
+ lucideReact.Zap,
843
+ {
844
+ size,
845
+ strokeWidth: 2,
846
+ className: cn(
847
+ "transition-colors duration-150",
848
+ isActive ? "text-accent-green fill-accent-green" : "text-fg-muted fill-none"
849
+ ),
850
+ "aria-hidden": "true"
851
+ }
852
+ )
636
853
  },
637
854
  i
638
855
  );