@scriptonita/chess-football-ui 0.4.2 → 0.5.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
@@ -171,6 +171,13 @@ var STATIC_I18N_KEYS = [
171
171
  "selectedPiece.atSquare",
172
172
  "selectedPiece.empty",
173
173
  "selectedPiece.hasBall",
174
+ "shortcuts.arrows",
175
+ "shortcuts.buttonLabel",
176
+ "shortcuts.cancel",
177
+ "shortcuts.move",
178
+ "shortcuts.pass",
179
+ "shortcuts.select",
180
+ "shortcuts.title",
174
181
  "teamBlack",
175
182
  "teamWhite",
176
183
  "waitingRival",
@@ -202,6 +209,7 @@ var ROWS = 12;
202
209
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
203
210
  function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
204
211
  const t = useGameT();
212
+ const prefersReducedMotion = framerMotion.useReducedMotion();
205
213
  const {
206
214
  boardState,
207
215
  selectedPieceId,
@@ -212,11 +220,62 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
212
220
  } = chunkLTFNSTAQ_cjs.useGameStore();
213
221
  const [cursor, setCursor] = React.useState(null);
214
222
  const [disambiguateAt, setDisambiguateAt] = React.useState(null);
223
+ const [shortcutsOpen, setShortcutsOpen] = React.useState(false);
224
+ const shortcutsRef = React.useRef(null);
225
+ React.useEffect(() => {
226
+ if (!shortcutsOpen) return;
227
+ const onPointerDown = (e) => {
228
+ if (shortcutsRef.current && !shortcutsRef.current.contains(e.target)) {
229
+ setShortcutsOpen(false);
230
+ }
231
+ };
232
+ document.addEventListener("mousedown", onPointerDown);
233
+ return () => document.removeEventListener("mousedown", onPointerDown);
234
+ }, [shortcutsOpen]);
235
+ const [invalidClickAt, setInvalidClickAt] = React.useState(null);
236
+ const invalidClickNonceRef = React.useRef(0);
237
+ const triggerInvalidClickShake = (x, y) => {
238
+ invalidClickNonceRef.current += 1;
239
+ setInvalidClickAt({ x, y, nonce: invalidClickNonceRef.current });
240
+ };
241
+ const [hoveredPassAt, setHoveredPassAt] = React.useState(null);
242
+ React.useEffect(() => {
243
+ if (!invalidClickAt) return;
244
+ const id = setTimeout(() => setInvalidClickAt(null), 100);
245
+ return () => clearTimeout(id);
246
+ }, [invalidClickAt]);
215
247
  const prevBallRef = React.useRef(boardState?.ball);
216
248
  const prevBall = prevBallRef.current;
217
249
  React.useEffect(() => {
218
250
  prevBallRef.current = boardState?.ball;
219
251
  });
252
+ const prevTurnRef = React.useRef(boardState?.turn);
253
+ const [replay, setReplay] = React.useState(null);
254
+ React.useEffect(() => {
255
+ const prevTurn = prevTurnRef.current;
256
+ prevTurnRef.current = boardState?.turn;
257
+ if (!boardState || prevTurn === boardState.turn) return;
258
+ if (boardState.turn !== userSide) {
259
+ setReplay(null);
260
+ return;
261
+ }
262
+ if (prefersReducedMotion || userSide === null) return;
263
+ const lastEntry = boardState.moveHistory.at(-1);
264
+ if (!lastEntry) return;
265
+ const steps = boardState.moveHistory.filter(
266
+ (m) => m.turnNumber === lastEntry.turnNumber && m.pieceSide !== userSide
267
+ );
268
+ if (steps.length > 1) setReplay({ steps, index: 0 });
269
+ }, [boardState, userSide, prefersReducedMotion]);
270
+ React.useEffect(() => {
271
+ if (!replay) return;
272
+ if (replay.index >= replay.steps.length - 1) {
273
+ const id2 = setTimeout(() => setReplay(null), 350);
274
+ return () => clearTimeout(id2);
275
+ }
276
+ const id = setTimeout(() => setReplay((r) => r ? { ...r, index: r.index + 1 } : null), 350);
277
+ return () => clearTimeout(id);
278
+ }, [replay]);
220
279
  const validMoves = React.useMemo(() => {
221
280
  if (!boardState) return [];
222
281
  const sp = boardState.pieces.find((p) => p.id === selectedPieceId);
@@ -254,7 +313,11 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
254
313
  }
255
314
  const ballCarrier = ball.holderId ? boardState.pieces.find((p) => p.id === ball.holderId) ?? null : null;
256
315
  const isOffsideRisk = !!ballCarrier && ballCarrier.type !== "king" && userSide !== null && ballCarrier.side === userSide && boardState.turn === userSide && chessFootballEngine.isInEnemyArea(ballCarrier.pos, ballCarrier.side);
316
+ const passCarrier = boardState.pieces.find((p) => p.id === selectedPieceId && boardState.ball.holderId === p.id) ?? null;
317
+ const trajectoryPath = hoveredPassAt && passCarrier ? passCarrier.type === "knight" ? [hoveredPassAt] : chessFootballEngine.getPath(passCarrier.pos, hoveredPassAt) : null;
318
+ 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
319
  const handleSquareClick = (x, y) => {
320
+ if (replay) return;
258
321
  setCursor(null);
259
322
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
260
323
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
@@ -265,15 +328,21 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
265
328
  if (isValidMove && selectedPieceId) {
266
329
  movePiece(selectedPieceId, { x, y });
267
330
  setDisambiguateAt(null);
331
+ setInvalidClickAt(null);
268
332
  } else if (isValidPass) {
269
333
  passBall({ x, y });
270
334
  setDisambiguateAt(null);
335
+ setInvalidClickAt(null);
271
336
  } else {
337
+ if (selectedPieceId) {
338
+ triggerInvalidClickShake(x, y);
339
+ }
272
340
  setDisambiguateAt(null);
273
341
  }
274
342
  };
275
343
  const handlePieceClick = (pieceId, x, y, e) => {
276
344
  e.stopPropagation();
345
+ if (replay) return;
277
346
  const pieceAt = boardState.pieces.find((p) => p.id === pieceId);
278
347
  if (!pieceAt) return;
279
348
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
@@ -287,6 +356,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
287
356
  setSelectedPieceId(pieceAt.id);
288
357
  }
289
358
  setDisambiguateAt(null);
359
+ setInvalidClickAt(null);
360
+ } else if (selectedPieceId) {
361
+ triggerInvalidClickShake(x, y);
362
+ setDisambiguateAt(null);
290
363
  }
291
364
  };
292
365
  const defaultCursor = () => {
@@ -303,7 +376,7 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
303
376
  }
304
377
  };
305
378
  const handleKeyDown = (e) => {
306
- if (!keyboardNav) return;
379
+ if (!keyboardNav || replay) return;
307
380
  const step = (dx, dy) => {
308
381
  e.preventDefault();
309
382
  setCursor((c) => {
@@ -347,7 +420,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
347
420
  }
348
421
  break;
349
422
  case "Escape":
350
- if (disambiguateAt) {
423
+ if (shortcutsOpen) {
424
+ setShortcutsOpen(false);
425
+ } else if (disambiguateAt) {
351
426
  setDisambiguateAt(null);
352
427
  } else {
353
428
  setSelectedPieceId(null);
@@ -365,8 +440,9 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
365
440
  const isValidMove = validMoves.some((m) => m.x === x && m.y === y);
366
441
  const isValidPass = validPasses.some((p) => p.x === x && p.y === y);
367
442
  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;
443
+ const displayedLastMove = replay ? replay.steps[replay.index] : lastMove;
444
+ const isLastMoveOrigin = displayedLastMove?.from && displayedLastMove.from.x === x && displayedLastMove.from.y === y;
445
+ const isLastMoveDest = displayedLastMove?.to && displayedLastMove.to.x === x && displayedLastMove.to.y === y;
370
446
  const isLastMove = isLastMoveOrigin || isLastMoveDest;
371
447
  const isEnemyGoalArea = userSide !== null && (x >= 2 && x <= 6) && (userSide === "white" ? y >= 10 && y <= 11 : y >= 0 && y <= 1);
372
448
  const pieceAt = boardState.pieces.find((p) => p.pos.x === x && p.pos.y === y);
@@ -377,6 +453,10 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
377
453
  "div",
378
454
  {
379
455
  onClick: () => handleSquareClick(x, y),
456
+ onMouseEnter: () => {
457
+ if (isValidPass) setHoveredPassAt({ x, y });
458
+ },
459
+ onMouseLeave: () => setHoveredPassAt(null),
380
460
  className: cn(
381
461
  "relative aspect-square w-full flex items-center justify-center cursor-pointer",
382
462
  isEven ? "bg-field-green-1" : "bg-field-green-2",
@@ -484,7 +564,21 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
484
564
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "1.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
485
565
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "4.5", cy: "10.5", r: "0.1", fill: "white", fillOpacity: "0.5" }),
486
566
  /* @__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" })
567
+ /* @__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" }),
568
+ hoveredPassAt && passCarrier && /* @__PURE__ */ jsxRuntime.jsx(
569
+ "line",
570
+ {
571
+ "data-testid": "pass-trajectory-line",
572
+ x1: passCarrier.pos.x + 0.5,
573
+ y1: ROWS - 1 - passCarrier.pos.y + 0.5,
574
+ x2: hoveredPassAt.x + 0.5,
575
+ y2: ROWS - 1 - hoveredPassAt.y + 0.5,
576
+ stroke: trajectoryIntercepted ? "var(--danger)" : "var(--pass-highlight)",
577
+ strokeWidth: "0.08",
578
+ strokeDasharray: "0.15 0.1",
579
+ strokeLinecap: "round"
580
+ }
581
+ )
488
582
  ]
489
583
  }
490
584
  ),
@@ -557,12 +651,92 @@ function GameBoard({ userSide, showCoordinates = false, keyboardNav = true }) {
557
651
  },
558
652
  "ball"
559
653
  );
560
- })()
654
+ })(),
655
+ invalidClickAt && /* @__PURE__ */ jsxRuntime.jsx(
656
+ framerMotion.motion.div,
657
+ {
658
+ "data-testid": "invalid-action-shake",
659
+ "aria-hidden": "true",
660
+ initial: false,
661
+ animate: prefersReducedMotion ? { opacity: [0.9, 0] } : { x: [0, -3, 3, -3, 3, 0] },
662
+ transition: { duration: 0.08 },
663
+ style: {
664
+ position: "absolute",
665
+ left: `${invalidClickAt.x / COLS * 100}%`,
666
+ top: `${(ROWS - 1 - invalidClickAt.y) / ROWS * 100}%`,
667
+ width: `${100 / COLS}%`,
668
+ height: `${100 / ROWS}%`
669
+ },
670
+ className: "ring-2 ring-inset ring-danger/70 rounded-sm"
671
+ },
672
+ invalidClickAt.nonce
673
+ )
561
674
  ] }),
562
675
  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
676
  "\u26A0 ",
564
677
  t("offsideWarning")
565
- ] })
678
+ ] }),
679
+ keyboardNav && /* @__PURE__ */ jsxRuntime.jsxs(
680
+ "div",
681
+ {
682
+ ref: shortcutsRef,
683
+ className: "hidden md:block absolute top-2 right-2 z-40",
684
+ onKeyDown: (e) => e.stopPropagation(),
685
+ children: [
686
+ /* @__PURE__ */ jsxRuntime.jsx(
687
+ "button",
688
+ {
689
+ type: "button",
690
+ onClick: (e) => {
691
+ e.stopPropagation();
692
+ setShortcutsOpen((o) => !o);
693
+ },
694
+ "aria-label": t("shortcuts.buttonLabel"),
695
+ "aria-expanded": shortcutsOpen,
696
+ className: "w-11 h-11 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 backdrop-blur-sm",
697
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.HelpCircle, { size: 16, strokeWidth: 2, "aria-hidden": "true" })
698
+ }
699
+ ),
700
+ shortcutsOpen && /* @__PURE__ */ jsxRuntime.jsxs(
701
+ "div",
702
+ {
703
+ 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",
704
+ onClick: (e) => e.stopPropagation(),
705
+ children: [
706
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "font-inter text-[11px] font-semibold text-fg-primary mb-1.5", children: t("shortcuts.title") }),
707
+ /* @__PURE__ */ jsxRuntime.jsxs("ul", { className: "font-mono text-[10px] text-fg-secondary leading-relaxed", children: [
708
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
709
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "\u2191\u2193\u2190\u2192" }),
710
+ " ",
711
+ t("shortcuts.arrows")
712
+ ] }),
713
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
714
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "Enter" }),
715
+ " ",
716
+ t("shortcuts.select")
717
+ ] }),
718
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
719
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "M" }),
720
+ " ",
721
+ t("shortcuts.move")
722
+ ] }),
723
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
724
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "P" }),
725
+ " ",
726
+ t("shortcuts.pass")
727
+ ] }),
728
+ /* @__PURE__ */ jsxRuntime.jsxs("li", { children: [
729
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "text-fg-primary", children: "Esc" }),
730
+ " ",
731
+ t("shortcuts.cancel")
732
+ ] })
733
+ ] })
734
+ ]
735
+ }
736
+ )
737
+ ]
738
+ }
739
+ )
566
740
  ] })
567
741
  }
568
742
  );
@@ -608,31 +782,50 @@ function AvatarFallback({ className, children }) {
608
782
  }
609
783
  function ActionPoints({ total, remaining, size = 20, className, kingMustRelease }) {
610
784
  const t = useGameT();
785
+ const prevRemainingRef = React.useRef(remaining);
786
+ const [spentRange, setSpentRange] = React.useState(null);
787
+ React.useEffect(() => {
788
+ const prev = prevRemainingRef.current;
789
+ prevRemainingRef.current = remaining;
790
+ if (remaining < prev) {
791
+ setSpentRange([remaining, prev - 1]);
792
+ const id = setTimeout(() => setSpentRange(null), 300);
793
+ return () => clearTimeout(id);
794
+ }
795
+ setSpentRange(null);
796
+ }, [remaining]);
611
797
  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
798
  const isActive = i < remaining;
613
799
  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
- }
800
+ const justSpent = !!spentRange && i >= spentRange[0] && i <= spentRange[1];
626
801
  return /* @__PURE__ */ jsxRuntime.jsx(
627
- lucideReact.Zap,
802
+ framerMotion.motion.span,
628
803
  {
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"
804
+ "data-testid": `ap-pip-${i}`,
805
+ "data-spent": justSpent || void 0,
806
+ className: "inline-flex",
807
+ animate: justSpent ? { scale: [1.5, 1] } : void 0,
808
+ transition: { duration: 0.3 },
809
+ children: showCrown ? /* @__PURE__ */ jsxRuntime.jsx(
810
+ lucideReact.Crown,
811
+ {
812
+ size,
813
+ strokeWidth: 2,
814
+ className: "text-yellow-400 fill-yellow-400 transition-colors duration-150",
815
+ "aria-hidden": "true"
816
+ }
817
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
818
+ lucideReact.Zap,
819
+ {
820
+ size,
821
+ strokeWidth: 2,
822
+ className: cn(
823
+ "transition-colors duration-150",
824
+ isActive ? "text-accent-green fill-accent-green" : "text-fg-muted fill-none"
825
+ ),
826
+ "aria-hidden": "true"
827
+ }
828
+ )
636
829
  },
637
830
  i
638
831
  );