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 {
card: VisualCard;
viewMode?: 'normal' | 'cutout';
viewMode?: 'normal' | 'cutout' | 'large';
isFoil?: boolean; // Explicit foil styling override
className?: string;
style?: React.CSSProperties;

View File

@@ -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<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 cardRef = useRef<HTMLDivElement>(null);
@@ -58,8 +59,10 @@ export const CardComponent: React.FC<CardComponentProps> = ({ 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={{

View File

@@ -75,6 +75,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null);
const [dragAnimationMode, setDragAnimationMode] = useState<'start' | 'end'>('end');
// Auto-Pass Priority if Yielding
useEffect(() => {
@@ -411,11 +412,26 @@ export const GameView: React.FC<GameViewProps> = ({ 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<GameViewProps> = ({ gameState, currentPlayerId }
</div>
<DragOverlay dropAnimation={{ duration: 0, easing: 'linear' }}>
{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];
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 (
<CardComponent
card={c}
viewMode="cutout"
viewMode={effectiveViewMode}
ignoreZoneLayout={true}
onDragStart={() => { }}
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 ---
private parseManaCost(manaCost: string): { generic: number, colors: Record<string, number> } {
const cost = { generic: 0, colors: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 } as 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>, 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;