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 {
|
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;
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user