feat: Enhance card drag-and-drop experience with a 'large' view mode, dynamic animations, and layout control.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user