diff --git a/src/client/src/components/CardVisual.tsx b/src/client/src/components/CardVisual.tsx index 805ffa0..735f942 100644 --- a/src/client/src/components/CardVisual.tsx +++ b/src/client/src/components/CardVisual.tsx @@ -38,7 +38,7 @@ export type VisualCard = { interface CardVisualProps { card: VisualCard; - viewMode?: 'normal' | 'cutout'; + viewMode?: 'normal' | 'cutout' | 'large'; isFoil?: boolean; // Explicit foil styling override className?: string; style?: React.CSSProperties; diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index 9d2c110..15248a4 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -16,10 +16,11 @@ interface CardComponentProps { onDragEnd?: (e: React.DragEvent) => void; style?: React.CSSProperties; className?: string; - viewMode?: 'normal' | 'cutout'; + viewMode?: 'normal' | 'cutout' | 'large'; + ignoreZoneLayout?: boolean; } -export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal' }) => { +export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal', ignoreZoneLayout = false }) => { const { registerCard, unregisterCard } = useGesture(); const cardRef = useRef(null); @@ -58,8 +59,10 @@ export const CardComponent: React.FC = ({ card, onDragStart, onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={` - relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none - ${card.zone === 'hand' ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' : (viewMode === 'cutout' ? 'w-24 h-24' : 'w-24 h-32')} + relative rounded-lg shadow-md cursor-grab active:cursor-grabbing transition-all duration-300 ease-[cubic-bezier(0.25,0.8,0.25,1)] select-none + ${(!ignoreZoneLayout && card.zone === 'hand') + ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' + : (viewMode === 'cutout' ? 'w-24 h-24' : (viewMode === 'large' ? 'w-32 h-44' : 'w-24 h-32'))} ${className || ''} `} style={{ diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index e322605..e24460f 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -75,6 +75,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const [contextMenu, setContextMenu] = useState(null); const [viewingZone, setViewingZone] = useState(null); const [hoveredCard, setHoveredCard] = useState(null); + const [dragAnimationMode, setDragAnimationMode] = useState<'start' | 'end'>('end'); // Auto-Pass Priority if Yielding useEffect(() => { @@ -411,11 +412,26 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } ); const handleDragStart = (event: DragStartEvent) => { - setActiveDragId(event.active.id as string); + const cardId = event.active.id as string; + setActiveDragId(cardId); + + const card = gameState.cards[cardId]; + if (card && card.zone === 'hand') { + setDragAnimationMode('start'); + // Trigger animation to shrink + setTimeout(() => { + setDragAnimationMode('end'); + }, 50); + } else { + setDragAnimationMode('end'); + } + + document.body.style.cursor = 'grabbing'; }; const handleDragEnd = (event: DragEndEvent) => { setActiveDragId(null); + document.body.style.cursor = ''; const { active, over } = event; if (!over) return; @@ -1018,17 +1034,25 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } {activeDragId ? ( -
+
{(() => { const c = gameState.cards[activeDragId]; if (!c) return null; + + // If coming from hand, we animate from Large to Cutout + // If start, use 'large' (matches hand size approximately, but we ignore margins) + // If end, use 'cutout' + const isHandOrigin = c.zone === 'hand'; + const effectiveViewMode = (isHandOrigin && dragAnimationMode === 'start') ? 'large' : 'cutout'; + return ( { }} onClick={() => { }} - className="w-full h-full rounded-lg shadow-2xl ring-2 ring-white/50" + className="rounded-lg shadow-2xl ring-2 ring-white/50" /> ); })()} diff --git a/src/server/game/RulesEngine.ts b/src/server/game/RulesEngine.ts index 0e19538..c3eebc1 100644 --- a/src/server/game/RulesEngine.ts +++ b/src/server/game/RulesEngine.ts @@ -107,8 +107,8 @@ export class RulesEngine { // --- Mana System --- - private parseManaCost(manaCost: string): { generic: number, colors: Record } { - const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record }; + private 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; @@ -123,7 +123,27 @@ export class RulesEngine { if (!isNaN(Number(content))) { cost.generic += Number(content); } - // check for hybrid/phyrexian later if needed, for now exact colors + // Check for Hybrid (contains /) + else if (content.includes('/')) { + // e.g. W/U, 2/W + const parts = content.split('/'); + // For now, assume Color/Color hybrid. + // TODO: Handle 2/W or P (Phyrexian) if needed. + // We push the options as an array of possible colors/costs. + // Filter valid colors to be safe. + const options = parts.filter(p => ['W', 'U', 'B', 'R', 'G', 'C'].includes(p)); + + // Handle "2/W" -> If part is '2', it's generic? Auto-tap makes this hard. + // For MVP, focus on Color/Color which solves the User Request (W/U). + if (options.length >= 2) { + cost.hybrids.push(options); + } else if (options.length === 1 && !isNaN(Number(parts[0]))) { + // Case like 2/W ? + // cost.hybrids.push([...options, 'GENERIC_' + parts[0]]); + // Let's stick to simple Color/Color for now or single color fallback. + cost.hybrids.push(options); // treat as just the color requirement if regex fails strictly + } + } else { // Standard colors if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) { @@ -199,6 +219,35 @@ export class RulesEngine { } } + // 2.5 Pay Hybrid Costs (Greedy Strategy) + // For each hybrid requirement (e.g. [W, U]), try to pay with first option, then second. + // Note: This is greedy. Optimal payment is NP-hard in general magic application, + // but for simple auto-tapping we assume simple priority. + for (const options of cost.hybrids) { + let paid = false; + // Try each color option + for (const color of options) { + // Check Pool + if (pool[color] > 0) { + pool[color]--; + paid = true; + break; + } + // Check Lands + // Find a land that produces this color and is UNUSED + const land = lands.find(l => !landsToTap.includes(l.instanceId) && this.getLandColor(l) === color); + if (land) { + landsToTap.push(land.instanceId); + paid = true; + break; + } + } + + if (!paid) { + throw new Error(`Insufficient mana for hybrid cost {${options.join('/')}}.`); + } + } + // 3. Pay Generic Cost let genericRequired = cost.generic;