feat: Enhance card drag-and-drop experience with a 'large' view mode, dynamic animations, and layout control.

This commit is contained in:
2025-12-22 23:48:29 +01:00
parent 66836cfde5
commit f701302923
4 changed files with 88 additions and 12 deletions

View File

@@ -38,7 +38,7 @@ export type VisualCard = {
interface CardVisualProps { interface CardVisualProps {
card: VisualCard; card: VisualCard;
viewMode?: 'normal' | 'cutout'; viewMode?: 'normal' | 'cutout' | 'large';
isFoil?: boolean; // Explicit foil styling override isFoil?: boolean; // Explicit foil styling override
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;

View File

@@ -16,10 +16,11 @@ interface CardComponentProps {
onDragEnd?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
viewMode?: 'normal' | 'cutout'; viewMode?: 'normal' | 'cutout' | 'large';
ignoreZoneLayout?: boolean;
} }
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal' }) => { export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal', ignoreZoneLayout = false }) => {
const { registerCard, unregisterCard } = useGesture(); const { registerCard, unregisterCard } = useGesture();
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
@@ -58,8 +59,10 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
className={` className={`
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none 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
${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')} ${(!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 || ''} ${className || ''}
`} `}
style={{ style={{

View File

@@ -75,6 +75,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null); const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null); const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null);
const [dragAnimationMode, setDragAnimationMode] = useState<'start' | 'end'>('end');
// Auto-Pass Priority if Yielding // Auto-Pass Priority if Yielding
useEffect(() => { useEffect(() => {
@@ -411,11 +412,26 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
); );
const handleDragStart = (event: DragStartEvent) => { 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) => { const handleDragEnd = (event: DragEndEvent) => {
setActiveDragId(null); setActiveDragId(null);
document.body.style.cursor = '';
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
@@ -1018,17 +1034,25 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
</div> </div>
<DragOverlay dropAnimation={{ duration: 0, easing: 'linear' }}> <DragOverlay dropAnimation={{ duration: 0, easing: 'linear' }}>
{activeDragId ? ( {activeDragId ? (
<div className="w-24 h-24 pointer-events-none opacity-90 z-[1000] drop-shadow-2xl"> <div className="pointer-events-none z-[1000] drop-shadow-[0_20px_50px_rgba(0,0,0,0.5)] scale-110 -rotate-6 transition-transform">
{(() => { {(() => {
const c = gameState.cards[activeDragId]; const c = gameState.cards[activeDragId];
if (!c) return null; 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 ( return (
<CardComponent <CardComponent
card={c} card={c}
viewMode="cutout" viewMode={effectiveViewMode}
ignoreZoneLayout={true}
onDragStart={() => { }} onDragStart={() => { }}
onClick={() => { }} 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"
/> />
); );
})()} })()}

View File

@@ -107,8 +107,8 @@ export class RulesEngine {
// --- Mana System --- // --- Mana System ---
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number> } { private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number>, hybrids: string[][] } {
const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record<string, number> }; const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as Record<string, number>, hybrids: [] as string[][] };
if (!manaCost) return cost; if (!manaCost) return cost;
@@ -123,7 +123,27 @@ export class RulesEngine {
if (!isNaN(Number(content))) { if (!isNaN(Number(content))) {
cost.generic += 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 { else {
// Standard colors // Standard colors
if (['W', 'U', 'B', 'R', 'G', 'C'].includes(content)) { 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 // 3. Pay Generic Cost
let genericRequired = cost.generic; let genericRequired = cost.generic;