diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 280d4d2..048b180 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -9,8 +9,8 @@ import { CardComponent } from './CardComponent'; import { GameContextMenu, ContextMenuRequest } from './GameContextMenu'; import { ZoneOverlay } from './ZoneOverlay'; import { PhaseStrip } from './PhaseStrip'; -import { SmartButton } from './SmartButton'; import { StackVisualizer } from './StackVisualizer'; + import { GestureManager } from './GestureManager'; import { MulliganView } from './MulliganView'; import { RadialMenu, RadialOption } from './RadialMenu'; @@ -801,10 +801,8 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } {/* Left Controls: Library/Grave/Exile */}
- {/* Phase Strip Integration */} -
- -
+ {/* Phase Strip Moved to Bottom Center */} +
= ({ gameState, currentPlayerId }
- {/* Smart Button Floating above Hand */} -
- + {/* Phase Strip Central Integration (Now acts as Smart Button) */} + socketService.socket.emit(type, { action: payload })} contextData={{ attackers: Array.from(proposedAttackers).map(id => ({ attackerId: id, targetId: opponentId })), @@ -867,6 +866,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } />
+
{myHand.map((card, index) => (
void; + contextData?: any; // For attackers/blockers context + isYielding?: boolean; + onYieldToggle?: () => void; } -export const PhaseStrip: React.FC = ({ gameState }) => { +export const PhaseStrip: React.FC = ({ + gameState, + currentPlayerId, + onAction, + contextData, + isYielding, + onYieldToggle +}) => { const currentPhase = gameState.phase as Phase; const currentStep = gameState.step as Step; + const isMyTurn = gameState.activePlayerId === currentPlayerId; + const hasPriority = gameState.priorityPlayerId === currentPlayerId; + const isStackEmpty = !gameState.stack || gameState.stack.length === 0; + + // --- Action Logic --- + let actionLabel = "Wait"; + // Base style: Glassmorphism dark + let baseStyle = "bg-slate-900/60 border-slate-700/50 text-slate-400"; + let hoverStyle = ""; + let glowStyle = ""; + let actionType: string | null = null; + let actionIcon = Hourglass; + + if (isYielding) { + actionLabel = "Yielding (Cancel)"; + baseStyle = "bg-sky-900/40 border-sky-500/30 text-sky-200"; + hoverStyle = "hover:bg-sky-900/60 hover:border-sky-400/50"; + glowStyle = "shadow-[0_0_20px_rgba(14,165,233,0.15)]"; + actionType = 'CANCEL_YIELD'; + actionIcon = XCircle; + } else if (hasPriority) { + // Interactive State: Subtle gradients, refined look + baseStyle = "cursor-pointer bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 border-emerald-500/40 text-emerald-100"; + hoverStyle = "hover:border-emerald-400/80 hover:shadow-[0_0_15px_rgba(16,185,129,0.2)]"; + actionIcon = Zap; + + if (currentStep === 'declare_attackers') { + if (gameState.attackersDeclared) { + actionLabel = "Confirm Attacks"; + actionType = 'PASS_PRIORITY'; + actionIcon = Swords; + baseStyle = "cursor-pointer bg-gradient-to-r from-orange-950/40 via-orange-900/40 to-orange-950/40 border-orange-500/50 text-orange-100"; + hoverStyle = "hover:border-orange-400 hover:shadow-[0_0_15px_rgba(249,115,22,0.2)]"; + } else { + const count = contextData?.attackers?.length || 0; + if (count > 0) { + actionLabel = `Attack with ${count}`; + actionType = 'DECLARE_ATTACKERS'; + actionIcon = Swords; + baseStyle = "cursor-pointer bg-gradient-to-r from-red-950/60 via-red-900/60 to-red-950/60 border-red-500/50 text-red-100"; + hoverStyle = "hover:border-red-400 hover:shadow-[0_0_15px_rgba(239,68,68,0.25)]"; + } else { + actionLabel = "Skip Combat"; + actionType = 'DECLARE_ATTACKERS'; + actionIcon = ChevronRight; + // Neutral/Skip style + baseStyle = "cursor-pointer bg-slate-900/80 border-slate-600/50 text-slate-300"; + hoverStyle = "hover:border-slate-500 hover:bg-slate-800"; + } + } + } else if (currentStep === 'declare_blockers') { + actionLabel = "Declare Blockers"; + actionType = 'DECLARE_BLOCKERS'; + actionIcon = Shield; + baseStyle = "cursor-pointer bg-gradient-to-r from-blue-950/60 via-blue-900/60 to-blue-950/60 border-blue-500/50 text-blue-100"; + hoverStyle = "hover:border-blue-400 hover:shadow-[0_0_15px_rgba(59,130,246,0.2)]"; + } else if (isStackEmpty) { + // Standard Pass + actionType = 'PASS_PRIORITY'; + actionIcon = ChevronRight; + if (gameState.phase === 'main1') actionLabel = "To Combat"; + else if (gameState.phase === 'main2') actionLabel = "End Turn"; + else actionLabel = "Pass Turn"; + + // Use a very sleek neutral/emerald gradient for standard progression + baseStyle = "cursor-pointer bg-gradient-to-b from-slate-800 to-slate-900 border-white/10 text-slate-200"; + hoverStyle = "hover:border-white/30 hover:bg-slate-800"; + } else { + // Resolve Check + const topItem = gameState.stack![gameState.stack!.length - 1]; + actionLabel = `Resolve ${topItem?.name || ''}`; + actionType = 'PASS_PRIORITY'; + actionIcon = Zap; + baseStyle = "cursor-pointer bg-amber-950/40 border-amber-500/40 text-amber-100"; + hoverStyle = "hover:border-amber-400/80 hover:shadow-[0_0_15px_rgba(245,158,11,0.2)]"; + } + } else { + // Waiting State + actionLabel = isMyTurn ? "Opponent Acting" : "Opponent's Turn"; + actionIcon = Hand; + baseStyle = "bg-black/40 border-white/5 text-slate-500"; + } + + const handleAction = () => { + if (isYielding) { + onYieldToggle?.(); + return; + } + if (!hasPriority) return; + + if (actionType) { + let payload: any = { type: actionType }; + if (actionType === 'DECLARE_ATTACKERS') { + payload.attackers = contextData?.attackers || []; + } + onAction('game_strict_action', payload); + } + }; // Phase Definitions - const phases: { id: Phase; icon: React.ElementType; label: string }[] = [ + const phases: { id: Phase; icon: React.ElementType; label: string }[] = useMemo(() => [ { id: 'beginning', icon: Sun, label: 'Beginning' }, { id: 'main1', icon: Shield, label: 'Main 1' }, { id: 'combat', icon: Swords, label: 'Combat' }, { id: 'main2', icon: Shield, label: 'Main 2' }, { id: 'ending', icon: Hourglass, label: 'End' }, - ]; + ], []); + + const activePhaseIndex = phases.findIndex(p => p.id === currentPhase); return ( -
- {phases.map((p) => { - const isActive = p.id === currentPhase; +
- return ( -
- + {/* Main Action Bar */} +
+ {/* Progress Line (Top) */} + {!hasPriority && ( +
+ )} +
- {/* Active Step Indicator (Text below or Tooltip) */} - {isActive && ( - - {currentStep} - - )} + {/* Left: Phase Indicator */} +
+
+ {(() => { + const PhaseIcon = phases.find(p => p.id === currentPhase)?.icon || Sun; + return ; + })()}
- ); - })} +
+ + {/* Center: Action Text */} +
+ + {actionLabel} + + {/* Detailed Step Subtext */} + {hasPriority && ( + + {currentStep.replace(/_/g, ' ')} + + )} +
+ + {/* Right: Interaction Icon */} +
+ {(() => { + const ActionIcon = actionIcon; + return ; + })()} +
+
+ + {/* Minimal Phase Dots */} +
+ {phases.map((p, idx) => { + const isActive = idx === activePhaseIndex; + const isPast = idx < activePhaseIndex; + return ( +
+ ); + })} +
+
); }; diff --git a/src/client/src/modules/game/SmartButton.tsx b/src/client/src/modules/game/SmartButton.tsx deleted file mode 100644 index 0038109..0000000 --- a/src/client/src/modules/game/SmartButton.tsx +++ /dev/null @@ -1,129 +0,0 @@ - -import React, { useRef } from 'react'; -import { GameState } from '../../types/game'; - -interface SmartButtonProps { - gameState: GameState; - playerId: string; - onAction: (type: string, payload?: any) => void; - contextData?: any; - isYielding?: boolean; - onYieldToggle?: () => void; -} - -export const SmartButton: React.FC = ({ gameState, playerId, onAction, contextData, isYielding, onYieldToggle }) => { - const isMyPriority = gameState.priorityPlayerId === playerId; - const isStackEmpty = !gameState.stack || gameState.stack.length === 0; - - let label = "Wait"; - let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed"; - let actionType: string | null = null; - - if (isYielding) { - label = "Yielding... (Tap to Cancel)"; - colorClass = "bg-sky-600 hover:bg-sky-500 text-white shadow-[0_0_15px_rgba(2,132,199,0.5)] animate-pulse"; - // Tap to cancel yield - actionType = 'CANCEL_YIELD'; - } else if (isMyPriority) { - if (gameState.step === 'declare_attackers') { - if (gameState.attackersDeclared) { - label = "Pass (to Blockers)"; - colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse"; - actionType = 'PASS_PRIORITY'; - } else { - const count = contextData?.attackers?.length || 0; - label = count > 0 ? `Attack with ${count}` : "Skip Combat"; - colorClass = "bg-red-600 hover:bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.5)] animate-pulse"; - actionType = 'DECLARE_ATTACKERS'; - } - } else if (gameState.step === 'declare_blockers') { - // Todo: blockers context - label = "Declare Blockers"; - colorClass = "bg-blue-600 hover:bg-blue-500 text-white shadow-[0_0_15px_rgba(37,99,235,0.5)] animate-pulse"; - actionType = 'DECLARE_BLOCKERS'; - } else if (isStackEmpty) { - // Pass Priority / Advance Step - // If Main Phase, could technically play land/cast, but button defaults to Pass - label = "Pass Turn/Phase"; - // If we want more granular: "Move to Combat" vs "End Turn" based on phase - if (gameState.phase === 'main1') label = "Pass to Combat"; - else if (gameState.phase === 'main2') label = "End Turn"; - else label = "Pass"; - - colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse"; - actionType = 'PASS_PRIORITY'; - } else { - // Resolve Top Item - const topItem = gameState.stack![gameState.stack!.length - 1]; - label = `Resolve ${topItem?.name || 'Item'}`; - colorClass = "bg-amber-600 hover:bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]"; - actionType = 'PASS_PRIORITY'; // Resolving is just passing priority when stack not empty - } - } - - const timerRef = useRef(null); - const isLongPress = useRef(false); - - const handlePointerDown = () => { - isLongPress.current = false; - timerRef.current = setTimeout(() => { - isLongPress.current = true; - if (onYieldToggle) { - // Visual feedback could be added here - onYieldToggle(); - } - }, 600); // 600ms long press for Yield - }; - - const handlePointerUp = () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - if (!isLongPress.current) { - handleClick(); - } - }; - - const handleClick = () => { - if (isYielding) { - // Cancel logic - if (onYieldToggle) onYieldToggle(); - return; - } - - if (actionType) { - let payload: any = { type: actionType }; - - if (actionType === 'DECLARE_ATTACKERS') { - payload.attackers = contextData?.attackers || []; - } - // TODO: Blockers payload - - onAction('game_strict_action', payload); - } - }; - - // Prevent context menu on long press - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - }; - - return ( - - ); -}; diff --git a/src/server/game/RulesEngine.ts b/src/server/game/RulesEngine.ts index 29f5661..d4de32c 100644 --- a/src/server/game/RulesEngine.ts +++ b/src/server/game/RulesEngine.ts @@ -13,17 +13,24 @@ export class RulesEngine { public passPriority(playerId: string): boolean { if (this.state.priorityPlayerId !== playerId) return false; // Not your turn - this.state.players[playerId].hasPassed = true; + const player = this.state.players[playerId]; + player.hasPassed = true; this.state.passedPriorityCount++; - // Check if all players passed - if (this.state.passedPriorityCount >= this.state.turnOrder.length) { + const totalPlayers = this.state.turnOrder.length; + + // Check if all players passed in a row + if (this.state.passedPriorityCount >= totalPlayers) { + // 1. If Stack is NOT empty, Resolve Top if (this.state.stack.length > 0) { this.resolveTopStack(); - } else { + } + // 2. If Stack IS empty, Advance Step + else { this.advanceStep(); } } else { + // Pass Priority to Next Player this.passPriorityToNext(); } return true;