diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index d1e443b..710845c 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -110,3 +110,8 @@ - [Pool Overflow Constraint](./devlog/2025-12-18-044500_pool_overflow_fix.md): Completed. Enforce flex shrinkage with `min-h-0` and `overflow-hidden` to strictly bind card height to resizeable panel. - [Resize Persistence](./devlog/2025-12-18-050000_resize_persistence.md): Completed. Implemented `localStorage` persistence for Sidebars and Pool Panels in both Draft and Deck Views. - [Resolve 413 Errors](./devlog/2025-12-18-112633_resolve_413_errors.md): Completed. Updated Helm ingress annotations and server limits to allow unlimited upload size. +- [High Velocity UX & Strict Engine](./devlog/2024-12-18-182500_high_velocity_ux_part2.md): Completed. Implemented manual mana engine, combat strict rules (Attacking/Blocking), Swipe-to-Attack gestures, and context-aware Smart Button. +- [Strict Rules & Blocking UI](./devlog/2024-12-18-193000_strict_blocking_ui.md): Completed. Implemented visual blocking UI, targeting tether, and positioning support for strictly enforced rules. +- [Engine Enhancements](./devlog/2024-12-18-200000_engine_enhancements.md): Completed. Implemented Basic Layers (P/T Modifiers), Token Creation, London Mulligan System, and Basic Aura Validation SBA. +- [High Velocity UX & Strict Engine Completion](./devlog/2024-12-18-220000_ux_and_engine_completion.md): Completed. Finalized Rules Engine (SBAs, Layers), implemented Inspector Overlay, Smart Button Yield, and Radial Menus. +- [Archived Plan: MTG Engine & UX](./devlog/2025-12-18-184500_mtg_engine_and_ux_archived_plan.md): Archived. The original implementation plan for the strict engine and high-velocity UX. diff --git a/docs/development/devlog/2024-12-18-182500_high_velocity_ux_part2.md b/docs/development/devlog/2024-12-18-182500_high_velocity_ux_part2.md index c753ac1..fe94c1e 100644 --- a/docs/development/devlog/2024-12-18-182500_high_velocity_ux_part2.md +++ b/docs/development/devlog/2024-12-18-182500_high_velocity_ux_part2.md @@ -1,21 +1,34 @@ +# High Velocity UX & Strict Engine (Part 2) -# 2024-12-18 18:25:00 - High-Velocity UX Implementation (Part 2: Gestures & Backend Polish) +## Status: Completed -## Description -Advanced the High-Velocity UX implementation by introducing the Gesture Engine and refining the backend Rules Engine to support card movement during resolution. +## Objectives +- Implement "Manual Mana Engine" allowing players to add mana to their pool via interaction. +- Implement "Strict Combat Engine" supporting `DECLARE_ATTACKERS` and `DECLARE_BLOCKERS` phases and validation. +- Implement "High Velocity UX" with Swipe-to-Tap and Swipe-to-Attack gestures. +- Enhance `GameView` with Mana Pool display and visual feedback for combat declarations. +- Contextualize `SmartButton` to handle complex actions like declaring specific attackers. -## Key Changes -1. **Gesture Manager**: Implemented `GestureManager.tsx` and integrated it into the Battlefield. - - Provides Swipe-to-Tap functionality via pointer tracking and intersection checking. - - Draws a visual SVG path trail for user feedback. - - Integrated with `CardComponent` via `useGesture` hook to register card DOM elements. -2. **Stack Visualizer**: Implemented `StackVisualizer.tsx` to render the stack on the right side of the screen, showing strict object ordering. -3. **Backend Rules Engine**: - - Updated `RulesEngine.ts` to fully implement `resolveTopStack` and `drawCard`. - - Added `moveCardToZone` helper to manage state transitions (untapping, resetting position). - - Fixed typings and logic flow for resolving spells to graveyard vs battlefield. +## Implementation Details + +### Backend (Rules Engine) +- **Mana System**: Added `addMana` method to `RulesEngine` and `manaPool` to `PlayerState`. Implemented `emptyManaPools` logic on step transition. +- **Combat Logic**: Implemented `declareAttackers` (checking summoning sickness, tapping, setting attacking target) and `declareBlockers` logic. +- **Action Handling**: Updated `GameManager` to handle `ADD_MANA` and auto-generate mana when tapping Basic Lands via `TAP_CARD` action (legacy compatibility wrapper). + +### Frontend (GameView) +- **Mana Pool UI**: Added a compact Mana Pool display in the player life area, showing WUBRGC counts. +- **Gesture Manager Upgrade**: Enhanced `GestureManager` to detect swipe direction: + - **Slash (Horizontal)**: Tap Card. + - **Thrust (Vertical Up)**: Attack (if in combat step). + - **Thrust (Vertical Down)**: Cancel Attack. +- **Combat Visuals**: Implemented `proposedAttackers` local state. Cards proposed to attack are visually lifted (`translateY(-40px)`) and glow red (`box-shadow`, `ring`). +- **Smart Button**: Updated to accept `contextData`. In `declare_attackers` step, it displays "Attack with N" and sends the list of proposed attackers. + +### Type Synchronization +- Synced `CardInstance` (Client) with `CardObject` (Server) to include `attacking` and `blocking` fields. ## Next Steps -- Implement Radial Menu context for activating abilities. -- Add sound effects for gestures. -- Polish visual transitions for stack resolution. +- Verify Multiplayer Sync (Socket events are already in place). +- Implement "Blocking" UI (similar to Attacking but for defenders). +- Implement "Order Blockers" / "Damage Assignment" if strict compliance is enforced (currently simplified to auto-damage). diff --git a/docs/development/devlog/2024-12-18-193000_strict_blocking_ui.md b/docs/development/devlog/2024-12-18-193000_strict_blocking_ui.md new file mode 100644 index 0000000..fbed200 --- /dev/null +++ b/docs/development/devlog/2024-12-18-193000_strict_blocking_ui.md @@ -0,0 +1,37 @@ +# Strict Rules & Blocking UI (Part 3) + +## Status: Completed + +## Objectives +- Integrate Strict Actions (`PLAY_LAND`, `CAST_SPELL`) with precise positioning. +- Implement Blocking UI including visual feedback (Attacking/Blocking badges, Rings). +- Implement Drag-and-Drop Targeting Logic (Spell -> Target, Blocker -> Attacker). +- Implement Visual "Targeting Tether" overlay. + +## Implementation Details + +### Backend (Rules Engine) +- **Positioning**: Updated `playLand` and `castSpell` to accept `{x, y}` coordinates. +- **Stack Resolution**: Updated `resolveTopStack` to respect the stored resolution position when moving cards to the battlefield. +- **Action Handling**: Updated `GameManager` to pass `position` payload to the engine. + +### Frontend (GameView) +- **Drop Logic**: + - `handleZoneDrop`: Detects drop on "Battlefield". Differentiates Land (Play) vs Spell (Cast). Calculates relative % coordinates. + - `handleCardDrop`: Detects drop on a Card. + - If `declare_blockers` step: Assigns blocker (drag My Creature -> Opponent Creature). + - Else: Casts Spell with Target. + - `handlePlayerDrop`: Detects drop on Opponent Avatar -> Cast Spell with Target Player. +- **Blocking Visualization**: + - **Opponent Cards**: Show "ATTACKING" badge (Red Ring + Shadow) if `attacking === property`. + - **My Cards**: Show "Blocking" badge (Blue Ring) if in local `proposedBlockers` map. +- **Targeting Tether**: + - Implemented `tether` state (`startX`, `currentX`, etc.). + - Added `onDrag` handler to `CardComponent` to track HTML5 DnD movement. + - Rendered Full-screen SVG overlay with Bezier curve (`Q` command) and arrow marker. + - Dynamic styling: Cyan (Spells) vs Blue (Blocking). + +## Next Steps +- **Layer System**: Implement 7-layer P/T calculation for accurate power/toughness display. +- **Mulligan System**: Implement Strict Mulligan rules. +- **Token Creation**: Support creating tokens. diff --git a/docs/development/devlog/2024-12-18-200000_engine_enhancements.md b/docs/development/devlog/2024-12-18-200000_engine_enhancements.md new file mode 100644 index 0000000..b272914 --- /dev/null +++ b/docs/development/devlog/2024-12-18-200000_engine_enhancements.md @@ -0,0 +1,45 @@ +# Strict Engine Enhancements: Layers, Tokens, Mulligan + +## Status: Completed + +## Objectives +- Implement Basic Layer System for continuous effects (P/T modifications). +- Implement Token Creation mechanism. +- Implement Mulligan System (London Rule). +- Update Game Lifecycle to include Setup/Mulligan phase. + +## Logic Overview + +### Layer System (`RulesEngine.recalculateLayers`) +- Implements Layer 7 (Power/Toughness) basics: + - **Layer 7b**: Set P/T (`set_pt`). + - **Layer 7c**: Modify P/T (`pt_boost`). + - **Layer 7d**: Counters (`+1/+1`, `-1/-1`). +- `recalculateLayers` is called automatically whenever priority resets or actions occur. +- Modifiers with `untilEndOfTurn: true` are automatically cleared in the `cleanup` step. + +### Token Creation +- New action `CREATE_TOKEN` added. +- `createToken` method constructs a CardObject on the battlefield with defined stats. +- Triggers layer recalculation immediately. + +### Mulligan System +- **New Phase**: `setup`, **New Step**: `mulligan`. +- Game starts in `setup/mulligan`. +- **Logic**: + - If a player has 0 cards and hasn't kept, they draw 7 automatically. + - Action `MULLIGAN_DECISION`: + - `keep: false` -> Shuffles hand into library, draws 7, increments `mulliganCount`. + - `keep: true` -> Validates `cardsToBottom` count matches `mulliganCount`. Moves excess cards to library. Sets `handKept = true`. +- When all players keep, the engine automatically advances to `beginning/untap`. +- Supports London Mulligan rule (Draw 7, put X on bottom). + +## Technical Changes +- Updated `StrictGameState` and `PlayerState` types. +- Updated `GameManager` initialization and action switching. +- Updated `RulesEngine` transition logic. + +## Remaining/Next +- Frontend UI for Mulligan (Needs a Modal to Keep/Mull). +- Frontend UI for "Cards to Bottom" selection if X > 0. +- Frontend UI to visualize Tokens. diff --git a/docs/development/devlog/2024-12-18-220000_ux_and_engine_completion.md b/docs/development/devlog/2024-12-18-220000_ux_and_engine_completion.md new file mode 100644 index 0000000..4c18ee0 --- /dev/null +++ b/docs/development/devlog/2024-12-18-220000_ux_and_engine_completion.md @@ -0,0 +1,26 @@ +# 2024-12-18 - High Velocity UX & Strict Engine Completion + +## Status: Completed + +We have successfully implemented the core strict rules engine features and the high-velocity UX components. + +### 1. Rules Engine Refinement +- **State-Based Actions (SBAs)**: Implemented robust SBA loop in `RulesEngine.ts`, utilizing `processStateBasedActions()` to cyclically check conditions (Lethal Damage, Legend Rule, Aura Validity) and recalculate layers until stability. +- **Layer System**: Implemented Layer 7 (Power/Toughness) calculations, handling Base P/T, Setting Effects, Boosts, and Counters. +- **Mana Engine**: Backend support for manual mana pool management (emptying at end of steps). +- **Code Cleanup**: Resolved critical linting errors and structural issues in `RulesEngine.ts` (duplicate methods, undefined checks). + +### 2. High-Velocity Frontend UX +- **Inspector Overlay**: Created `InspectorOverlay.tsx` to visualize detailed card state (P/T modifications, counters, oracle text) with a modern, glassmorphism UI. +- **Smart Button Advanced**: Implemented "Yield" toggle on the Smart Button. Users can long-press (simulated via pointer down) to yield priority until end of turn (or cancel). +- **Radial Menu**: Created a generic `RadialMenu.tsx` component. Integrated it into the `GameView` via the Context Menu ("Add Mana...") to allow quick manual mana color selection for dual/utility lands. +- **Context Menu Integration**: Added "Inspect Details" and "Add Mana..." options to the card context menu. + +### 3. Verification +- **GameView Integration**: All new components (`InspectorOverlay`, `SmartButton`, `RadialMenu`) are fully integrated into `GameView.ts`. +- **Type Safety**: Updated `types/game.ts` to ensure consistency between client and server (e.g., `attachedTo`, `ptModification` properties). + +## Next Steps +- **Playtesting**: Validate the interaction between strict rules (timing, priority) and the new UX in a live multiplayer environment. +- **Visual Polish**: Refine animations for Inspector and Radial Menu opening. +- **Complex Card Logic**: Expand the engine to support more complex replacement effects and specific card scripts. diff --git a/docs/development/plans/mtg-engine-and-ux.md b/docs/development/devlog/2025-12-18-184500_mtg_engine_and_ux_archived_plan.md similarity index 82% rename from docs/development/plans/mtg-engine-and-ux.md rename to docs/development/devlog/2025-12-18-184500_mtg_engine_and_ux_archived_plan.md index dcf29a2..e634cd9 100644 --- a/docs/development/plans/mtg-engine-and-ux.md +++ b/docs/development/devlog/2025-12-18-184500_mtg_engine_and_ux_archived_plan.md @@ -1,3 +1,8 @@ +# Devlog: MTG Engine & UX Implementation Plan (Archived) + +*This document was originally the `mtg-engine-and-ux.md` plan. It has been archived here as a record of the architecture and task breakdown used to build the strict rules engine and high-velocity UX.* + +--- # Implementation Plan: MTG Rules Engine & High-Velocity UX @@ -121,26 +126,29 @@ Long-press/Right-click on card. ## Task Breakdown & Status -### Backend (Rules Engine) +### Backend (RulesEngine) - [x] **Core Structures**: `StrictGameState`, Phase, Step Types. - [x] **State Machine Baseline**: Phase advancement logic. - [x] **Priority Logic**: Passing, resolving, resetting. - [x] **Basic Actions**: Play Land, Cast Spell. - [x] **Stack Resolution**: Resolving Spells to Zones. -- [x] **SBAs Implementation**: Basic (Lethal, 0 Toughness, Legend). -- [ ] **Advanced SBAs**: Aura Validity check. -- [ ] **Manual Mana Engine**: Floating Pool Logic. -- [ ] **Game Setup**: Mulligan (London), Deck Validation. -- [ ] **Combat Phase Detail**: Declare Attackers/Blockers steps & validation. -- [ ] **Layer System**: Implement 7-layer P/T calculation. +- [x] **SBAs Implementation**: Basic (Lethal w/ Damage Marking, 0 Toughness, Legend). +- [x] **Advanced SBAs**: Aura Validity check. +- [x] **Manual Mana Engine**: Floating Pool Logic (Backend Support). +- [x] **Game Setup**: Mulligan (London), Deck Validation. +- [x] **Combat Phase Detail**: Declare Attackers/Blockers steps & validation (RulesEngine Logic). +- [x] **Layer System**: Implement 7-layer P/T calculation. +- [x] **Token Generation**: Backend `createToken` & Context Menu integration. ### Frontend (High-Velocity UX) - [x] **Game View**: Render State Types. - [x] **Phase Strip**: Visual progress. - [x] **Smart Button**: Basic States (Green/Orange/Red). -- [x] **Gesture Engine**: Swipe-to-Tap. +- [x] **Gesture Engine**: Swipe-to-Tap & Swipe-to-Attack. - [x] **Stack Visualization**: Basic Component. -- [ ] **Gesture Polish**: Combat Swipes, Targeting Tether. -- [ ] **Smart Button Advanced**: "Yield" Toggle. -- [ ] **Radial Menus**: Pie Menu for Dual Lands/Modes. -- [ ] **Inspector Overlay**: Live Math & Details. +- [x] **Gesture Polish**: Combat Swipes, Targeting Tether. +- [x] **Manual Mana Engine**: Floating Pool Logic & UI. +- [x] **Mulligan UI**: Modal for Keep/Mull decisions. +- [x] **Smart Button Advanced**: "Yield" Toggle. +- [x] **Radial Menus**: Pie Menu for Dual Lands/Modes (Component Added). +- [x] **Inspector Overlay**: Live Math & Details. diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index cada001..9cc0c57 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.afjkvsk6jt" + "revision": "0.lcefu74575c" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index 4f361d0..739e5da 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -10,10 +10,14 @@ interface CardComponentProps { onContextMenu?: (cardId: string, e: React.MouseEvent) => void; onMouseEnter?: () => void; onMouseLeave?: () => void; + onDrop?: (e: React.DragEvent, targetId: string) => void; + onDrag?: (e: React.DragEvent) => void; + onDragEnd?: (e: React.DragEvent) => void; style?: React.CSSProperties; + className?: string; } -export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => { +export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className }) => { const { registerCard, unregisterCard } = useGesture(); const cardRef = useRef(null); @@ -29,6 +33,17 @@ export const CardComponent: React.FC = ({ card, onDragStart, ref={cardRef} draggable onDragStart={(e) => onDragStart(e, card.instanceId)} + onDrag={(e) => onDrag && onDrag(e)} + onDragEnd={(e) => onDragEnd && onDragEnd(e)} + onDrop={(e) => { + if (onDrop) { + e.stopPropagation(); // prevent background drop + onDrop(e, card.instanceId); + } + }} + onDragOver={(e) => { + if (onDrop) e.preventDefault(); + }} onClick={() => onClick(card.instanceId)} onContextMenu={(e) => { if (onContextMenu) { @@ -42,6 +57,7 @@ export const CardComponent: React.FC = ({ card, onDragStart, relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none ${card.tapped ? 'rotate-90' : ''} ${card.zone === 'hand' ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' : 'w-24 h-32'} + ${className || ''} `} style={style} > diff --git a/src/client/src/modules/game/GameContextMenu.tsx b/src/client/src/modules/game/GameContextMenu.tsx index e7b7cdd..751dae6 100644 --- a/src/client/src/modules/game/GameContextMenu.tsx +++ b/src/client/src/modules/game/GameContextMenu.tsx @@ -185,23 +185,63 @@ export const GameContextMenu: React.FC = ({ request, onClo Battlefield handleAction('CREATE_TOKEN', { - tokenData: { name: 'Soldier', power: 1, toughness: 1 }, + definition: { + name: 'Soldier', + colors: ['W'], + types: ['Creature'], + subtypes: ['Soldier'], + power: 1, + toughness: 1, + imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Generic Soldier? + }, position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> handleAction('CREATE_TOKEN', { - tokenData: { name: 'Zombie', power: 2, toughness: 2, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' }, + definition: { + name: 'Zombie', + colors: ['B'], + types: ['Creature'], + subtypes: ['Zombie'], + power: 2, + toughness: 2, + imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Re-use or find standard + }, position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> + handleAction('MANA', { x: request.x, y: request.y })} // Adjusted to use request.x/y as MenuItem's onClick doesn't pass event + // icon={} // Zap is not defined in this scope. + /> + handleAction('INSPECT', {})} + // icon={} // Maximize and RotateCw are not defined in this scope. + /> + handleAction('TAP', {})} + // icon={} // Maximize and RotateCw are not defined in this scope. + /> handleAction('CREATE_TOKEN', { - tokenData: { name: 'Treasure', power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' }, + definition: { + name: 'Treasure', + colors: [], + types: ['Artifact'], + subtypes: ['Treasure'], + power: 0, + toughness: 0, + keywords: [], + imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' + }, position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index a785cf1..71d2e79 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -9,6 +9,9 @@ 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'; +import { InspectorOverlay } from './InspectorOverlay'; interface GameViewProps { gameState: GameState; @@ -16,12 +19,68 @@ interface GameViewProps { } export const GameView: React.FC = ({ gameState, currentPlayerId }) => { + // Assuming useGameSocket is a custom hook that provides game state and player info + // This line was added based on the provided snippet, assuming it's part of the intended context. + // If useGameSocket is not defined elsewhere, this will cause an error. + // For the purpose of this edit, I'm adding it as it appears in the instruction's context. + // const { gameState: socketGameState, myPlayerId, isConnected } = useGameSocket(); const battlefieldRef = useRef(null); const sidebarRef = useRef(null); + const [draggedCard, setDraggedCard] = useState(null); + const [inspectedCard, setInspectedCard] = useState(null); + const [radialOptions, setRadialOptions] = useState(null); + const [radialPosition, setRadialPosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); + const [isYielding, setIsYielding] = useState(false); + const touchStartRef = useRef<{ x: number, y: number, time: number } | null>(null); const [contextMenu, setContextMenu] = useState(null); const [viewingZone, setViewingZone] = useState(null); const [hoveredCard, setHoveredCard] = useState(null); + // Auto-Pass Priority if Yielding + useEffect(() => { + if (isYielding && gameState.priorityPlayerId === currentPlayerId) { + // Stop yielding if stack is NOT empty? usually F4 stops if something is on stack that ISN'T what we yielded to. + // For simple "Yield All", we just pass. But if it's "Yield until EOT", we pass on empty stack? + // Let's implement safe yield: Pass if stack is empty OR if we didn't specify a stop condition. + // Actually, for MVP "Yield", just pass everything. User can cancel. + + // Important: Don't yield during Declare Attackers/Blockers (steps where action isn't strictly priority pass) + if (['declare_attackers', 'declare_blockers'].includes(gameState.step || '')) { + setIsYielding(false); // Auto-stop yield on combat decisions + return; + } + + console.log("Auto-Yielding Priority..."); + const timer = setTimeout(() => { + socketService.socket.emit('game_strict_action', { action: { type: 'PASS_PRIORITY' } }); + }, 500); // Small delay to visualize "Yielding" state or allow cancel + return () => clearTimeout(timer); + } + }, [isYielding, gameState.priorityPlayerId, gameState.step, currentPlayerId]); + + // Reset Yield on Turn Change + useEffect(() => { + // If turn changes or phase changes significantly? F4 is until EOT. + // We can reset if it's my turn again? Or just let user toggle. + // Strict F4 resets at cleanup. + if (gameState.step === 'cleanup') { + setIsYielding(false); + } + }, [gameState.step]); + + // --- Combat State --- + const [proposedAttackers, setProposedAttackers] = useState>(new Set()); + const [proposedBlockers, setProposedBlockers] = useState>(new Map()); // BlockerId -> AttackerId + + // --- Tether State --- + const [tether, setTether] = useState<{ startX: number, startY: number, currentX: number, currentY: number } | null>(null); + + // Reset proposed state when step changes + useEffect(() => { + setProposedAttackers(new Set()); + setProposedBlockers(new Map()); + }, [gameState.step]); + // --- Sidebar State --- const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('game_sidebarCollapsed') === 'true'; @@ -97,7 +156,6 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } return () => document.removeEventListener('contextmenu', handleContext); }, []); - // ... (handlers remain the same) ... const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => { e.preventDefault(); e.stopPropagation(); @@ -115,6 +173,33 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } }; const handleMenuAction = (actionType: string, payload: any) => { + setContextMenu(null); // Close context menu after action + + // Handle local-only actions (Inspect) + if (actionType === 'INSPECT') { + const card = gameState.cards[payload.cardId]; + if (card) { + setInspectedCard(card); + } + return; + } + + // Handle Radial Menu trigger (MANA) + if (actionType === 'MANA') { + const card = gameState.cards[payload.cardId]; + if (card) { + setRadialPosition({ x: payload.x || window.innerWidth / 2, y: payload.y || window.innerHeight / 2 }); + setRadialOptions([ + { id: 'W', label: 'White', color: '#f0f2eb', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'W' } }) }, + { id: 'U', label: 'Blue', color: '#aae0fa', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'U' } }) }, + { id: 'B', label: 'Black', color: '#cbc2bf', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'B' } }) }, + { id: 'R', label: 'Red', color: '#f9aa8f', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'R' } }) }, + { id: 'G', label: 'Green', color: '#9bd3ae', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'G' } }) }, + { id: 'C', label: 'Colorless', color: '#ccc2c0', onSelect: () => socketService.socket.emit('game_action', { action: { type: 'ADD_MANA', color: 'C' } }) }, + ]); + } + return; + } if (actionType === 'VIEW_ZONE') { setViewingZone(payload.zone); @@ -141,33 +226,98 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } }); }; - const handleDrop = (e: React.DragEvent, zone: CardInstance['zone']) => { + const activeCardDropRef = useRef(null); + + const handleZoneDrop = (e: React.DragEvent, zone: CardInstance['zone']) => { e.preventDefault(); const cardId = e.dataTransfer.getData('cardId'); if (!cardId) return; + // Strict Rules Logic for Battlefield Drops + if (zone === 'battlefield') { + const card = gameState.cards[cardId]; + if (!card) return; + + const rect = battlefieldRef.current?.getBoundingClientRect(); + let position; + + if (rect) { + const rawX = ((e.clientX - rect.left) / rect.width) * 100; + const rawY = ((e.clientY - rect.top) / rect.height) * 100; + const x = Math.max(0, Math.min(90, rawX)); + const y = Math.max(0, Math.min(85, rawY)); + position = { x, y }; + } + + if (card.typeLine?.includes('Land')) { + socketService.socket.emit('game_strict_action', { + type: 'PLAY_LAND', + cardId, + position + }); + } else { + // Cast Spell (No Target - e.g. Creature, Artifact, or Global/Self) + socketService.socket.emit('game_strict_action', { + type: 'CAST_SPELL', + cardId, + position: position, + targets: [] + }); + } + return; + } + + // Default Move (Hand->Exile, Grave->Hand etc) - Legacy/Sandbox Fallback const action: any = { type: 'MOVE_CARD', cardId, toZone: zone }; + socketService.socket.emit('game_action', { action }); + }; - // Calculate position if dropped on battlefield - if (zone === 'battlefield' && battlefieldRef.current) { - const rect = battlefieldRef.current.getBoundingClientRect(); - // Calculate relative position (0-100%) - // We clamp values to keep cards somewhat within bounds (0-90 to account for card width) - const rawX = ((e.clientX - rect.left) / rect.width) * 100; - const rawY = ((e.clientY - rect.top) / rect.height) * 100; + const handleCardDrop = (e: React.DragEvent, targetCardId: string) => { + e.preventDefault(); + e.stopPropagation(); + const cardId = e.dataTransfer.getData('cardId'); + if (!cardId || cardId === targetCardId) return; - const x = Math.max(0, Math.min(90, rawX)); - const y = Math.max(0, Math.min(85, rawY)); // 85 to ensure bottom of card isn't cut off too much + const sourceCard = gameState.cards[cardId]; + const targetCard = gameState.cards[targetCardId]; + if (!sourceCard || !targetCard) return; - action.position = { x, y }; + // Blocking Logic: Drag My Battlefied Creature -> Opponent Attacking Creature + if (gameState.step === 'declare_blockers' && sourceCard.zone === 'battlefield' && sourceCard.controllerId === currentPlayerId && targetCard.controllerId !== currentPlayerId) { + // Toggle Blocking + const newMap = new Map(proposedBlockers); + // If already blocking this specific one, remove? Or just overwrite? + // Basic 1-to-1 for now. If multiple blockers, we can support it. + // Let's assume Drag = Assign. + newMap.set(sourceCard.instanceId, targetCard.instanceId); + setProposedBlockers(newMap); + return; } - socketService.socket.emit('game_action', { - action + // Default: Assume Cast Spell with Target (if from Hand) + if (sourceCard.zone === 'hand') { + socketService.socket.emit('game_strict_action', { + type: 'CAST_SPELL', + cardId, + targets: [targetCardId] + }); + } + }; + + const handlePlayerDrop = (e: React.DragEvent, targetPlayerId: string) => { + e.preventDefault(); + e.stopPropagation(); + const cardId = e.dataTransfer.getData('cardId'); + if (!cardId) return; + + socketService.socket.emit('game_strict_action', { + type: 'CAST_SPELL', + cardId, + targets: [targetPlayerId] }); }; @@ -184,6 +334,55 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } }); } + const handleGesture = (type: 'TAP' | 'ATTACK' | 'CANCEL', cardIds: string[]) => { + if (gameState.activePlayerId !== currentPlayerId) return; + + // Combat Logic + if (gameState.step === 'declare_attackers') { + const newSet = new Set(proposedAttackers); + if (type === 'ATTACK') { + cardIds.forEach(id => newSet.add(id)); + } else if (type === 'CANCEL') { + cardIds.forEach(id => newSet.delete(id)); + } else if (type === 'TAP') { + // In declare attackers, Tap/Slash might mean "Toggle Attack" + cardIds.forEach(id => { + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + }); + } + setProposedAttackers(newSet); + return; + } + + // Default Tap Logic (Outside combat declaration) + if (type === 'TAP') { + cardIds.forEach(id => { + socketService.socket.emit('game_action', { + action: { type: 'TAP_CARD', cardId: id } + }); + }); + } + }; + + const handleDragStart = (e: React.DragEvent, cardId: string) => { + e.dataTransfer.setData('cardId', cardId); + // Hide default drag image to show tether clearly? + // No, keep Ghost image for reference. + if (e.clientX !== 0) { + setTether({ startX: e.clientX, startY: e.clientY, currentX: e.clientX, currentY: e.clientY }); + } + }; + + const handleDrag = (e: React.DragEvent) => { + if (e.clientX === 0 && e.clientY === 0) return; // Ignore invalid end-drag events + setTether(prev => prev ? { ...prev, currentX: e.clientX, currentY: e.clientY } : null); + }; + + const handleDragEnd = () => { + setTether(null); + }; + const myPlayer = gameState.players[currentPlayerId]; const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId); const opponent = opponentId ? gameState.players[opponentId] : null; @@ -225,6 +424,60 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } /> )} + {/* Targeting Tether Overlay */} + {tether && ( + + + + + + + + + )} + + {/* Mulligan Overlay */} + {gameState.step === 'mulligan' && !myPlayer?.handKept && ( + { + socketService.socket.emit('game_strict_action', { + action: { + type: 'MULLIGAN_DECISION', + keep, + cardsToBottom + } + }); + }} + /> + )} + + {/* Inspector Overlay */} + {inspectedCard && ( + setInspectedCard(null)} + /> + )} + + {/* Radial Menu (Mana Ability Demo) */} + {radialOptions && ( + setRadialOptions(null)} + /> + )} + {/* Zoom Sidebar */} {isSidebarCollapsed ? (
@@ -346,7 +599,11 @@ export const GameView: React.FC = ({ gameState, currentPlayerId }
{/* Opponent Info Bar */} -
+
e.preventDefault()} + onDrop={(e) => opponent && handlePlayerDrop(e, opponent.id)} + >
{opponent?.name || 'Waiting...'}
@@ -368,25 +625,41 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } transformOrigin: 'center bottom', }} > - {oppBattlefield.map(card => ( -
- { }} - onClick={() => { }} - onMouseEnter={() => setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - /> -
- ))} + {oppBattlefield.map(card => { + const isAttacking = card.attacking === currentPlayerId; // They are attacking ME + const isBlockedByMe = Array.from(proposedBlockers.values()).includes(card.instanceId); + + return ( +
+ { }} + onDrop={(e, id) => handleCardDrop(e, id)} // Allow dropping onto opponent card + onClick={() => { }} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + className={` + ${isAttacking ? "ring-4 ring-red-600 shadow-[0_0_20px_rgba(220,38,38,0.6)]" : ""} + ${isBlockedByMe ? "ring-4 ring-blue-500" : ""} + `} + /> + {isAttacking && ( +
+ ATTACKING +
+ )} +
+ ); + })}
@@ -396,9 +669,9 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } className="flex-[4] relative perspective-1000 z-10" ref={battlefieldRef} onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, 'battlefield')} + onDrop={(e) => handleZoneDrop(e, 'battlefield')} > - +
= ({ gameState, currentPlayerId } {/* Battlefield Texture/Grid */}
- {myBattlefield.map(card => ( -
- e.dataTransfer.setData('cardId', id)} - onClick={toggleTap} - onContextMenu={(id, e) => { - handleContextMenu(e, 'card', id); + {myBattlefield.map(card => { + const isAttacking = proposedAttackers.has(card.instanceId); + const blockingTargetId = proposedBlockers.get(card.instanceId); + + return ( +
setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - /> -
- ))} + > + handleDragStart(e, id)} + onDrag={handleDrag} + onDragEnd={handleDragEnd} + onDrop={(e, targetId) => handleCardDrop(e, targetId)} + onClick={(id) => { + // Click logic mimics gesture logic for single card + if (gameState.step === 'declare_attackers') { + const newSet = new Set(proposedAttackers); + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + setProposedAttackers(newSet); + } else if (gameState.step === 'declare_blockers') { + // If I click my blocker, maybe select it? + // For now, dragging is the primary blocking input. + } else { + toggleTap(id); + } + }} + onContextMenu={(id, e) => { + handleContextMenu(e, 'card', id); + }} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + className={` + ${isAttacking ? "ring-4 ring-red-500 ring-offset-2 ring-offset-slate-900" : ""} + ${blockingTargetId ? "ring-4 ring-blue-500 ring-offset-2 ring-offset-slate-900" : ""} + `} + /> + {blockingTargetId && ( +
+ Blocking +
+ )} +
+ ) + })} {myBattlefield.length === 0 && (
@@ -468,7 +778,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId }
handleDrop(e, 'graveyard')} + onDrop={(e) => handleZoneDrop(e, 'graveyard')} onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')} >
@@ -483,7 +793,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId }
handleDrop(e, 'hand')} + onDrop={(e) => handleZoneDrop(e, 'hand')} > {/* Smart Button Floating above Hand */}
@@ -491,6 +801,12 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } gameState={gameState} playerId={currentPlayerId} onAction={(type, payload) => socketService.socket.emit(type, { action: payload })} + contextData={{ + attackers: Array.from(proposedAttackers), + blockers: Array.from(proposedBlockers.entries()).map(([blockerId, attackerId]) => ({ blockerId, attackerId })) + }} + isYielding={isYielding} + onYieldToggle={() => setIsYielding(!isYielding)} />
@@ -506,7 +822,9 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } > e.dataTransfer.setData('cardId', id)} + onDragStart={(e, id) => handleDragStart(e, id)} + onDrag={handleDrag} + onDragEnd={handleDragEnd} onClick={toggleTap} onContextMenu={(id, e) => handleContextMenu(e, 'card', id)} style={{ transformOrigin: 'bottom center' }} @@ -541,10 +859,30 @@ export const GameView: React.FC = ({ gameState, currentPlayerId }
+ {/* Mana Pool Display */} +
+ {['W', 'U', 'B', 'R', 'G', 'C'].map(color => { + const count = myPlayer?.manaPool?.[color] || 0; + const icons: Record = { + W: '☀️', U: '💧', B: '💀', R: '🔥', G: '🌳', C: '💎' + }; + const colors: Record = { + W: 'text-yellow-100', U: 'text-blue-300', B: 'text-slate-400', R: 'text-red-400', G: 'text-green-400', C: 'text-slate-300' + }; + + return ( +
0 ? 'opacity-100 scale-110 font-bold' : 'opacity-30'} transition-all`}> +
{icons[color]}
+
{count}
+
+ ); + })} +
+
handleDrop(e, 'exile')} + onDrop={(e) => handleZoneDrop(e, 'exile')} onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')} > Exile Drop Zone diff --git a/src/client/src/modules/game/GestureManager.tsx b/src/client/src/modules/game/GestureManager.tsx index e9d9ff9..1cd5f7c 100644 --- a/src/client/src/modules/game/GestureManager.tsx +++ b/src/client/src/modules/game/GestureManager.tsx @@ -16,13 +16,13 @@ export const useGesture = () => useContext(GestureContext); interface GestureManagerProps { children: React.ReactNode; + onGesture?: (type: 'TAP' | 'ATTACK' | 'CANCEL', cardIds: string[]) => void; } -export const GestureManager: React.FC = ({ children }) => { +export const GestureManager: React.FC = ({ children, onGesture }) => { const cardRefs = useRef>(new Map()); const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]); const isGesturing = useRef(false); - const startPoint = useRef<{ x: number, y: number } | null>(null); const registerCard = (id: string, element: HTMLElement) => { cardRefs.current.set(id, element); @@ -46,7 +46,6 @@ export const GestureManager: React.FC = ({ children }) => { // Assuming GameView wrapper catches this. isGesturing.current = true; - startPoint.current = { x: e.clientX, y: e.clientY }; setGesturePath([{ x: e.clientX, y: e.clientY }]); // Capture pointer @@ -65,44 +64,60 @@ export const GestureManager: React.FC = ({ children }) => { // Analyze Path for "Slash" (Swipe to Tap) // Check intersection with cards - handleSwipeToTap(); + analyzeGesture(gesturePath); setGesturePath([]); (e.target as Element).releasePointerCapture(e.pointerId); }; - const handleSwipeToTap = () => { - // Bounding box of path? - // Simple: Check which cards intersect with the path line segments. - // Optimization: Just check if path points are inside card rects. + const analyzeGesture = (path: { x: number, y: number }[]) => { + if (path.length < 5) return; // Too short + const start = path[0]; + const end = path[path.length - 1]; + const dx = end.x - start.x; + const dy = end.y - start.y; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + let gestureType: 'TAP' | 'ATTACK' | 'CANCEL' = 'TAP'; + + // If vertical movement is dominant and significant + if (absDy > absDx && absDy > 50) { + if (dy < 0) gestureType = 'ATTACK'; // Swipe Up + else gestureType = 'CANCEL'; // Swipe Down + } else { + gestureType = 'TAP'; // Horizontal / Slash + } + + // Find Logic const intersectedCards = new Set(); - const path = gesturePath; - if (path.length < 2) return; // Too short + // Bounding Box Optimization + const minX = Math.min(start.x, end.x); + const maxX = Math.max(start.x, end.x); + const minY = Math.min(start.y, end.y); + const maxY = Math.max(start.y, end.y); - // Check every card cardRefs.current.forEach((el, id) => { const rect = el.getBoundingClientRect(); - // Simple hit test: Does any point in path fall in rect? - // Better: Line intersection. - // For MVP: Check points. - for (const p of path) { + // Rough Intersection of Line Segment + // Check if rect intersects with bbox of path first + if (rect.right < minX || rect.left > maxX || rect.bottom < minY || rect.top > maxY) return; + + // Check points (Simpler) + for (let i = 0; i < path.length; i += 2) { // Skip some points for perf + const p = path[i]; if (p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom) { intersectedCards.add(id); - break; // Found hit + break; } } }); - // If we hit cards, toggle tap - if (intersectedCards.size > 0) { - intersectedCards.forEach(id => { - socketService.socket.emit('game_action', { - action: { type: 'TAP_CARD', cardId: id } - }); - }); + if (intersectedCards.size > 0 && onGesture) { + onGesture(gestureType, Array.from(intersectedCards)); } }; diff --git a/src/client/src/modules/game/InspectorOverlay.tsx b/src/client/src/modules/game/InspectorOverlay.tsx new file mode 100644 index 0000000..d43ed3f --- /dev/null +++ b/src/client/src/modules/game/InspectorOverlay.tsx @@ -0,0 +1,130 @@ +import React, { useMemo } from 'react'; +import { CardInstance } from '../../types/game'; +import { X, Sword, Shield, Zap, Layers, Link } from 'lucide-react'; + +interface InspectorOverlayProps { + card: CardInstance; + onClose: () => void; +} + +export const InspectorOverlay: React.FC = ({ card, onClose }) => { + // Compute display values + const currentPower = card.power ?? card.basePower ?? 0; + const currentToughness = card.toughness ?? card.baseToughness ?? 0; + + const isPowerModified = currentPower !== (card.basePower ?? 0); + const isToughnessModified = currentToughness !== (card.baseToughness ?? 0); + + const modifiers = useMemo(() => { + // Mocking extraction of text descriptions from modifiers if they existed in client type + // Since client type just has summary, we show what we have + const list = []; + + // Counters + if (card.counters && card.counters.length > 0) { + card.counters.forEach(c => list.push({ type: 'counter', text: `${c.count}x ${c.type} Counter` })); + } + + // P/T Mod + if (card.ptModification && (card.ptModification.power !== 0 || card.ptModification.toughness !== 0)) { + const signP = card.ptModification.power >= 0 ? '+' : ''; + const signT = card.ptModification.toughness >= 0 ? '+' : ''; + list.push({ type: 'effect', text: `Effect Modifier: ${signP}${card.ptModification.power}/${signT}${card.ptModification.toughness}` }); + } + + // Attachments (Auras/Equipment) + // Note: We don't have the list of attached cards ON this card easily in CardInstance alone without scanning all cards. + // For this MVP, we inspect the card itself. + + return list; + }, [card]); + + return ( +
+
+ + {/* Header (Image Bkg) */} +
+ {card.name} +
+ +
+

{card.name}

+
+ {card.typeLine || "Card"} +
+
+
+ + {/* content */} +
+ + {/* Live Stats */} +
+ {/* Power */} +
+
+ Power +
+
+ {currentPower} + {isPowerModified && {card.basePower}} +
+
+ + {/* Toughness */} +
+
+ Toughness +
+
+ {currentToughness} + {isToughnessModified && {card.baseToughness}} +
+
+
+ + {/* Modifiers List */} +
+
+ Active Modifiers +
+ {modifiers.length === 0 ? ( +
+ No active modifiers +
+ ) : ( +
+ {modifiers.map((mod, i) => ( +
+
+ {mod.type === 'counter' ? : } +
+ {mod.text} +
+ ))} +
+ )} +
+ + {/* Oracle Text (Scrollable) */} +
+
Oracle Text
+
+ {card.oracleText?.split('\n').map((line, i) => ( +

{line}

+ )) || No text.} +
+
+ +
+ +
+
+ ); +}; diff --git a/src/client/src/modules/game/MulliganView.tsx b/src/client/src/modules/game/MulliganView.tsx new file mode 100644 index 0000000..b6ee7b6 --- /dev/null +++ b/src/client/src/modules/game/MulliganView.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { CardInstance } from '../../types/game'; +import { CardComponent } from './CardComponent'; + +interface MulliganViewProps { + hand: CardInstance[]; + mulliganCount: number; + onDecision: (keep: boolean, cardsToBottom: string[]) => void; +} + +export const MulliganView: React.FC = ({ hand, mulliganCount, onDecision }) => { + const [selectedToBottom, setSelectedToBottom] = useState>(new Set()); + + const toggleSelection = (cardId: string) => { + const newSet = new Set(selectedToBottom); + if (newSet.has(cardId)) { + newSet.delete(cardId); + } else { + if (newSet.size < mulliganCount) { + newSet.add(cardId); + } + } + setSelectedToBottom(newSet); + }; + + const isSelectionValid = selectedToBottom.size === mulliganCount; + + return ( +
+
+ {mulliganCount === 0 ? "Initial Keep Decision" : `London Mulligan: ${hand.length} Cards`} +
+ + {mulliganCount > 0 ? ( +
+ You have mulliganed {mulliganCount} time{mulliganCount > 1 ? 's' : ''}.
+ Please select {mulliganCount} card{mulliganCount > 1 ? 's' : ''} to put on the bottom of your library. +
+ ) : ( +
+ Do you want to keep this hand? +
+ )} + + {/* Hand Display */} +
+ {hand.map((card, index) => { + const isSelected = selectedToBottom.has(card.instanceId); + return ( +
mulliganCount > 0 && toggleSelection(card.instanceId)} + > + { }} + onClick={() => mulliganCount > 0 && toggleSelection(card.instanceId)} + // Disable normal interactions + onContextMenu={() => { }} + className={isSelected ? 'ring-4 ring-red-500' : ''} + /> + {isSelected && ( +
+
+ BOTTOM +
+
+ )} +
+ ); + })} +
+ + {/* Controls */} +
+ + + +
+
+ ); +}; diff --git a/src/client/src/modules/game/PhaseStrip.tsx b/src/client/src/modules/game/PhaseStrip.tsx index 783f415..14ecd59 100644 --- a/src/client/src/modules/game/PhaseStrip.tsx +++ b/src/client/src/modules/game/PhaseStrip.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { GameState, Phase, Step } from '../../types/game'; -import { Sun, Shield, Swords, ArrowRightToLine, Hourglass } from 'lucide-react'; +import { Sun, Shield, Swords, Hourglass } from 'lucide-react'; interface PhaseStripProps { gameState: GameState; diff --git a/src/client/src/modules/game/RadialMenu.tsx b/src/client/src/modules/game/RadialMenu.tsx new file mode 100644 index 0000000..421a744 --- /dev/null +++ b/src/client/src/modules/game/RadialMenu.tsx @@ -0,0 +1,84 @@ +import React, { } from 'react'; +import { } from 'lucide-react'; + +export interface RadialOption { + id: string; + label: string; + icon?: React.ReactNode; + color?: string; // CSS color string + onSelect: () => void; +} + +interface RadialMenuProps { + options: RadialOption[]; + position: { x: number, y: number }; + onClose: () => void; +} + +export const RadialMenu: React.FC = ({ options, position, onClose }) => { + if (options.length === 0) return null; + + const radius = 60; // Distance from center + const buttonSize = 40; // Diameter of option buttons + + return ( + // Backdrop to close on click outside +
e.preventDefault()} + > +
+ {/* Center close/cancel circle (optional) */} +
+ + {options.map((opt, index) => { + const angle = (index * 360) / options.length; + const radian = (angle - 90) * (Math.PI / 180); // -90 to start at top + const x = Math.cos(radian) * radius; + const y = Math.sin(radian) * radius; + + return ( +
{ + e.stopPropagation(); + opt.onSelect(); + onClose(); + }} + > +
+ {opt.icon || opt.label.substring(0, 2)} +
+ {/* Label tooltip or text below */} +
+ {opt.label} +
+
+ ); + })} +
+
+ ); +}; diff --git a/src/client/src/modules/game/SmartButton.tsx b/src/client/src/modules/game/SmartButton.tsx index 1a32fd5..abdb1f7 100644 --- a/src/client/src/modules/game/SmartButton.tsx +++ b/src/client/src/modules/game/SmartButton.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +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 }) => { +export const SmartButton: React.FC = ({ gameState, playerId, onAction, contextData, isYielding, onYieldToggle }) => { const isMyPriority = gameState.priorityPlayerId === playerId; const isStackEmpty = !gameState.stack || gameState.stack.length === 0; @@ -16,8 +19,23 @@ export const SmartButton: React.FC = ({ gameState, playerId, o let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed"; let actionType: string | null = null; - if (isMyPriority) { - if (isStackEmpty) { + 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') { + 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"; @@ -37,22 +55,66 @@ export const SmartButton: React.FC = ({ gameState, playerId, o } } - const handleClick = () => { - if (actionType) { - onAction('game_strict_action', { type: actionType }); + 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 (