From 7aa34adf950ee10f7d4f9fd1345868be4a524356 Mon Sep 17 00:00:00 2001 From: dnviti Date: Mon, 22 Dec 2025 23:51:52 +0100 Subject: [PATCH] feat: implement mana cost parsing and auto-tap calculation with visual preview in game view --- src/client/src/modules/game/GameView.tsx | 26 +++- src/client/src/utils/manaUtils.ts | 147 +++++++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/client/src/utils/manaUtils.ts diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index e24460f..699d8c6 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -17,6 +17,7 @@ import { MulliganView } from './MulliganView'; import { RadialMenu, RadialOption } from './RadialMenu'; import { InspectorOverlay } from './InspectorOverlay'; import { SidePanelPreview } from '../../components/SidePanelPreview'; +import { calculateAutoTap } from '../../utils/manaUtils'; // --- DnD Helpers --- const DraggableCardWrapper = ({ children, card, disabled }: { children: React.ReactNode, card: CardInstance, disabled?: boolean }) => { @@ -76,6 +77,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const [viewingZone, setViewingZone] = useState(null); const [hoveredCard, setHoveredCard] = useState(null); const [dragAnimationMode, setDragAnimationMode] = useState<'start' | 'end'>('end'); + const [previewTappedIds, setPreviewTappedIds] = useState>(new Set()); // Auto-Pass Priority if Yielding useEffect(() => { @@ -418,6 +420,21 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const card = gameState.cards[cardId]; if (card && card.zone === 'hand') { setDragAnimationMode('start'); + + // PREVIEW AUTO TAP + // If no cost (Land), do nothing. + if (card.manaCost && myPlayer) { + const myLands = Object.values(gameState.cards).filter(c => + c.controllerId === currentPlayerId && + c.zone === 'battlefield' && + (c.types?.includes('Land') || c.typeLine?.includes('Land')) + ); + const toTap = calculateAutoTap(card.manaCost, myPlayer, myLands); + if (toTap.size > 0) { + setPreviewTappedIds(toTap); + } + } + // Trigger animation to shrink setTimeout(() => { setDragAnimationMode('end'); @@ -431,6 +448,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const handleDragEnd = (event: DragEndEvent) => { setActiveDragId(null); + setPreviewTappedIds(new Set()); // Clear preview document.body.style.cursor = ''; const { active, over } = event; @@ -700,6 +718,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const renderCard = (card: CardInstance) => { const isAttacking = proposedAttackers.has(card.instanceId); const blockingTargetId = proposedBlockers.get(card.instanceId); + const isPreviewTapped = previewTappedIds.has(card.instanceId); return (
= ({ gameState, currentPlayerId } ? 'translateY(-40px) scale(1.1) rotateX(10deg)' : blockingTargetId ? 'translateY(-20px) scale(1.05)' - : 'none', - boxShadow: isAttacking ? '0 20px 40px -10px rgba(239, 68, 68, 0.5)' : 'none' + : isPreviewTapped + ? 'rotate(10deg)' // Preview Tap Rotation + : 'none', + boxShadow: isAttacking ? '0 20px 40px -10px rgba(239, 68, 68, 0.5)' : 'none', + opacity: isPreviewTapped ? 0.7 : 1 // Preview Tap Opacity }} > diff --git a/src/client/src/utils/manaUtils.ts b/src/client/src/utils/manaUtils.ts new file mode 100644 index 0000000..26a1ea8 --- /dev/null +++ b/src/client/src/utils/manaUtils.ts @@ -0,0 +1,147 @@ + +import { CardInstance, PlayerState } from '../types/game'; + +// Helper to determine land color identity from type line or name +export const getLandColor = (card: CardInstance): string | null => { + const typeLine = card.typeLine || ''; + const types = card.types || []; + + if (!typeLine.includes('Land') && !types.includes('Land')) return null; + + if (typeLine.includes('Plains')) return 'W'; + if (typeLine.includes('Island')) return 'U'; + if (typeLine.includes('Swamp')) return 'B'; + if (typeLine.includes('Mountain')) return 'R'; + if (typeLine.includes('Forest')) return 'G'; + + // TODO: Wastes + return null; +}; + +export const parseManaCost = (manaCost: string): { generic: number, colors: Record, hybrids: string[][] } => { + const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record, hybrids: [] as string[][] }; + + if (!manaCost) return cost; + + const matches = manaCost.match(/{[^{}]+}/g); + if (!matches) return cost; + + matches.forEach(symbol => { + const content = symbol.replace(/[{}]/g, ''); + + if (!isNaN(Number(content))) { + cost.generic += Number(content); + } + else if (content.includes('/')) { + const parts = content.split('/'); + const options = parts.filter(p => ['W', 'U', 'B', 'R', 'G', 'C'].includes(p)); + if (options.length >= 1) { + cost.hybrids.push(options); + } + } + else { + if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) { + cost.colors[content]++; + } + } + }); + + return cost; +}; + +// Returns a set of card IDs to tap +export const calculateAutoTap = ( + costStr: string, + player: PlayerState, + myLands: CardInstance[] +): Set => { + const landsToTap = new Set(); + const cost = parseManaCost(costStr); + + // Clone pool so we don't mutate state locally + const pool = { ...player.manaPool }; + if (!pool.W) pool.W = 0; if (!pool.U) pool.U = 0; if (!pool.B) pool.B = 0; + if (!pool.R) pool.R = 0; if (!pool.G) pool.G = 0; if (!pool.C) pool.C = 0; + + // Filter usable lands (untapped) + // We only consider lands that haven't been marked for tap yet (initially none) + const availableLands = myLands.filter(l => !l.tapped); + + // 1. Pay Colored Costs + for (const color of ['W', 'U', 'B', 'R', 'G', 'C']) { + let required = cost.colors[color]; + if (required <= 0) continue; + + // Pool First + if (pool[color] >= required) { + pool[color] -= required; + required = 0; + } else { + required -= pool[color]; + pool[color] = 0; + } + + // Lands + if (required > 0) { + const producers = availableLands.filter(l => !landsToTap.has(l.instanceId) && getLandColor(l) === color); + if (producers.length >= required) { + for (let i = 0; i < required; i++) { + landsToTap.add(producers[i].instanceId); + } + required = 0; + } else { + // Cannot pay strictly + return new Set(); // Fail + } + } + } + + // 2. Pay Hybrid (Greedy) + for (const options of cost.hybrids) { + let paid = false; + for (const color of options) { + if (pool[color] > 0) { + pool[color]--; + paid = true; + break; + } + const land = availableLands.find(l => !landsToTap.has(l.instanceId) && getLandColor(l) === color); + if (land) { + landsToTap.add(land.instanceId); + paid = true; + break; + } + } + // If greedy fail, we might fail overall. + // Real auto-tapper might backtrack, but for preview/MVP we match server greedy logic. + if (!paid) return new Set(); + } + + // 3. Pay Generic + let genericRequired = cost.generic; + if (genericRequired > 0) { + // Pool + for (const color of Object.keys(pool)) { + if (genericRequired <= 0) break; + const available = pool[color]; + if (available > 0) { + const take = Math.min(available, genericRequired); + pool[color] -= take; + genericRequired -= take; + } + } + // Lands + if (genericRequired > 0) { + const unusedLands = availableLands.filter(l => !landsToTap.has(l.instanceId) && getLandColor(l) !== null); + if (unusedLands.length >= genericRequired) { + for (let i = 0; i < genericRequired; i++) { + landsToTap.add(unusedLands[i].instanceId); + } + } else { + return new Set(); // Fail + } + } + } + + return landsToTap; +};