feat(ui): Actions menu + royalty-transfer target selection in GameView
This commit is contained in:
parent
2951a2d547
commit
d6bc1ca2cc
4 changed files with 355 additions and 5 deletions
|
|
@ -324,3 +324,78 @@ test.describe('Rule variants — lobby vertical slice', () => {
|
||||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('F4c post-epic: transferable-royalty UI', () => {
|
||||||
|
test('transfer royalty action popover and selection flow', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Open rules drawer
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game');
|
||||||
|
|
||||||
|
// Open the drawer
|
||||||
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||||
|
await expect(page.locator('[data-testid="rules-drawer"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Toggle Transferable Royalty ON
|
||||||
|
const trToggle = page.locator('[data-preset="transferable-royalty"] [data-role="toggle"]');
|
||||||
|
await expect(trToggle).toBeVisible();
|
||||||
|
await trToggle.click();
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
|
||||||
|
// Close drawer
|
||||||
|
await page.locator('[data-action="close-rules-drawer"]').click();
|
||||||
|
await expect(page.locator('[data-testid="rules-drawer"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Ensure game has started and is on white's turn
|
||||||
|
const turnIndicator = page.getByTestId('turn-indicator');
|
||||||
|
await expect(turnIndicator).toHaveText("White's turn");
|
||||||
|
|
||||||
|
// 3. Assert "Actions" button is visible
|
||||||
|
const actionsMenuBtn = page.getByTestId('actions-menu-button');
|
||||||
|
await expect(actionsMenuBtn).toBeVisible();
|
||||||
|
|
||||||
|
// 4. Click "Actions" -> popover opens
|
||||||
|
await actionsMenuBtn.click();
|
||||||
|
|
||||||
|
// 5. Click "Transfer royalty..." item
|
||||||
|
const transferBtn = page.getByTestId('action-transfer-royalty');
|
||||||
|
await expect(transferBtn).toBeVisible();
|
||||||
|
await transferBtn.click();
|
||||||
|
|
||||||
|
// 6. Hint card appears: "Select the piece whose royalty you want to transfer."
|
||||||
|
await expect(page.getByText('Select the piece whose royalty you want to transfer.')).toBeVisible();
|
||||||
|
|
||||||
|
// 7. Click the white king (e1).
|
||||||
|
await page.locator('[data-square="e1"]').click();
|
||||||
|
|
||||||
|
// 8. Hint updates: "Select the new royal piece."
|
||||||
|
await expect(page.getByText('Select the new royal piece.')).toBeVisible();
|
||||||
|
|
||||||
|
// 9. Click a friendly piece (e.g., the queen at d1)
|
||||||
|
await page.locator('[data-square="d1"]').click();
|
||||||
|
|
||||||
|
// 10. Board state updates: Turn flipped (meaning the action was accepted and consumed the turn)
|
||||||
|
await expect(turnIndicator).toHaveText("Black's turn");
|
||||||
|
|
||||||
|
// 11. "Transfer royalty..." is now greyed out (already used this game by White)
|
||||||
|
// Black's turn: click actions -> should be enabled for Black since Black hasn't used it.
|
||||||
|
await actionsMenuBtn.click();
|
||||||
|
const transferBtn2 = page.getByTestId('action-transfer-royalty');
|
||||||
|
await expect(transferBtn2).toBeVisible();
|
||||||
|
await expect(transferBtn2).toBeEnabled();
|
||||||
|
|
||||||
|
// Play a move for Black to get back to White
|
||||||
|
await drag(page, 'e7', 'e5');
|
||||||
|
|
||||||
|
await expect(turnIndicator).toHaveText("White's turn");
|
||||||
|
|
||||||
|
// Now White's action should be disabled
|
||||||
|
await actionsMenuBtn.click();
|
||||||
|
const transferBtn3 = page.getByTestId('action-transfer-royalty');
|
||||||
|
await expect(transferBtn3).toBeVisible();
|
||||||
|
await expect(transferBtn3).toBeDisabled();
|
||||||
|
await expect(page.getByText('Used this game')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
99
packages/chess/src/ui/ActionMenu.tsx
Normal file
99
packages/chess/src/ui/ActionMenu.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
export interface ActionMenuProps {
|
||||||
|
/** True if the user can act right now (it's their turn) */
|
||||||
|
isActive: boolean;
|
||||||
|
/** True if the transferable-royalty preset is active */
|
||||||
|
canTransferRoyalty: boolean;
|
||||||
|
/** True if the user has NOT transferred royalty yet */
|
||||||
|
hasTransferRemaining: boolean;
|
||||||
|
/** Call when the "Transfer royalty..." item is clicked */
|
||||||
|
onTransferRoyaltyClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionMenu({
|
||||||
|
isActive,
|
||||||
|
canTransferRoyalty,
|
||||||
|
hasTransferRemaining,
|
||||||
|
onTransferRoyaltyClick,
|
||||||
|
}: ActionMenuProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleDocumentClick = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Use capture to avoid conflicts with buttons that trigger state changes
|
||||||
|
document.addEventListener('click', handleDocumentClick, true);
|
||||||
|
return () => document.removeEventListener('click', handleDocumentClick, true);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Don't render anything if no action-providing presets are active
|
||||||
|
if (!canTransferRoyalty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
data-testid="actions-menu-button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
disabled={!isActive}
|
||||||
|
className={`px-4 py-2 font-medium rounded-md border shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? isOpen
|
||||||
|
? 'bg-neutral-100 border-neutral-300 text-neutral-800'
|
||||||
|
: 'bg-white border-neutral-300 text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
|
||||||
|
: 'bg-white border-neutral-200 text-neutral-400 opacity-60 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
className="absolute top-full right-0 mt-2 w-56 bg-neutral-100 border border-neutral-200 rounded-lg shadow-lg z-50 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{canTransferRoyalty && (
|
||||||
|
<button
|
||||||
|
data-testid="action-transfer-royalty"
|
||||||
|
disabled={!hasTransferRemaining}
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onTransferRoyaltyClick();
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm transition-colors
|
||||||
|
${
|
||||||
|
hasTransferRemaining
|
||||||
|
? 'text-neutral-700 hover:bg-neutral-200 active:bg-neutral-300'
|
||||||
|
: 'text-neutral-400 cursor-not-allowed opacity-60'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Transfer royalty…</div>
|
||||||
|
{!hasTransferRemaining && (
|
||||||
|
<div className="text-xs mt-0.5">Used this game</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,10 @@ interface BoardProps {
|
||||||
* to pin the modifier inspection panel for that piece.
|
* to pin the modifier inspection panel for that piece.
|
||||||
*/
|
*/
|
||||||
onPieceClick?: (pieceId: number) => void;
|
onPieceClick?: (pieceId: number) => void;
|
||||||
|
/**
|
||||||
|
* Set of entity IDs to highlight (e.g. for action target selection).
|
||||||
|
*/
|
||||||
|
highlightedPieces?: Set<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PieceState {
|
interface PieceState {
|
||||||
|
|
@ -53,7 +57,7 @@ interface PieceState {
|
||||||
color: PieceColor;
|
color: PieceColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover, onPieceClick }: BoardProps) {
|
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover, onPieceClick, highlightedPieces }: BoardProps) {
|
||||||
// Pre-compute overlay components once per render — lookup is cheap
|
// Pre-compute overlay components once per render — lookup is cheap
|
||||||
// but doing it once in a useMemo keeps the Piece render path clean.
|
// but doing it once in a useMemo keeps the Piece render path clean.
|
||||||
const overlays: PieceOverlayComponent[] = useMemo(
|
const overlays: PieceOverlayComponent[] = useMemo(
|
||||||
|
|
@ -249,6 +253,10 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
||||||
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{highlightedPieces?.has(piece?.id as number) && (
|
||||||
|
<div className="absolute inset-0 ring-4 ring-inset ring-amber-400 bg-amber-400/20 pointer-events-none z-10 shadow-[0_0_15px_rgba(251,191,36,0.6)]" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Per-square preset overlays (poison tint, etc.). Each is
|
{/* Per-square preset overlays (poison tint, etc.). Each is
|
||||||
a pure function of the square index; overlays decide for
|
a pure function of the square index; overlays decide for
|
||||||
themselves whether to render on any given cell. */}
|
themselves whether to render on any given cell. */}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,18 @@ import { RulesDrawer } from './RulesDrawer';
|
||||||
import { ModifierTooltip } from './ModifierTooltip.js';
|
import { ModifierTooltip } from './ModifierTooltip.js';
|
||||||
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
||||||
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
||||||
|
import { ActionMenu } from './ActionMenu.js';
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||||
import type { Color, PresetActivation, ModifierProfileWire } from '../net/types';
|
import type { Color, PresetActivation, ModifierProfileWire, PlayerActionWire } from '../net/types';
|
||||||
import type { GameResult } from '../engine';
|
import type { GameResult } from '../engine';
|
||||||
import type { LegalMove } from '../rules/types';
|
import type { LegalMove } from '../rules/types';
|
||||||
import type { ChessEngine } from '../engine';
|
import type { ChessEngine } from '../engine';
|
||||||
|
import type { EntityId } from '@paratype/rete';
|
||||||
|
import { TRANSFERABLE_ROYALTY_ID } from '../presets/transferable-royalty.js';
|
||||||
import { isInCheck } from '../rules/check';
|
import { isInCheck } from '../rules/check';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
||||||
|
|
@ -49,6 +52,7 @@ interface GameEngineState {
|
||||||
sendRegisterCustomModifier?: (
|
sendRegisterCustomModifier?: (
|
||||||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||||
) => void;
|
) => void;
|
||||||
|
sendAction?: (action: PlayerActionWire) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameViewProps {
|
interface GameViewProps {
|
||||||
|
|
@ -156,6 +160,7 @@ function GameLayout({
|
||||||
isProposer,
|
isProposer,
|
||||||
sendConsent,
|
sendConsent,
|
||||||
sendRegisterCustomModifier,
|
sendRegisterCustomModifier,
|
||||||
|
sendAction,
|
||||||
} = state as GameEngineState & {
|
} = state as GameEngineState & {
|
||||||
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||||
modifierRejectionMessage?: string | null;
|
modifierRejectionMessage?: string | null;
|
||||||
|
|
@ -164,8 +169,29 @@ function GameLayout({
|
||||||
sendRegisterCustomModifier?: (
|
sendRegisterCustomModifier?: (
|
||||||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||||
) => void;
|
) => void;
|
||||||
|
sendAction?: (action: PlayerActionWire) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Action Mode State
|
||||||
|
type ActionMode =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "transfer-royalty-pick-from" }
|
||||||
|
| { kind: "transfer-royalty-pick-to"; fromPieceId: EntityId };
|
||||||
|
|
||||||
|
const [actionMode, setActionMode] = useState<ActionMode>({ kind: "none" });
|
||||||
|
|
||||||
|
// Escape to cancel action mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionMode.kind === "none") return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setActionMode({ kind: "none" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [actionMode.kind]);
|
||||||
|
|
||||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||||
applyMove(from, to, promoteTo || 'queen');
|
applyMove(from, to, promoteTo || 'queen');
|
||||||
};
|
};
|
||||||
|
|
@ -297,6 +323,117 @@ function GameLayout({
|
||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// --- Actions Support ---
|
||||||
|
const canTransferRoyalty = activations.some(a => a.id === TRANSFERABLE_ROYALTY_ID);
|
||||||
|
|
||||||
|
// Calculate candidates for transfer-royalty if in that mode
|
||||||
|
const actionHighlightCandidates = useMemo(() => {
|
||||||
|
if (engine === null || actionMode.kind === "none") return undefined;
|
||||||
|
|
||||||
|
const highlighted = new Set<number>();
|
||||||
|
|
||||||
|
if (actionMode.kind === "transfer-royalty-pick-from") {
|
||||||
|
// Pick FROM: must be royal piece for current mover
|
||||||
|
const royals = engine.getActiveRoyalEntityIds(turnAsColor);
|
||||||
|
if (royals) {
|
||||||
|
for (const id of royals) {
|
||||||
|
// Check if piece has Position (alive)
|
||||||
|
if (engine.session.contains(id, "Position")) {
|
||||||
|
highlighted.add(id as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback default kings
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.attr === "PieceType" && f.value === "king") {
|
||||||
|
const colorFact = facts.find(cf => cf.id === f.id && cf.attr === "Color");
|
||||||
|
const posFact = facts.find(pf => pf.id === f.id && pf.attr === "Position");
|
||||||
|
if (colorFact?.value === turnAsColor && posFact) {
|
||||||
|
highlighted.add(f.id as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (actionMode.kind === "transfer-royalty-pick-to") {
|
||||||
|
// Pick TO: must be friendly non-royal live piece
|
||||||
|
const royals = new Set(engine.getActiveRoyalEntityIds(turnAsColor) ?? []);
|
||||||
|
if (royals.size === 0) {
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.attr === "PieceType" && f.value === "king") {
|
||||||
|
const colorFact = facts.find(cf => cf.id === f.id && cf.attr === "Color");
|
||||||
|
if (colorFact?.value === turnAsColor) {
|
||||||
|
royals.add(f.id as EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myPieceIds = new Set<number>();
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.attr === "Color" && f.value === turnAsColor) {
|
||||||
|
const posFact = facts.find(pf => pf.id === f.id && pf.attr === "Position");
|
||||||
|
if (posFact && !royals.has(f.id as EntityId)) {
|
||||||
|
myPieceIds.add(f.id as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of myPieceIds) highlighted.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted;
|
||||||
|
}, [engine, actionMode, turnAsColor, facts]);
|
||||||
|
|
||||||
|
const hasTransferRemaining = useMemo(() => {
|
||||||
|
if (!canTransferRoyalty || !engine) return false;
|
||||||
|
const st = engine.presetState(TRANSFERABLE_ROYALTY_ID);
|
||||||
|
return !st.has(`transferredFrom:${turnAsColor}`);
|
||||||
|
}, [canTransferRoyalty, engine, turnAsColor, state.turn]); // Use state.turn to refresh or we could expose tick from hook. We'll use result/turn as proxies for tick for now.
|
||||||
|
|
||||||
|
const handlePieceClick = (pieceId: number) => {
|
||||||
|
if (actionMode.kind === "none") {
|
||||||
|
// Normal click -> pin modifier
|
||||||
|
setPinnedPieceId((prev) => (prev === pieceId ? null : pieceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engine === null) return;
|
||||||
|
|
||||||
|
if (actionMode.kind === "transfer-royalty-pick-from") {
|
||||||
|
if (actionHighlightCandidates?.has(pieceId as number)) {
|
||||||
|
setActionMode({ kind: "transfer-royalty-pick-to", fromPieceId: pieceId as EntityId });
|
||||||
|
} else {
|
||||||
|
toast.error("Please select a valid royal piece.");
|
||||||
|
}
|
||||||
|
} else if (actionMode.kind === "transfer-royalty-pick-to") {
|
||||||
|
if (actionHighlightCandidates?.has(pieceId as number)) {
|
||||||
|
const action: PlayerActionWire = {
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: actionMode.fromPieceId as number,
|
||||||
|
toPieceId: pieceId as number
|
||||||
|
};
|
||||||
|
if (roomCode === null) {
|
||||||
|
// Solo mode
|
||||||
|
const result = engine.performAction({
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: actionMode.fromPieceId,
|
||||||
|
toPieceId: pieceId as EntityId
|
||||||
|
});
|
||||||
|
if (result && result.ok) {
|
||||||
|
refresh();
|
||||||
|
} else if (result) {
|
||||||
|
toast.error(result.reason || "Action failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiplayer mode
|
||||||
|
sendAction?.(action);
|
||||||
|
}
|
||||||
|
setActionMode({ kind: "none" });
|
||||||
|
} else {
|
||||||
|
toast.error("Please select a friendly non-royal piece.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|
@ -352,6 +489,13 @@ function GameLayout({
|
||||||
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
||||||
<LayoutBadge />
|
<LayoutBadge />
|
||||||
<ModifierProfileBadge />
|
<ModifierProfileBadge />
|
||||||
|
|
||||||
|
<ActionMenu
|
||||||
|
isActive={myColor !== null ? turn === myColor : true}
|
||||||
|
canTransferRoyalty={canTransferRoyalty}
|
||||||
|
hasTransferRemaining={hasTransferRemaining}
|
||||||
|
onTransferRoyaltyClick={() => setActionMode({ kind: "transfer-royalty-pick-from" })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -471,9 +615,32 @@ function GameLayout({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full px-4 relative">
|
<div className="w-full px-4 relative">
|
||||||
|
<AnimatePresence>
|
||||||
|
{actionMode.kind !== "none" && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="absolute -top-12 left-1/2 -translate-x-1/2 z-40 bg-white border border-neutral-200 shadow-lg px-4 py-2 rounded-full text-sm font-medium text-neutral-800 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
{actionMode.kind === "transfer-royalty-pick-from"
|
||||||
|
? "Select the piece whose royalty you want to transfer."
|
||||||
|
: "Select the new royal piece."}
|
||||||
|
<button
|
||||||
|
onClick={() => setActionMode({ kind: "none" })}
|
||||||
|
className="ml-2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
|
title="Cancel (Esc)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<Board
|
<Board
|
||||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||||
legalMoves={legalMoves}
|
legalMoves={actionMode.kind === "none" ? legalMoves : []}
|
||||||
turn={turn as Color}
|
turn={turn as Color}
|
||||||
myColor={myColor}
|
myColor={myColor}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
|
|
@ -481,7 +648,8 @@ function GameLayout({
|
||||||
checkedKingSquare={checkedKingSquare}
|
checkedKingSquare={checkedKingSquare}
|
||||||
activePresetIds={activations.map((a) => a.id)}
|
activePresetIds={activations.map((a) => a.id)}
|
||||||
onPieceHover={handlePieceHover}
|
onPieceHover={handlePieceHover}
|
||||||
onPieceClick={(id) => setPinnedPieceId((prev) => (prev === id ? null : id))}
|
onPieceClick={handlePieceClick}
|
||||||
|
{...(actionHighlightCandidates ? { highlightedPieces: actionHighlightCandidates } : {})}
|
||||||
{...(state.engine !== null ? { engine: state.engine } : {})}
|
{...(state.engine !== null ? { engine: state.engine } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue