gameboard fixes

This commit is contained in:
seavor 2026-04-25 14:42:41 -05:00
parent 88489ea2eb
commit f534cd79b7
86 changed files with 4249 additions and 2145 deletions

View file

@ -3,6 +3,16 @@ import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
import {
CARD_HEIGHT_PX,
CARD_WIDTH_PX,
MARGIN_LEFT_PX,
PADDING_X_PX,
STACKED_CARD_OFFSET_X_PX,
closestGridPoint,
mapToGridX,
stackCountsForRow,
} from '../../components/Game/Battlefield/gridMath';
export interface GameDnd {
activeCard: Data.ServerInfo_Card | null;
@ -16,6 +26,19 @@ export interface UseGameDndArgs {
onDragStart: () => void;
}
// Derive the drop pointer's x-coordinate relative to the target row's content
// edge. We use the center of the dragged card's translated rect as the "drop
// point" — this matches desktop's behavior where the drop position is the
// card's scene position at release (card_drag_item.cpp:updatePosition), not a
// raw cursor coordinate.
function computePointerXInRow(event: DragEndEvent): number {
const overRect = event.over?.rect;
const activeRect = event.active.rect.current.translated;
if (!overRect || !activeRect) return 0;
const cardCenterX = activeRect.left + activeRect.width / 2;
return cardCenterX - overRect.left - MARGIN_LEFT_PX;
}
export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
const webClient = useWebClient();
const [activeCard, setActiveCard] = useState<Data.ServerInfo_Card | null>(null);
@ -51,6 +74,7 @@ export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
row?: number;
attachTarget?: boolean;
targetCardId?: number;
rowCards?: Data.ServerInfo_Card[];
};
// Drop onto another card on the table → attach source to target.
@ -83,21 +107,69 @@ export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
const sameZone =
source.sourcePlayerId === target.targetPlayerId &&
source.sourceZone === target.targetZone;
if (sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === (target.row ?? 0)) {
return;
}
// Non-TABLE same-zone drops aren't meaningful (dragging within the hand
// or library isn't a user-facing reorder on desktop either).
if (sameZone && source.sourceZone !== App.ZoneName.TABLE) {
return;
}
const targetRow = target.row ?? 0;
const targetIsTable = target.targetZone === App.ZoneName.TABLE;
const sameRowOnTable =
sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === targetRow;
// Compute an integer gridX matching desktop's stack-and-subposition
// packing (table_zone.cpp:mapToGrid + closestGridPoint). Non-TABLE
// targets get x = 0 — the server assigns a position in piles/hand.
let gridX = 0;
if (targetIsTable) {
const rowCards = target.rowCards ?? [];
// When reordering within the same row, exclude the dragged card from
// occupancy / stack width — otherwise closestGridPoint would skip its
// own slot and a same-position no-op drop would bump to base+1.
const neighbors = sameRowOnTable
? rowCards.filter((c) => c.id !== source.card.id)
: rowCards;
const pointerXInRow = computePointerXInRow(event);
const stackCounts = stackCountsForRow(neighbors);
// Cards render at laneHeight × (146/204) via CSS aspect-ratio. Derive
// the same effective width here so gridMath maps the pointer to the
// stack/sub-position the user visually dropped on — at any zoom level.
const laneHeight = event.over.rect.height;
const effectiveCardWidth = laneHeight > 0
? (laneHeight * CARD_WIDTH_PX) / CARD_HEIGHT_PX
: CARD_WIDTH_PX;
const effectiveOffsetX =
(effectiveCardWidth * STACKED_CARD_OFFSET_X_PX) / CARD_WIDTH_PX;
const rawGridX = mapToGridX(
pointerXInRow,
stackCounts,
effectiveCardWidth,
effectiveOffsetX,
PADDING_X_PX,
);
const occupied = new Set(neighbors.map((c) => c.x ?? 0));
const resolved = closestGridPoint(rawGridX, occupied);
// Fully-occupied 3-card stack → desktop silently rejects (see
// card_drag_item.cpp:115); skip dispatch to match.
if (resolved == null) return;
gridX = resolved;
}
// targetRow passed from BattlefieldRow is already the LOGICAL y (cards
// get y=rowIdx where rowIdx iterates rowOrder = [0,1,2] or [2,1,0]). On
// a mirrored board the first iterated rowIdx is 2, so dropping on the
// visual top row yields target.row=2 — which is the correct server-side
// y. Unlike desktop's mapToGrid (which takes pixel-y and inverts), we
// never see a pre-inverted value here, so no re-inversion needed.
webClient.request.game.moveCard(gameId, {
startPlayerId: source.sourcePlayerId,
startZone: source.sourceZone,
cardsToMove: { card: [{ cardId: source.card.id }] },
targetPlayerId: target.targetPlayerId,
targetZone: target.targetZone,
x: 0,
y: target.row ?? 0,
x: gridX,
y: targetIsTable ? targetRow : 0,
isReversed: false,
});
},