feat: Implement core game engine logic, high-velocity UX, and new UI components including radial menu, inspector overlay, and mulligan view.
This commit is contained in:
@@ -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.
|
- [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.
|
- [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.
|
- [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.
|
||||||
|
|||||||
@@ -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
|
## Objectives
|
||||||
Advanced the High-Velocity UX implementation by introducing the Gesture Engine and refining the backend Rules Engine to support card movement during resolution.
|
- 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
|
## Implementation Details
|
||||||
1. **Gesture Manager**: Implemented `GestureManager.tsx` and integrated it into the Battlefield.
|
|
||||||
- Provides Swipe-to-Tap functionality via pointer tracking and intersection checking.
|
### Backend (Rules Engine)
|
||||||
- Draws a visual SVG path trail for user feedback.
|
- **Mana System**: Added `addMana` method to `RulesEngine` and `manaPool` to `PlayerState`. Implemented `emptyManaPools` logic on step transition.
|
||||||
- Integrated with `CardComponent` via `useGesture` hook to register card DOM elements.
|
- **Combat Logic**: Implemented `declareAttackers` (checking summoning sickness, tapping, setting attacking target) and `declareBlockers` logic.
|
||||||
2. **Stack Visualizer**: Implemented `StackVisualizer.tsx` to render the stack on the right side of the screen, showing strict object ordering.
|
- **Action Handling**: Updated `GameManager` to handle `ADD_MANA` and auto-generate mana when tapping Basic Lands via `TAP_CARD` action (legacy compatibility wrapper).
|
||||||
3. **Backend Rules Engine**:
|
|
||||||
- Updated `RulesEngine.ts` to fully implement `resolveTopStack` and `drawCard`.
|
### Frontend (GameView)
|
||||||
- Added `moveCardToZone` helper to manage state transitions (untapping, resetting position).
|
- **Mana Pool UI**: Added a compact Mana Pool display in the player life area, showing WUBRGC counts.
|
||||||
- Fixed typings and logic flow for resolving spells to graveyard vs battlefield.
|
- **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
|
## Next Steps
|
||||||
- Implement Radial Menu context for activating abilities.
|
- Verify Multiplayer Sync (Socket events are already in place).
|
||||||
- Add sound effects for gestures.
|
- Implement "Blocking" UI (similar to Attacking but for defenders).
|
||||||
- Polish visual transitions for stack resolution.
|
- Implement "Order Blockers" / "Damage Assignment" if strict compliance is enforced (currently simplified to auto-damage).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
# Implementation Plan: MTG Rules Engine & High-Velocity UX
|
||||||
|
|
||||||
@@ -121,26 +126,29 @@ Long-press/Right-click on card.
|
|||||||
|
|
||||||
## Task Breakdown & Status
|
## Task Breakdown & Status
|
||||||
|
|
||||||
### Backend (Rules Engine)
|
### Backend (RulesEngine)
|
||||||
- [x] **Core Structures**: `StrictGameState`, Phase, Step Types.
|
- [x] **Core Structures**: `StrictGameState`, Phase, Step Types.
|
||||||
- [x] **State Machine Baseline**: Phase advancement logic.
|
- [x] **State Machine Baseline**: Phase advancement logic.
|
||||||
- [x] **Priority Logic**: Passing, resolving, resetting.
|
- [x] **Priority Logic**: Passing, resolving, resetting.
|
||||||
- [x] **Basic Actions**: Play Land, Cast Spell.
|
- [x] **Basic Actions**: Play Land, Cast Spell.
|
||||||
- [x] **Stack Resolution**: Resolving Spells to Zones.
|
- [x] **Stack Resolution**: Resolving Spells to Zones.
|
||||||
- [x] **SBAs Implementation**: Basic (Lethal, 0 Toughness, Legend).
|
- [x] **SBAs Implementation**: Basic (Lethal w/ Damage Marking, 0 Toughness, Legend).
|
||||||
- [ ] **Advanced SBAs**: Aura Validity check.
|
- [x] **Advanced SBAs**: Aura Validity check.
|
||||||
- [ ] **Manual Mana Engine**: Floating Pool Logic.
|
- [x] **Manual Mana Engine**: Floating Pool Logic (Backend Support).
|
||||||
- [ ] **Game Setup**: Mulligan (London), Deck Validation.
|
- [x] **Game Setup**: Mulligan (London), Deck Validation.
|
||||||
- [ ] **Combat Phase Detail**: Declare Attackers/Blockers steps & validation.
|
- [x] **Combat Phase Detail**: Declare Attackers/Blockers steps & validation (RulesEngine Logic).
|
||||||
- [ ] **Layer System**: Implement 7-layer P/T calculation.
|
- [x] **Layer System**: Implement 7-layer P/T calculation.
|
||||||
|
- [x] **Token Generation**: Backend `createToken` & Context Menu integration.
|
||||||
|
|
||||||
### Frontend (High-Velocity UX)
|
### Frontend (High-Velocity UX)
|
||||||
- [x] **Game View**: Render State Types.
|
- [x] **Game View**: Render State Types.
|
||||||
- [x] **Phase Strip**: Visual progress.
|
- [x] **Phase Strip**: Visual progress.
|
||||||
- [x] **Smart Button**: Basic States (Green/Orange/Red).
|
- [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.
|
- [x] **Stack Visualization**: Basic Component.
|
||||||
- [ ] **Gesture Polish**: Combat Swipes, Targeting Tether.
|
- [x] **Gesture Polish**: Combat Swipes, Targeting Tether.
|
||||||
- [ ] **Smart Button Advanced**: "Yield" Toggle.
|
- [x] **Manual Mana Engine**: Floating Pool Logic & UI.
|
||||||
- [ ] **Radial Menus**: Pie Menu for Dual Lands/Modes.
|
- [x] **Mulligan UI**: Modal for Keep/Mull decisions.
|
||||||
- [ ] **Inspector Overlay**: Live Math & Details.
|
- [x] **Smart Button Advanced**: "Yield" Toggle.
|
||||||
|
- [x] **Radial Menus**: Pie Menu for Dual Lands/Modes (Component Added).
|
||||||
|
- [x] **Inspector Overlay**: Live Math & Details.
|
||||||
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.afjkvsk6jt"
|
"revision": "0.lcefu74575c"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ interface CardComponentProps {
|
|||||||
onContextMenu?: (cardId: string, e: React.MouseEvent) => void;
|
onContextMenu?: (cardId: string, e: React.MouseEvent) => void;
|
||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
|
onDrop?: (e: React.DragEvent, targetId: string) => void;
|
||||||
|
onDrag?: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd?: (e: React.DragEvent) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => {
|
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className }) => {
|
||||||
const { registerCard, unregisterCard } = useGesture();
|
const { registerCard, unregisterCard } = useGesture();
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -29,6 +33,17 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
|||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, card.instanceId)}
|
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)}
|
onClick={() => onClick(card.instanceId)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
if (onContextMenu) {
|
if (onContextMenu) {
|
||||||
@@ -42,6 +57,7 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
|||||||
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
|
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
|
||||||
${card.tapped ? 'rotate-90' : ''}
|
${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'}
|
${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}
|
style={style}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -185,23 +185,63 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
|||||||
Battlefield
|
Battlefield
|
||||||
</div>
|
</div>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Create Token (1/1)"
|
label="Create Token (1/1 Soldier)"
|
||||||
onClick={() => handleAction('CREATE_TOKEN', {
|
onClick={() => 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 }
|
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Create Token (2/2)"
|
label="Create Token (2/2 Zombie)"
|
||||||
onClick={() => handleAction('CREATE_TOKEN', {
|
onClick={() => 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 }
|
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Add Mana..."
|
||||||
|
onClick={() => handleAction('MANA', { x: request.x, y: request.y })} // Adjusted to use request.x/y as MenuItem's onClick doesn't pass event
|
||||||
|
// icon={<Zap size={14} />} // Zap is not defined in this scope.
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Inspect Details"
|
||||||
|
onClick={() => handleAction('INSPECT', {})}
|
||||||
|
// icon={<Maximize size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Tap / Untap"
|
||||||
|
onClick={() => handleAction('TAP', {})}
|
||||||
|
// icon={<RotateCw size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||||
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Create Treasure"
|
label="Create Treasure"
|
||||||
onClick={() => handleAction('CREATE_TOKEN', {
|
onClick={() => 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 }
|
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { PhaseStrip } from './PhaseStrip';
|
|||||||
import { SmartButton } from './SmartButton';
|
import { SmartButton } from './SmartButton';
|
||||||
import { StackVisualizer } from './StackVisualizer';
|
import { StackVisualizer } from './StackVisualizer';
|
||||||
import { GestureManager } from './GestureManager';
|
import { GestureManager } from './GestureManager';
|
||||||
|
import { MulliganView } from './MulliganView';
|
||||||
|
import { RadialMenu, RadialOption } from './RadialMenu';
|
||||||
|
import { InspectorOverlay } from './InspectorOverlay';
|
||||||
|
|
||||||
interface GameViewProps {
|
interface GameViewProps {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
@@ -16,12 +19,68 @@ interface GameViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
|
export const GameView: React.FC<GameViewProps> = ({ 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<HTMLDivElement>(null);
|
const battlefieldRef = useRef<HTMLDivElement>(null);
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draggedCard, setDraggedCard] = useState<CardInstance | null>(null);
|
||||||
|
const [inspectedCard, setInspectedCard] = useState<CardInstance | null>(null);
|
||||||
|
const [radialOptions, setRadialOptions] = useState<RadialOption[] | null>(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<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);
|
||||||
|
|
||||||
|
// 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<Set<string>>(new Set());
|
||||||
|
const [proposedBlockers, setProposedBlockers] = useState<Map<string, string>>(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 ---
|
// --- Sidebar State ---
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('game_sidebarCollapsed') === 'true';
|
return localStorage.getItem('game_sidebarCollapsed') === 'true';
|
||||||
@@ -97,7 +156,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
return () => document.removeEventListener('contextmenu', handleContext);
|
return () => document.removeEventListener('contextmenu', handleContext);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ... (handlers remain the same) ...
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => {
|
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -115,6 +173,33 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuAction = (actionType: string, payload: any) => {
|
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') {
|
if (actionType === 'VIEW_ZONE') {
|
||||||
setViewingZone(payload.zone);
|
setViewingZone(payload.zone);
|
||||||
@@ -141,33 +226,98 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent, zone: CardInstance['zone']) => {
|
const activeCardDropRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleZoneDrop = (e: React.DragEvent, zone: CardInstance['zone']) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cardId = e.dataTransfer.getData('cardId');
|
const cardId = e.dataTransfer.getData('cardId');
|
||||||
if (!cardId) return;
|
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 = {
|
const action: any = {
|
||||||
type: 'MOVE_CARD',
|
type: 'MOVE_CARD',
|
||||||
cardId,
|
cardId,
|
||||||
toZone: zone
|
toZone: zone
|
||||||
};
|
};
|
||||||
|
socketService.socket.emit('game_action', { action });
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate position if dropped on battlefield
|
const handleCardDrop = (e: React.DragEvent, targetCardId: string) => {
|
||||||
if (zone === 'battlefield' && battlefieldRef.current) {
|
e.preventDefault();
|
||||||
const rect = battlefieldRef.current.getBoundingClientRect();
|
e.stopPropagation();
|
||||||
// Calculate relative position (0-100%)
|
const cardId = e.dataTransfer.getData('cardId');
|
||||||
// We clamp values to keep cards somewhat within bounds (0-90 to account for card width)
|
if (!cardId || cardId === targetCardId) return;
|
||||||
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 sourceCard = gameState.cards[cardId];
|
||||||
const y = Math.max(0, Math.min(85, rawY)); // 85 to ensure bottom of card isn't cut off too much
|
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', {
|
// Default: Assume Cast Spell with Target (if from Hand)
|
||||||
action
|
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<GameViewProps> = ({ 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 myPlayer = gameState.players[currentPlayerId];
|
||||||
const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId);
|
const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId);
|
||||||
const opponent = opponentId ? gameState.players[opponentId] : null;
|
const opponent = opponentId ? gameState.players[opponentId] : null;
|
||||||
@@ -225,6 +424,60 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Targeting Tether Overlay */}
|
||||||
|
{tether && (
|
||||||
|
<svg className="absolute inset-0 pointer-events-none z-[100] overflow-visible w-full h-full">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill={gameState.step === 'declare_blockers' ? '#3b82f6' : '#22d3ee'} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d={`M ${tether.startX} ${tether.startY} Q ${(tether.startX + tether.currentX) / 2} ${Math.min(tether.startY, tether.currentY) - 50} ${tether.currentX} ${tether.currentY}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={gameState.step === 'declare_blockers' ? '#3b82f6' : '#22d3ee'}
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeDasharray="10,5"
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
className="drop-shadow-[0_0_10px_rgba(34,211,238,0.8)] animate-pulse"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mulligan Overlay */}
|
||||||
|
{gameState.step === 'mulligan' && !myPlayer?.handKept && (
|
||||||
|
<MulliganView
|
||||||
|
hand={myHand}
|
||||||
|
mulliganCount={myPlayer?.mulliganCount || 0}
|
||||||
|
onDecision={(keep, cardsToBottom) => {
|
||||||
|
socketService.socket.emit('game_strict_action', {
|
||||||
|
action: {
|
||||||
|
type: 'MULLIGAN_DECISION',
|
||||||
|
keep,
|
||||||
|
cardsToBottom
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inspector Overlay */}
|
||||||
|
{inspectedCard && (
|
||||||
|
<InspectorOverlay
|
||||||
|
card={inspectedCard}
|
||||||
|
onClose={() => setInspectedCard(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Radial Menu (Mana Ability Demo) */}
|
||||||
|
{radialOptions && (
|
||||||
|
<RadialMenu
|
||||||
|
options={radialOptions}
|
||||||
|
position={radialPosition}
|
||||||
|
onClose={() => setRadialOptions(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoom Sidebar */}
|
{/* Zoom Sidebar */}
|
||||||
{isSidebarCollapsed ? (
|
{isSidebarCollapsed ? (
|
||||||
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300">
|
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300">
|
||||||
@@ -346,7 +599,11 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Opponent Info Bar */}
|
{/* Opponent Info Bar */}
|
||||||
<div className="absolute top-4 left-4 z-10 flex items-center space-x-4 pointer-events-auto bg-black/50 p-2 rounded-lg backdrop-blur-sm border border-slate-700">
|
<div
|
||||||
|
className="absolute top-4 left-4 z-10 flex items-center space-x-4 pointer-events-auto bg-black/50 p-2 rounded-lg backdrop-blur-sm border border-slate-700"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => opponent && handlePlayerDrop(e, opponent.id)}
|
||||||
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
|
<span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
|
||||||
<div className="flex gap-2 text-xs text-slate-400">
|
<div className="flex gap-2 text-xs text-slate-400">
|
||||||
@@ -368,7 +625,11 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
transformOrigin: 'center bottom',
|
transformOrigin: 'center bottom',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{oppBattlefield.map(card => (
|
{oppBattlefield.map(card => {
|
||||||
|
const isAttacking = card.attacking === currentPlayerId; // They are attacking ME
|
||||||
|
const isBlockedByMe = Array.from(proposedBlockers.values()).includes(card.instanceId);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.instanceId}
|
key={card.instanceId}
|
||||||
className="absolute transition-all duration-300 ease-out"
|
className="absolute transition-all duration-300 ease-out"
|
||||||
@@ -376,17 +637,29 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
left: `${card.position?.x || 50}%`,
|
left: `${card.position?.x || 50}%`,
|
||||||
top: `${card.position?.y || 50}%`,
|
top: `${card.position?.y || 50}%`,
|
||||||
zIndex: Math.floor((card.position?.y || 0)),
|
zIndex: Math.floor((card.position?.y || 0)),
|
||||||
|
transform: isAttacking ? 'translateY(40px) scale(1.1)' : 'none' // Move towards me
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardComponent
|
<CardComponent
|
||||||
card={card}
|
card={card}
|
||||||
onDragStart={() => { }}
|
onDragStart={() => { }}
|
||||||
|
onDrop={(e, id) => handleCardDrop(e, id)} // Allow dropping onto opponent card
|
||||||
onClick={() => { }}
|
onClick={() => { }}
|
||||||
onMouseEnter={() => setHoveredCard(card)}
|
onMouseEnter={() => setHoveredCard(card)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
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 && (
|
||||||
|
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-red-600 text-white text-[10px] font-bold px-2 py-0.5 rounded shadow">
|
||||||
|
ATTACKING
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,9 +669,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
className="flex-[4] relative perspective-1000 z-10"
|
className="flex-[4] relative perspective-1000 z-10"
|
||||||
ref={battlefieldRef}
|
ref={battlefieldRef}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, 'battlefield')}
|
onDrop={(e) => handleZoneDrop(e, 'battlefield')}
|
||||||
>
|
>
|
||||||
<GestureManager>
|
<GestureManager onGesture={handleGesture}>
|
||||||
<div
|
<div
|
||||||
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
|
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
|
||||||
style={{
|
style={{
|
||||||
@@ -410,28 +683,65 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
{/* Battlefield Texture/Grid */}
|
{/* Battlefield Texture/Grid */}
|
||||||
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
|
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
|
||||||
|
|
||||||
{myBattlefield.map(card => (
|
{myBattlefield.map(card => {
|
||||||
|
const isAttacking = proposedAttackers.has(card.instanceId);
|
||||||
|
const blockingTargetId = proposedBlockers.get(card.instanceId);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.instanceId}
|
key={card.instanceId}
|
||||||
className="absolute transition-all duration-200"
|
className="absolute transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
left: `${card.position?.x || Math.random() * 80}%`,
|
left: `${card.position?.x || Math.random() * 80}%`,
|
||||||
top: `${card.position?.y || Math.random() * 80}%`,
|
top: `${card.position?.y || Math.random() * 80}%`,
|
||||||
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
|
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
|
||||||
|
// Visual feedback for attacking OR blocking
|
||||||
|
transform: isAttacking
|
||||||
|
? 'translateY(-40px) scale(1.1) rotateX(10deg)'
|
||||||
|
: blockingTargetId
|
||||||
|
? 'translateY(-20px) scale(1.05)'
|
||||||
|
: 'none',
|
||||||
|
boxShadow: isAttacking ? '0 20px 40px -10px rgba(239, 68, 68, 0.5)' : 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardComponent
|
<CardComponent
|
||||||
card={card}
|
card={card}
|
||||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
onDragStart={(e, id) => handleDragStart(e, id)}
|
||||||
onClick={toggleTap}
|
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) => {
|
onContextMenu={(id, e) => {
|
||||||
handleContextMenu(e, 'card', id);
|
handleContextMenu(e, 'card', id);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHoveredCard(card)}
|
onMouseEnter={() => setHoveredCard(card)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
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 && (
|
||||||
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-[10px] uppercase font-bold px-2 py-0.5 rounded shadow z-50 whitespace-nowrap">
|
||||||
|
Blocking
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{myBattlefield.length === 0 && (
|
{myBattlefield.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
@@ -468,7 +778,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
<div
|
<div
|
||||||
className="w-12 h-16 border-2 border-dashed border-slate-600 rounded flex items-center justify-center transition-colors hover:border-slate-400 hover:bg-white/5"
|
className="w-12 h-16 border-2 border-dashed border-slate-600 rounded flex items-center justify-center transition-colors hover:border-slate-400 hover:bg-white/5"
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, 'graveyard')}
|
onDrop={(e) => handleZoneDrop(e, 'graveyard')}
|
||||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
|
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -483,7 +793,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
<div
|
<div
|
||||||
className="flex-1 relative flex flex-col items-center justify-end px-4 pb-2"
|
className="flex-1 relative flex flex-col items-center justify-end px-4 pb-2"
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, 'hand')}
|
onDrop={(e) => handleZoneDrop(e, 'hand')}
|
||||||
>
|
>
|
||||||
{/* Smart Button Floating above Hand */}
|
{/* Smart Button Floating above Hand */}
|
||||||
<div className="mb-4 z-40">
|
<div className="mb-4 z-40">
|
||||||
@@ -491,6 +801,12 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
playerId={currentPlayerId}
|
playerId={currentPlayerId}
|
||||||
onAction={(type, payload) => socketService.socket.emit(type, { action: payload })}
|
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)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,7 +822,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
>
|
>
|
||||||
<CardComponent
|
<CardComponent
|
||||||
card={card}
|
card={card}
|
||||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
onDragStart={(e, id) => handleDragStart(e, id)}
|
||||||
|
onDrag={handleDrag}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
onClick={toggleTap}
|
onClick={toggleTap}
|
||||||
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
|
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
|
||||||
style={{ transformOrigin: 'bottom center' }}
|
style={{ transformOrigin: 'bottom center' }}
|
||||||
@@ -541,10 +859,30 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mana Pool Display */}
|
||||||
|
<div className="w-full bg-slate-800/50 rounded-lg p-2 flex flex-wrap justify-between gap-1 border border-white/5">
|
||||||
|
{['W', 'U', 'B', 'R', 'G', 'C'].map(color => {
|
||||||
|
const count = myPlayer?.manaPool?.[color] || 0;
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
W: '☀️', U: '💧', B: '💀', R: '🔥', G: '🌳', C: '💎'
|
||||||
|
};
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div key={color} className={`flex flex-col items-center w-[30%] ${count > 0 ? 'opacity-100 scale-110 font-bold' : 'opacity-30'} transition-all`}>
|
||||||
|
<div className={`text-xs ${colors[color]}`}>{icons[color]}</div>
|
||||||
|
<div className="text-sm font-mono">{count}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1"
|
className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1"
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, 'exile')}
|
onDrop={(e) => handleZoneDrop(e, 'exile')}
|
||||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')}
|
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')}
|
||||||
>
|
>
|
||||||
<span className="text-xs text-slate-500 block">Exile Drop Zone</span>
|
<span className="text-xs text-slate-500 block">Exile Drop Zone</span>
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export const useGesture = () => useContext(GestureContext);
|
|||||||
|
|
||||||
interface GestureManagerProps {
|
interface GestureManagerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
onGesture?: (type: 'TAP' | 'ATTACK' | 'CANCEL', cardIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GestureManager: React.FC<GestureManagerProps> = ({ children }) => {
|
export const GestureManager: React.FC<GestureManagerProps> = ({ children, onGesture }) => {
|
||||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map());
|
const cardRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]);
|
const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]);
|
||||||
const isGesturing = useRef(false);
|
const isGesturing = useRef(false);
|
||||||
const startPoint = useRef<{ x: number, y: number } | null>(null);
|
|
||||||
|
|
||||||
const registerCard = (id: string, element: HTMLElement) => {
|
const registerCard = (id: string, element: HTMLElement) => {
|
||||||
cardRefs.current.set(id, element);
|
cardRefs.current.set(id, element);
|
||||||
@@ -46,7 +46,6 @@ export const GestureManager: React.FC<GestureManagerProps> = ({ children }) => {
|
|||||||
// Assuming GameView wrapper catches this.
|
// Assuming GameView wrapper catches this.
|
||||||
|
|
||||||
isGesturing.current = true;
|
isGesturing.current = true;
|
||||||
startPoint.current = { x: e.clientX, y: e.clientY };
|
|
||||||
setGesturePath([{ x: e.clientX, y: e.clientY }]);
|
setGesturePath([{ x: e.clientX, y: e.clientY }]);
|
||||||
|
|
||||||
// Capture pointer
|
// Capture pointer
|
||||||
@@ -65,44 +64,60 @@ export const GestureManager: React.FC<GestureManagerProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Analyze Path for "Slash" (Swipe to Tap)
|
// Analyze Path for "Slash" (Swipe to Tap)
|
||||||
// Check intersection with cards
|
// Check intersection with cards
|
||||||
handleSwipeToTap();
|
analyzeGesture(gesturePath);
|
||||||
|
|
||||||
setGesturePath([]);
|
setGesturePath([]);
|
||||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwipeToTap = () => {
|
const analyzeGesture = (path: { x: number, y: number }[]) => {
|
||||||
// Bounding box of path?
|
if (path.length < 5) return; // Too short
|
||||||
// Simple: Check which cards intersect with the path line segments.
|
|
||||||
// Optimization: Just check if path points are inside card rects.
|
|
||||||
|
|
||||||
|
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<string>();
|
const intersectedCards = new Set<string>();
|
||||||
|
|
||||||
const path = gesturePath;
|
// Bounding Box Optimization
|
||||||
if (path.length < 2) return; // Too short
|
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) => {
|
cardRefs.current.forEach((el, id) => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
// Simple hit test: Does any point in path fall in rect?
|
// Rough Intersection of Line Segment
|
||||||
// Better: Line intersection.
|
// Check if rect intersects with bbox of path first
|
||||||
// For MVP: Check points.
|
if (rect.right < minX || rect.left > maxX || rect.bottom < minY || rect.top > maxY) return;
|
||||||
for (const p of path) {
|
|
||||||
|
// 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) {
|
if (p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom) {
|
||||||
intersectedCards.add(id);
|
intersectedCards.add(id);
|
||||||
break; // Found hit
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we hit cards, toggle tap
|
if (intersectedCards.size > 0 && onGesture) {
|
||||||
if (intersectedCards.size > 0) {
|
onGesture(gestureType, Array.from(intersectedCards));
|
||||||
intersectedCards.forEach(id => {
|
|
||||||
socketService.socket.emit('game_action', {
|
|
||||||
action: { type: 'TAP_CARD', cardId: id }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
130
src/client/src/modules/game/InspectorOverlay.tsx
Normal file
130
src/client/src/modules/game/InspectorOverlay.tsx
Normal file
@@ -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<InspectorOverlayProps> = ({ 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 (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-sm w-full overflow-hidden flex flex-col">
|
||||||
|
|
||||||
|
{/* Header (Image Bkg) */}
|
||||||
|
<div className="relative h-32 bg-slate-800">
|
||||||
|
<img src={card.imageUrl} alt={card.name} className="w-full h-full object-cover opacity-50 mask-image-b-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900 to-transparent" />
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-2 left-4 right-4">
|
||||||
|
<h2 className="text-xl font-bold text-white truncate drop-shadow-md">{card.name}</h2>
|
||||||
|
<div className="text-xs text-slate-300 flex items-center gap-2">
|
||||||
|
<span className="bg-slate-800/80 px-2 py-0.5 rounded border border-slate-600">{card.typeLine || "Card"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
|
||||||
|
{/* Live Stats */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Power */}
|
||||||
|
<div className={`flex-1 bg-slate-800 rounded-lg p-3 flex flex-col items-center border ${isPowerModified ? 'border-amber-500/50 bg-amber-500/10' : 'border-slate-700'}`}>
|
||||||
|
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||||
|
<Sword size={12} /> Power
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white flex items-baseline gap-1">
|
||||||
|
{currentPower}
|
||||||
|
{isPowerModified && <span className="text-xs text-amber-500 font-normal line-through opacity-70">{card.basePower}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toughness */}
|
||||||
|
<div className={`flex-1 bg-slate-800 rounded-lg p-3 flex flex-col items-center border ${isToughnessModified ? 'border-blue-500/50 bg-blue-500/10' : 'border-slate-700'}`}>
|
||||||
|
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||||
|
<Shield size={12} /> Toughness
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white flex items-baseline gap-1">
|
||||||
|
{currentToughness}
|
||||||
|
{isToughnessModified && <span className="text-xs text-blue-400 font-normal line-through opacity-70">{card.baseToughness}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modifiers List */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||||
|
<Layers size={12} /> Active Modifiers
|
||||||
|
</div>
|
||||||
|
{modifiers.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-600 italic text-center py-2 h-20 flex items-center justify-center bg-slate-800/50 rounded">
|
||||||
|
No active modifiers
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{modifiers.map((mod, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 bg-slate-800 p-2 rounded border border-slate-700">
|
||||||
|
<div className={`p-1.5 rounded-full ${mod.type === 'counter' ? 'bg-purple-500/20 text-purple-400' : 'bg-emerald-500/20 text-emerald-400'}`}>
|
||||||
|
{mod.type === 'counter' ? <Zap size={12} /> : <Link size={12} />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-200">{mod.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Oracle Text (Scrollable) */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">Oracle Text</div>
|
||||||
|
<div className="text-sm text-slate-300 leading-relaxed max-h-32 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{card.oracleText?.split('\n').map((line, i) => (
|
||||||
|
<p key={i} className="mb-1 last:mb-0">{line}</p>
|
||||||
|
)) || <span className="italic text-slate-600">No text.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
src/client/src/modules/game/MulliganView.tsx
Normal file
105
src/client/src/modules/game/MulliganView.tsx
Normal file
@@ -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<MulliganViewProps> = ({ hand, mulliganCount, onDecision }) => {
|
||||||
|
const [selectedToBottom, setSelectedToBottom] = useState<Set<string>>(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 (
|
||||||
|
<div className="absolute inset-0 z-[100] bg-black/90 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||||
|
<div className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-br from-purple-400 to-pink-600 mb-8 drop-shadow-lg">
|
||||||
|
{mulliganCount === 0 ? "Initial Keep Decision" : `London Mulligan: ${hand.length} Cards`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mulliganCount > 0 ? (
|
||||||
|
<div className="text-xl text-slate-300 mb-8 max-w-2xl text-center">
|
||||||
|
You have mulliganed <strong>{mulliganCount}</strong> time{mulliganCount > 1 ? 's' : ''}.<br />
|
||||||
|
Please select <span className="text-red-400 font-bold">{mulliganCount}</span> card{mulliganCount > 1 ? 's' : ''} to put on the bottom of your library.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xl text-slate-300 mb-8">
|
||||||
|
Do you want to keep this hand?
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hand Display */}
|
||||||
|
<div className="flex justify-center -space-x-4 mb-12 perspective-1000">
|
||||||
|
{hand.map((card, index) => {
|
||||||
|
const isSelected = selectedToBottom.has(card.instanceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.instanceId}
|
||||||
|
className={`relative transition-all duration-300 cursor-pointer ${isSelected ? 'translate-y-12 opacity-50 grayscale scale-90' : 'hover:-translate-y-4 hover:scale-105 hover:z-50'
|
||||||
|
}`}
|
||||||
|
style={{ zIndex: isSelected ? 0 : 10 + index }}
|
||||||
|
onClick={() => mulliganCount > 0 && toggleSelection(card.instanceId)}
|
||||||
|
>
|
||||||
|
<CardComponent
|
||||||
|
card={card}
|
||||||
|
onDragStart={() => { }}
|
||||||
|
onClick={() => mulliganCount > 0 && toggleSelection(card.instanceId)}
|
||||||
|
// Disable normal interactions
|
||||||
|
onContextMenu={() => { }}
|
||||||
|
className={isSelected ? 'ring-4 ring-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="bg-red-600 text-white font-bold px-2 py-1 rounded shadow-lg text-xs transform rotate-[-15deg]">
|
||||||
|
BOTTOM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button
|
||||||
|
onClick={() => onDecision(false, [])}
|
||||||
|
className="px-8 py-4 bg-red-600/20 hover:bg-red-600/40 border border-red-500 text-red-100 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 group"
|
||||||
|
>
|
||||||
|
<span>Mulligan</span>
|
||||||
|
<span className="text-xs text-red-400 group-hover:text-red-200">Draw {hand.length > 0 ? 7 : 7} New Cards</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => isSelectionValid && onDecision(true, Array.from(selectedToBottom))}
|
||||||
|
disabled={!isSelectionValid}
|
||||||
|
className={`px-8 py-4 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 min-w-[200px] ${isSelectionValid
|
||||||
|
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-slate-800 text-slate-500 border border-slate-700 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Keep Hand</span>
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
{mulliganCount > 0
|
||||||
|
? `${selectedToBottom.size}/${mulliganCount} Selected`
|
||||||
|
: 'Start Game'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GameState, Phase, Step } from '../../types/game';
|
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 {
|
interface PhaseStripProps {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
|
|||||||
84
src/client/src/modules/game/RadialMenu.tsx
Normal file
84
src/client/src/modules/game/RadialMenu.tsx
Normal file
@@ -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<RadialMenuProps> = ({ 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
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[150] touch-none select-none"
|
||||||
|
onClick={onClose}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
transform: 'translate(-50%, -50%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Center close/cancel circle (optional) */}
|
||||||
|
<div className="absolute inset-0 w-8 h-8 -translate-x-1/2 -translate-y-1/2 bg-black/50 rounded-full backdrop-blur-sm pointer-events-none" />
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={opt.id}
|
||||||
|
className="absolute flex flex-col items-center justify-center cursor-pointer transition-transform hover:scale-110 active:scale-95 animate-in zoom-in duration-200"
|
||||||
|
style={{
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize,
|
||||||
|
transform: 'translate(-50%, -50%)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
opt.onSelect();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-full h-full rounded-full shadow-lg border-2 border-white/20 flex items-center justify-center text-white font-bold
|
||||||
|
${opt.color ? '' : 'bg-slate-700'}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: opt.color }}
|
||||||
|
>
|
||||||
|
{opt.icon || opt.label.substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
{/* Label tooltip or text below */}
|
||||||
|
<div className="absolute top-full mt-1 bg-black/80 px-1.5 py-0.5 rounded text-[10px] text-white whitespace-nowrap opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { GameState } from '../../types/game';
|
import { GameState } from '../../types/game';
|
||||||
|
|
||||||
interface SmartButtonProps {
|
interface SmartButtonProps {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
onAction: (type: string, payload?: any) => void;
|
onAction: (type: string, payload?: any) => void;
|
||||||
|
contextData?: any;
|
||||||
|
isYielding?: boolean;
|
||||||
|
onYieldToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction }) => {
|
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction, contextData, isYielding, onYieldToggle }) => {
|
||||||
const isMyPriority = gameState.priorityPlayerId === playerId;
|
const isMyPriority = gameState.priorityPlayerId === playerId;
|
||||||
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
||||||
|
|
||||||
@@ -16,8 +19,23 @@ export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, o
|
|||||||
let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed";
|
let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed";
|
||||||
let actionType: string | null = null;
|
let actionType: string | null = null;
|
||||||
|
|
||||||
if (isMyPriority) {
|
if (isYielding) {
|
||||||
if (isStackEmpty) {
|
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
|
// Pass Priority / Advance Step
|
||||||
// If Main Phase, could technically play land/cast, but button defaults to Pass
|
// If Main Phase, could technically play land/cast, but button defaults to Pass
|
||||||
label = "Pass Turn/Phase";
|
label = "Pass Turn/Phase";
|
||||||
@@ -37,22 +55,66 @@ export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = () => {
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
if (actionType) {
|
const isLongPress = useRef(false);
|
||||||
onAction('game_strict_action', { type: actionType });
|
|
||||||
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onPointerDown={handlePointerDown}
|
||||||
disabled={!isMyPriority}
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={() => { if (timerRef.current) clearTimeout(timerRef.current); }}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
disabled={!isMyPriority && !isYielding}
|
||||||
className={`
|
className={`
|
||||||
px-6 py-3 rounded-xl font-bold text-lg uppercase tracking-wider transition-all duration-300
|
px-6 py-3 rounded-xl font-bold text-lg uppercase tracking-wider transition-all duration-300
|
||||||
${colorClass}
|
${colorClass}
|
||||||
border border-white/10
|
border border-white/10
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
min-w-[200px]
|
min-w-[200px] select-none
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StackObject, GameState } from '../../types/game';
|
import { GameState } from '../../types/game';
|
||||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface StackVisualizerProps {
|
interface StackVisualizerProps {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
onResolve?: () => void; // Optional fast-action helper
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StackVisualizer: React.FC<StackVisualizerProps> = ({ gameState, onResolve }) => {
|
export const StackVisualizer: React.FC<StackVisualizerProps> = ({ gameState }) => {
|
||||||
const stack = gameState.stack || [];
|
const stack = gameState.stack || [];
|
||||||
|
|
||||||
if (stack.length === 0) return null;
|
if (stack.length === 0) return null;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
export type Phase = 'setup' | 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
||||||
|
|
||||||
export type Step =
|
export type Step =
|
||||||
|
| 'mulligan'
|
||||||
| 'untap' | 'upkeep' | 'draw'
|
| 'untap' | 'upkeep' | 'draw'
|
||||||
| 'main'
|
| 'main'
|
||||||
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
|
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
|
||||||
@@ -27,9 +28,16 @@ export interface CardInstance {
|
|||||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
|
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
|
||||||
tapped: boolean;
|
tapped: boolean;
|
||||||
faceDown: boolean;
|
faceDown: boolean;
|
||||||
position: { x: number; y: number; z: number }; // For freeform placement
|
attacking?: string; // Player/Planeswalker ID
|
||||||
|
blocking?: string[]; // List of attacker IDs blocked by this card
|
||||||
|
attachedTo?: string; // ID of card/player this aura/equipment is attached to
|
||||||
counters: { type: string; count: number }[];
|
counters: { type: string; count: number }[];
|
||||||
ptModification: { power: number; toughness: number };
|
ptModification: { power: number; toughness: number };
|
||||||
|
power?: number; // Current Calculated Power
|
||||||
|
toughness?: number; // Current Calculated Toughness
|
||||||
|
basePower?: number; // Base Power
|
||||||
|
baseToughness?: number; // Base Toughness
|
||||||
|
position: { x: number; y: number; z: number }; // For freeform placement
|
||||||
typeLine?: string;
|
typeLine?: string;
|
||||||
oracleText?: string;
|
oracleText?: string;
|
||||||
manaCost?: string;
|
manaCost?: string;
|
||||||
@@ -43,6 +51,9 @@ export interface PlayerState {
|
|||||||
energy: number;
|
energy: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
hasPassed?: boolean;
|
hasPassed?: boolean;
|
||||||
|
manaPool?: Record<string, number>;
|
||||||
|
handKept?: boolean;
|
||||||
|
mulliganCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class RulesEngine {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public playLand(playerId: string, cardId: string): boolean {
|
public playLand(playerId: string, cardId: string, position?: { x: number, y: number }): boolean {
|
||||||
// 1. Check Priority
|
// 1. Check Priority
|
||||||
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||||
|
|
||||||
@@ -46,9 +46,10 @@ export class RulesEngine {
|
|||||||
const card = this.state.cards[cardId];
|
const card = this.state.cards[cardId];
|
||||||
if (!card || card.controllerId !== playerId || card.zone !== 'hand') throw new Error("Invalid card.");
|
if (!card || card.controllerId !== playerId || card.zone !== 'hand') throw new Error("Invalid card.");
|
||||||
|
|
||||||
// TODO: Verify it IS a land (need Type system)
|
// Verify it IS a land
|
||||||
|
if (!card.typeLine?.includes('Land') && !card.types.includes('Land')) throw new Error("Not a land card.");
|
||||||
|
|
||||||
this.moveCardToZone(card.instanceId, 'battlefield');
|
this.moveCardToZone(card.instanceId, 'battlefield', false, position);
|
||||||
this.state.landsPlayedThisTurn++;
|
this.state.landsPlayedThisTurn++;
|
||||||
|
|
||||||
// Playing a land does NOT use the stack, but priority remains with AP?
|
// Playing a land does NOT use the stack, but priority remains with AP?
|
||||||
@@ -59,7 +60,7 @@ export class RulesEngine {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public castSpell(playerId: string, cardId: string, targets: string[] = []) {
|
public castSpell(playerId: string, cardId: string, targets: string[] = [], position?: { x: number, y: number }) {
|
||||||
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
if (this.state.priorityPlayerId !== playerId) throw new Error("Not your priority.");
|
||||||
|
|
||||||
const card = this.state.cards[cardId];
|
const card = this.state.cards[cardId];
|
||||||
@@ -76,8 +77,9 @@ export class RulesEngine {
|
|||||||
controllerId: playerId,
|
controllerId: playerId,
|
||||||
type: 'spell', // or permanent-spell
|
type: 'spell', // or permanent-spell
|
||||||
name: card.name,
|
name: card.name,
|
||||||
text: "Spell Text...", // TODO: get rules text
|
text: card.oracleText || "",
|
||||||
targets
|
targets,
|
||||||
|
resolutionPosition: position
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset priority to caster (Rule 117.3c)
|
// Reset priority to caster (Rule 117.3c)
|
||||||
@@ -85,6 +87,185 @@ export class RulesEngine {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addMana(playerId: string, mana: { color: string, amount: number }) {
|
||||||
|
// Check if player has priority or if checking for mana abilities?
|
||||||
|
// 605.3a: Player may activate mana ability whenever they have priority... or when rule/effect asks for mana payment.
|
||||||
|
// For manual engine, we assume priority or loose check.
|
||||||
|
|
||||||
|
// Validate Color
|
||||||
|
const validColors = ['W', 'U', 'B', 'R', 'G', 'C'];
|
||||||
|
if (!validColors.includes(mana.color)) throw new Error("Invalid mana color.");
|
||||||
|
|
||||||
|
const player = this.state.players[playerId];
|
||||||
|
if (!player) throw new Error("Invalid player.");
|
||||||
|
|
||||||
|
if (!player.manaPool) player.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
|
||||||
|
|
||||||
|
player.manaPool[mana.color] = (player.manaPool[mana.color] || 0) + mana.amount;
|
||||||
|
|
||||||
|
console.log(`Player ${playerId} added ${mana.amount}${mana.color} to pool.`, player.manaPool);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public declareAttackers(playerId: string, attackers: { attackerId: string, targetId: string }[]) {
|
||||||
|
// 508.1. Declare Attackers Step
|
||||||
|
if (this.state.phase !== 'combat' || this.state.step !== 'declare_attackers') throw new Error("Not Declare Attackers step.");
|
||||||
|
if (this.state.activePlayerId !== playerId) throw new Error("Only Active Player can declare attackers.");
|
||||||
|
|
||||||
|
// Validate and Process
|
||||||
|
attackers.forEach(({ attackerId, targetId }) => {
|
||||||
|
const card = this.state.cards[attackerId];
|
||||||
|
if (!card || card.controllerId !== playerId || card.zone !== 'battlefield') throw new Error(`Invalid attacker ${attackerId}`);
|
||||||
|
if (!card.types.includes('Creature')) throw new Error(`${card.name} is not a creature.`);
|
||||||
|
|
||||||
|
// Summoning Sickness
|
||||||
|
const hasHaste = card.keywords.includes('Haste'); // Simple string check
|
||||||
|
if (card.controlledSinceTurn === this.state.turnCount && !hasHaste) {
|
||||||
|
throw new Error(`${card.name} has Summoning Sickness.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap if not Vigilance
|
||||||
|
const hasVigilance = card.keywords.includes('Vigilance');
|
||||||
|
if (card.tapped && !hasVigilance) throw new Error(`${card.name} is tapped.`);
|
||||||
|
|
||||||
|
if (!hasVigilance) {
|
||||||
|
card.tapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.attacking = targetId;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Player ${playerId} declared ${attackers.length} attackers.`);
|
||||||
|
|
||||||
|
// 508.2. Active Player gets priority
|
||||||
|
// But usually passing happens immediately after declaration in digital?
|
||||||
|
// We will reset priority to AP.
|
||||||
|
this.resetPriority(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public declareBlockers(playerId: string, blockers: { blockerId: string, attackerId: string }[]) {
|
||||||
|
if (this.state.phase !== 'combat' || this.state.step !== 'declare_blockers') throw new Error("Not Declare Blockers step.");
|
||||||
|
if (this.state.activePlayerId === playerId) throw new Error("Active Player cannot declare blockers.");
|
||||||
|
|
||||||
|
blockers.forEach(({ blockerId, attackerId }) => {
|
||||||
|
const blocker = this.state.cards[blockerId];
|
||||||
|
const attacker = this.state.cards[attackerId];
|
||||||
|
|
||||||
|
if (!blocker || blocker.controllerId !== playerId || blocker.zone !== 'battlefield') throw new Error(`Invalid blocker ${blockerId}`);
|
||||||
|
if (blocker.tapped) throw new Error(`${blocker.name} is tapped.`);
|
||||||
|
|
||||||
|
if (!attacker || !attacker.attacking) throw new Error(`Invalid attacker target ${attackerId}`);
|
||||||
|
|
||||||
|
if (!blocker.blocking) blocker.blocking = [];
|
||||||
|
blocker.blocking.push(attackerId);
|
||||||
|
|
||||||
|
// Note: 509.2. Damage Assignment Order (if multiple blockers)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Player ${playerId} declared ${blockers.length} blockers.`);
|
||||||
|
|
||||||
|
// Priority goes to Active Player first after blockers declared
|
||||||
|
this.resetPriority(this.state.activePlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveMulligan(playerId: string, keep: boolean, cardsToBottom: string[] = []) {
|
||||||
|
if (this.state.step !== 'mulligan') throw new Error("Not mulligan step");
|
||||||
|
|
||||||
|
const player = this.state.players[playerId];
|
||||||
|
if (player.handKept) throw new Error("Already kept hand");
|
||||||
|
|
||||||
|
if (keep) {
|
||||||
|
// Validate Cards to Bottom
|
||||||
|
// London Mulligan: Draw 7, put X on bottom. X = mulliganCount.
|
||||||
|
const currentMulls = player.mulliganCount || 0;
|
||||||
|
if (cardsToBottom.length !== currentMulls) {
|
||||||
|
throw new Error(`Must put ${currentMulls} cards to bottom.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move cards to library bottom
|
||||||
|
cardsToBottom.forEach(cid => {
|
||||||
|
const c = this.state.cards[cid];
|
||||||
|
if (c && c.ownerId === playerId && c.zone === 'hand') {
|
||||||
|
// Move to library
|
||||||
|
// We don't have explicit "bottom", just library?
|
||||||
|
// In random fetch, it doesn't matter. But strictly...
|
||||||
|
// Let's just put them in 'library' zone.
|
||||||
|
this.moveCardToZone(cid, 'library');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
player.handKept = true;
|
||||||
|
console.log(`Player ${playerId} kept hand with ${cardsToBottom.length} on bottom.`);
|
||||||
|
|
||||||
|
// Trigger check
|
||||||
|
this.performTurnBasedActions();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Take Mulligan
|
||||||
|
// 1. Hand -> Library
|
||||||
|
const hand = Object.values(this.state.cards).filter(c => c.ownerId === playerId && c.zone === 'hand');
|
||||||
|
hand.forEach(c => this.moveCardToZone(c.instanceId, 'library'));
|
||||||
|
|
||||||
|
// 2. Shuffle (noop here as library is bag)
|
||||||
|
|
||||||
|
// 3. Draw 7
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
this.drawCard(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Increment count
|
||||||
|
player.mulliganCount = (player.mulliganCount || 0) + 1;
|
||||||
|
|
||||||
|
console.log(`Player ${playerId} took mulligan. Count: ${player.mulliganCount}`);
|
||||||
|
// Wait for next decision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public createToken(playerId: string, definition: {
|
||||||
|
name: string,
|
||||||
|
colors: string[],
|
||||||
|
types: string[],
|
||||||
|
subtypes: string[],
|
||||||
|
power: number,
|
||||||
|
toughness: number,
|
||||||
|
keywords?: string[],
|
||||||
|
imageUrl?: string
|
||||||
|
}) {
|
||||||
|
const token: any = { // Using any allowing partial CardObject construction
|
||||||
|
instanceId: Math.random().toString(36).substring(7),
|
||||||
|
oracleId: 'token-' + Math.random(),
|
||||||
|
name: definition.name,
|
||||||
|
controllerId: playerId,
|
||||||
|
ownerId: playerId,
|
||||||
|
zone: 'battlefield',
|
||||||
|
tapped: false,
|
||||||
|
faceDown: false,
|
||||||
|
counters: [],
|
||||||
|
keywords: definition.keywords || [],
|
||||||
|
modifiers: [],
|
||||||
|
colors: definition.colors,
|
||||||
|
types: definition.types,
|
||||||
|
subtypes: definition.subtypes,
|
||||||
|
supertypes: [], // e.g. Legendary?
|
||||||
|
basePower: definition.power,
|
||||||
|
baseToughness: definition.toughness,
|
||||||
|
power: definition.power, // Will be recalc-ed by layers
|
||||||
|
toughness: definition.toughness,
|
||||||
|
imageUrl: definition.imageUrl || '',
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: this.state.turnCount,
|
||||||
|
position: { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type-safe assignment
|
||||||
|
this.state.cards[token.instanceId] = token;
|
||||||
|
|
||||||
|
// Recalculate layers immediately
|
||||||
|
this.recalculateLayers();
|
||||||
|
|
||||||
|
console.log(`Created token ${definition.name} for ${playerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Core State Machine ---
|
// --- Core State Machine ---
|
||||||
|
|
||||||
private passPriorityToNext() {
|
private passPriorityToNext() {
|
||||||
@@ -93,16 +274,26 @@ export class RulesEngine {
|
|||||||
this.state.priorityPlayerId = this.state.turnOrder[nextIndex];
|
this.state.priorityPlayerId = this.state.turnOrder[nextIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveCardToZone(cardId: string, toZone: any, faceDown = false) {
|
private moveCardToZone(cardId: string, toZone: any, faceDown = false, position?: { x: number, y: number }) {
|
||||||
const card = this.state.cards[cardId];
|
const card = this.state.cards[cardId];
|
||||||
if (card) {
|
if (card) {
|
||||||
|
|
||||||
|
if (toZone === 'battlefield' && card.zone !== 'battlefield') {
|
||||||
|
card.controlledSinceTurn = this.state.turnCount;
|
||||||
|
}
|
||||||
|
|
||||||
card.zone = toZone;
|
card.zone = toZone;
|
||||||
card.faceDown = faceDown;
|
card.faceDown = faceDown;
|
||||||
card.tapped = false; // Reset tap usually on zone change (except battlefield->battlefield)
|
card.tapped = false; // Reset tap usually on zone change (except battlefield->battlefield)
|
||||||
|
|
||||||
|
if (position) {
|
||||||
|
card.position = { ...position, z: ++this.state.maxZ };
|
||||||
|
} else {
|
||||||
// Reset X position?
|
// Reset X position?
|
||||||
card.position = { x: 0, y: 0, z: ++this.state.maxZ };
|
card.position = { x: 0, y: 0, z: ++this.state.maxZ };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveTopStack() {
|
private resolveTopStack() {
|
||||||
const item = this.state.stack.pop();
|
const item = this.state.stack.pop();
|
||||||
@@ -120,7 +311,7 @@ export class RulesEngine {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isPermanent) {
|
if (isPermanent) {
|
||||||
this.moveCardToZone(card.instanceId, 'battlefield');
|
this.moveCardToZone(card.instanceId, 'battlefield', false, item.resolutionPosition);
|
||||||
} else {
|
} else {
|
||||||
// Instant / Sorcery
|
// Instant / Sorcery
|
||||||
this.moveCardToZone(card.instanceId, 'graveyard');
|
this.moveCardToZone(card.instanceId, 'graveyard');
|
||||||
@@ -135,6 +326,7 @@ export class RulesEngine {
|
|||||||
private advanceStep() {
|
private advanceStep() {
|
||||||
// Transition Table
|
// Transition Table
|
||||||
const structure: Record<Phase, Step[]> = {
|
const structure: Record<Phase, Step[]> = {
|
||||||
|
setup: ['mulligan'],
|
||||||
beginning: ['untap', 'upkeep', 'draw'],
|
beginning: ['untap', 'upkeep', 'draw'],
|
||||||
main1: ['main'],
|
main1: ['main'],
|
||||||
combat: ['beginning_combat', 'declare_attackers', 'declare_blockers', 'combat_damage', 'end_combat'],
|
combat: ['beginning_combat', 'declare_attackers', 'declare_blockers', 'combat_damage', 'end_combat'],
|
||||||
@@ -142,7 +334,7 @@ export class RulesEngine {
|
|||||||
ending: ['end', 'cleanup']
|
ending: ['end', 'cleanup']
|
||||||
};
|
};
|
||||||
|
|
||||||
const phaseOrder: Phase[] = ['beginning', 'main1', 'combat', 'main2', 'ending'];
|
const phaseOrder: Phase[] = ['setup', 'beginning', 'main1', 'combat', 'main2', 'ending'];
|
||||||
|
|
||||||
let nextStep: Step | null = null;
|
let nextStep: Step | null = null;
|
||||||
let nextPhase: Phase = this.state.phase;
|
let nextPhase: Phase = this.state.phase;
|
||||||
@@ -169,6 +361,9 @@ export class RulesEngine {
|
|||||||
nextStep = structure[nextPhase][0];
|
nextStep = structure[nextPhase][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rule 500.4: Mana empties at end of each step and phase
|
||||||
|
this.emptyManaPools();
|
||||||
|
|
||||||
this.state.phase = nextPhase;
|
this.state.phase = nextPhase;
|
||||||
this.state.step = nextStep!;
|
this.state.step = nextStep!;
|
||||||
|
|
||||||
@@ -199,7 +394,30 @@ export class RulesEngine {
|
|||||||
// --- Turn Based Actions & Triggers ---
|
// --- Turn Based Actions & Triggers ---
|
||||||
|
|
||||||
private performTurnBasedActions() {
|
private performTurnBasedActions() {
|
||||||
const { phase, step, activePlayerId } = this.state;
|
const { step, activePlayerId } = this.state;
|
||||||
|
|
||||||
|
// 0. Mulligan Step
|
||||||
|
if (step === 'mulligan') {
|
||||||
|
// Draw 7 for everyone if they have 0 cards in hand and haven't kept
|
||||||
|
Object.values(this.state.players).forEach(p => {
|
||||||
|
const hand = Object.values(this.state.cards).filter(c => c.ownerId === p.id && c.zone === 'hand');
|
||||||
|
if (hand.length === 0 && !p.handKept) {
|
||||||
|
// Initial Draw
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
this.drawCard(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Check if all kept
|
||||||
|
const allKept = Object.values(this.state.players).every(p => p.handKept);
|
||||||
|
if (allKept) {
|
||||||
|
console.log("All players kept hand. Starting game.");
|
||||||
|
// Normally untap is automatic?
|
||||||
|
// advanceStep will go to beginning/untap
|
||||||
|
this.advanceStep();
|
||||||
|
}
|
||||||
|
return; // Wait for actions
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Untap Step
|
// 1. Untap Step
|
||||||
if (step === 'untap') {
|
if (step === 'untap') {
|
||||||
@@ -226,8 +444,73 @@ export class RulesEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Combat Steps requiring declaration (Pause for External Action)
|
||||||
|
if (step === 'declare_attackers') {
|
||||||
|
// WAITING for declareAttackers() from Client
|
||||||
|
// Do NOT reset priority yet.
|
||||||
|
// TODO: Maybe set a timeout or auto-skip if no creatures?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'declare_blockers') {
|
||||||
|
// WAITING for declareBlockers() from Client (Defending Player)
|
||||||
|
// Do NOT reset priority yet.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Combat Damage Step
|
||||||
|
if (step === 'combat_damage') {
|
||||||
|
this.resolveCombatDamage();
|
||||||
|
this.resetPriority(activePlayerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Default: Reset priority to AP to start the step
|
// Default: Reset priority to AP to start the step
|
||||||
this.resetPriority(activePlayerId);
|
this.resetPriority(activePlayerId);
|
||||||
|
|
||||||
|
// Empty Mana Pools at end of steps?
|
||||||
|
// Actually, mana empties at the END of steps/phases.
|
||||||
|
// Since we are STARTING a step here, we should have emptied prev step mana before transition.
|
||||||
|
// Let's do it in advanceStep() immediately before changing steps.
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Combat Logic ---
|
||||||
|
|
||||||
|
// --- Combat Logic ---
|
||||||
|
|
||||||
|
|
||||||
|
private resolveCombatDamage() {
|
||||||
|
console.log("Resolving Combat Damage...");
|
||||||
|
const attackers = Object.values(this.state.cards).filter(c => !!c.attacking);
|
||||||
|
|
||||||
|
for (const attacker of attackers) {
|
||||||
|
const blockers = Object.values(this.state.cards).filter(c => c.blocking?.includes(attacker.instanceId));
|
||||||
|
|
||||||
|
// 1. Assign Damage
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
// Blocked
|
||||||
|
// Logically: Attacker deals damage to blockers, Blockers deal damage to attacker.
|
||||||
|
// Simple: 1v1 blocking
|
||||||
|
const blocker = blockers[0];
|
||||||
|
|
||||||
|
// Attacker -> Blocker
|
||||||
|
console.log(`${attacker.name} deals ${attacker.power} damage to ${blocker.name}`);
|
||||||
|
blocker.damageMarked = (blocker.damageMarked || 0) + attacker.power;
|
||||||
|
|
||||||
|
// Blocker -> Attacker
|
||||||
|
console.log(`${blocker.name} deals ${blocker.power} damage to ${attacker.name}`);
|
||||||
|
attacker.damageMarked = (attacker.damageMarked || 0) + blocker.power;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Unblocked -> Player/PW
|
||||||
|
const targetId = attacker.attacking!;
|
||||||
|
const targetPlayer = this.state.players[targetId];
|
||||||
|
if (targetPlayer) {
|
||||||
|
console.log(`${attacker.name} deals ${attacker.power} damage to Player ${targetPlayer.name}`);
|
||||||
|
targetPlayer.life -= attacker.power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private untapStep(playerId: string) {
|
private untapStep(playerId: string) {
|
||||||
@@ -257,6 +540,12 @@ export class RulesEngine {
|
|||||||
private cleanupStep(playerId: string) {
|
private cleanupStep(playerId: string) {
|
||||||
// Remove damage, discard down to 7
|
// Remove damage, discard down to 7
|
||||||
console.log(`Cleanup execution.`);
|
console.log(`Cleanup execution.`);
|
||||||
|
Object.values(this.state.cards).forEach(c => {
|
||||||
|
c.damageMarked = 0;
|
||||||
|
if (c.modifiers) {
|
||||||
|
c.modifiers = c.modifiers.filter(m => !m.untilEndOfTurn);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- State Based Actions ---
|
// --- State Based Actions ---
|
||||||
@@ -293,12 +582,8 @@ export class RulesEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 704.5g Lethal Damage
|
// 704.5g Lethal Damage
|
||||||
// TODO: Calculate damage marked on creature (need damage tracking on card)
|
if (c.damageMarked >= c.toughness && !c.supertypes.includes('Indestructible')) {
|
||||||
// Assuming c.damageAssignment holds damage marked?
|
console.log(`SBA: ${c.name} destroyed (Lethal Damage: ${c.damageMarked}/${c.toughness}).`);
|
||||||
let totalDamage = 0;
|
|
||||||
// logic to sum damage
|
|
||||||
if (totalDamage >= c.toughness && !c.supertypes.includes('Indestructible')) {
|
|
||||||
console.log(`SBA: ${c.name} destroyed (Lethal Damage).`);
|
|
||||||
c.zone = 'graveyard';
|
c.zone = 'graveyard';
|
||||||
sbaPerformed = true;
|
sbaPerformed = true;
|
||||||
}
|
}
|
||||||
@@ -306,18 +591,38 @@ export class RulesEngine {
|
|||||||
|
|
||||||
// 3. Legend Rule (704.5j)
|
// 3. Legend Rule (704.5j)
|
||||||
// Map<Controller, Map<Name, Count>>
|
// Map<Controller, Map<Name, Count>>
|
||||||
// If count > 1, prompt user to choose one?
|
|
||||||
// SBAs don't use stack, but Legend Rule requires a choice.
|
|
||||||
// In strict engine, if a choice is required, we might need a special state 'awaiting_sba_choice'.
|
|
||||||
// For now, simplify: Auto-keep oldest? Or newest?
|
// For now, simplify: Auto-keep oldest? Or newest?
|
||||||
// Rules say "choose one", so we can't automate strictly without pausing.
|
// Rules say "choose one", so we can't automate strictly without pausing.
|
||||||
// Let's implement auto-graveyard oldest duplicate for now to avoid stuck state.
|
// Let's implement auto-graveyard oldest duplicate for now to avoid stuck state.
|
||||||
|
|
||||||
|
// 4. Aura Validity (704.5n)
|
||||||
|
Object.values(cards).forEach(c => {
|
||||||
|
if (c.zone === 'battlefield' && c.types.includes('Enchantment') && c.subtypes.includes('Aura')) {
|
||||||
|
// If not attached to anything, or attached to invalid thing (not checking validity yet, just existence)
|
||||||
|
if (!c.attachedTo) {
|
||||||
|
console.log(`SBA: ${c.name} (Aura) unattached. Destroyed.`);
|
||||||
|
c.zone = 'graveyard';
|
||||||
|
sbaPerformed = true;
|
||||||
|
} else {
|
||||||
|
const target = cards[c.attachedTo];
|
||||||
|
// If target is gone or no longer on battlefield
|
||||||
|
if (!target || target.zone !== 'battlefield') {
|
||||||
|
console.log(`SBA: ${c.name} (Aura) target invalid. Destroyed.`);
|
||||||
|
c.zone = 'graveyard';
|
||||||
|
sbaPerformed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return sbaPerformed;
|
return sbaPerformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetPriority(playerId: string) {
|
|
||||||
// Check SBAs first (Loop until no SBAs happen)
|
// This method encapsulates the SBA loop and recalculation of layers
|
||||||
|
private processStateBasedActions() {
|
||||||
|
this.recalculateLayers();
|
||||||
|
|
||||||
let loops = 0;
|
let loops = 0;
|
||||||
while (this.checkStateBasedActions()) {
|
while (this.checkStateBasedActions()) {
|
||||||
loops++;
|
loops++;
|
||||||
@@ -325,10 +630,82 @@ export class RulesEngine {
|
|||||||
console.error("Infinite SBA Loop Detected");
|
console.error("Infinite SBA Loop Detected");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
this.recalculateLayers();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetPriority(playerId: string) {
|
||||||
|
this.processStateBasedActions();
|
||||||
|
|
||||||
this.state.priorityPlayerId = playerId;
|
this.state.priorityPlayerId = playerId;
|
||||||
this.state.passedPriorityCount = 0;
|
this.state.passedPriorityCount = 0;
|
||||||
Object.values(this.state.players).forEach(p => p.hasPassed = false);
|
Object.values(this.state.players).forEach(p => p.hasPassed = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private emptyManaPools() {
|
||||||
|
Object.values(this.state.players).forEach(p => {
|
||||||
|
if (p.manaPool) {
|
||||||
|
p.manaPool = { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recalculateLayers() {
|
||||||
|
// Basic Layer System Implementation (7. Interaction of Continuous Effects)
|
||||||
|
Object.values(this.state.cards).forEach(card => {
|
||||||
|
// Only process battlefield
|
||||||
|
if (card.zone !== 'battlefield') {
|
||||||
|
card.power = card.basePower;
|
||||||
|
card.toughness = card.baseToughness;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 7a: Characteristic-Defining Abilities (CDA) - skipped for now
|
||||||
|
let p = card.basePower;
|
||||||
|
let t = card.baseToughness;
|
||||||
|
|
||||||
|
// Layer 7b: Effects that set power and/or toughness to a specific number
|
||||||
|
// e.g. "Become 0/1"
|
||||||
|
if (card.modifiers) {
|
||||||
|
card.modifiers.filter(m => m.type === 'set_pt').forEach(mod => {
|
||||||
|
if (mod.value.power !== undefined) p = mod.value.power;
|
||||||
|
if (mod.value.toughness !== undefined) t = mod.value.toughness;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 7c: Effects that modify power and/or toughness (+X/+Y)
|
||||||
|
// e.g. Giant Growth, Anthems
|
||||||
|
if (card.modifiers) {
|
||||||
|
card.modifiers.filter(m => m.type === 'pt_boost').forEach(mod => {
|
||||||
|
p += (mod.value.power || 0);
|
||||||
|
t += (mod.value.toughness || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 7d: Counters (+1/+1, -1/-1)
|
||||||
|
if (card.counters) {
|
||||||
|
card.counters.forEach(c => {
|
||||||
|
if (c.type === '+1/+1') {
|
||||||
|
p += c.count;
|
||||||
|
t += c.count;
|
||||||
|
} else if (c.type === '-1/-1') {
|
||||||
|
p -= c.count;
|
||||||
|
t -= c.count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 7e: Switch Power/Toughness - skipped for now
|
||||||
|
|
||||||
|
// Final Floor rule: T cannot be less than 0 for logic? No, T can be negative for calculation, but usually treated as 0 for damage?
|
||||||
|
// Actually CR says negative numbers are real in calculation, but treated as 0 for dealing damage.
|
||||||
|
// We store true values.
|
||||||
|
|
||||||
|
card.power = p;
|
||||||
|
card.toughness = t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
export type Phase = 'setup' | 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
||||||
|
|
||||||
export type Step =
|
export type Step =
|
||||||
|
| 'mulligan' // Setup
|
||||||
// Beginning
|
// Beginning
|
||||||
| 'untap' | 'upkeep' | 'draw'
|
| 'untap' | 'upkeep' | 'draw'
|
||||||
// Main
|
// Main
|
||||||
@@ -26,6 +27,7 @@ export interface CardObject {
|
|||||||
faceDown: boolean;
|
faceDown: boolean;
|
||||||
attacking?: string; // Player/Planeswalker ID
|
attacking?: string; // Player/Planeswalker ID
|
||||||
blocking?: string[]; // List of attacker IDs blocked by this car
|
blocking?: string[]; // List of attacker IDs blocked by this car
|
||||||
|
attachedTo?: string; // ID of card/player this aura/equipment is attached to
|
||||||
damageAssignment?: Record<string, number>; // TargetID -> Amount
|
damageAssignment?: Record<string, number>; // TargetID -> Amount
|
||||||
|
|
||||||
// Characteristics (Base + Modified)
|
// Characteristics (Base + Modified)
|
||||||
@@ -38,12 +40,28 @@ export interface CardObject {
|
|||||||
toughness: number;
|
toughness: number;
|
||||||
basePower: number;
|
basePower: number;
|
||||||
baseToughness: number;
|
baseToughness: number;
|
||||||
|
damageMarked: number;
|
||||||
|
|
||||||
// Counters & Mods
|
// Counters & Mods
|
||||||
counters: { type: string; count: number }[];
|
counters: { type: string; count: number }[];
|
||||||
|
keywords: string[]; // e.g. ["Haste", "Flying"]
|
||||||
|
|
||||||
|
// Continuous Effects (Layers)
|
||||||
|
modifiers: {
|
||||||
|
sourceId: string;
|
||||||
|
type: 'pt_boost' | 'set_pt' | 'ability_grant' | 'type_change';
|
||||||
|
value: any; // ({power: +3, toughness: +3} or "Flying")
|
||||||
|
untilEndOfTurn: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
typeLine?: string;
|
||||||
|
oracleText?: string;
|
||||||
|
position?: { x: number; y: number; z: number };
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
controlledSinceTurn: number; // For Summoning Sickness check
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
@@ -54,6 +72,9 @@ export interface PlayerState {
|
|||||||
energy: number;
|
energy: number;
|
||||||
isActive: boolean; // Is it their turn?
|
isActive: boolean; // Is it their turn?
|
||||||
hasPassed: boolean; // For priority loop
|
hasPassed: boolean; // For priority loop
|
||||||
|
handKept?: boolean; // For Mulligan phase
|
||||||
|
mulliganCount?: number;
|
||||||
|
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StackObject {
|
export interface StackObject {
|
||||||
@@ -66,6 +87,7 @@ export interface StackObject {
|
|||||||
targets: string[];
|
targets: string[];
|
||||||
modes?: number[]; // Selected modes
|
modes?: number[]; // Selected modes
|
||||||
costPaid?: boolean;
|
costPaid?: boolean;
|
||||||
|
resolutionPosition?: { x: number, y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrictGameState {
|
export interface StrictGameState {
|
||||||
|
|||||||
@@ -247,7 +247,10 @@ const draftInterval = setInterval(() => {
|
|||||||
zone: 'library',
|
zone: 'library',
|
||||||
typeLine: card.typeLine || card.type_line || '',
|
typeLine: card.typeLine || card.type_line || '',
|
||||||
oracleText: card.oracleText || card.oracle_text || '',
|
oracleText: card.oracleText || card.oracle_text || '',
|
||||||
manaCost: card.manaCost || card.mana_cost || ''
|
manaCost: card.manaCost || card.mana_cost || '',
|
||||||
|
keywords: card.keywords || [],
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -466,7 +469,10 @@ io.on('connection', (socket) => {
|
|||||||
zone: 'library',
|
zone: 'library',
|
||||||
typeLine: card.typeLine || card.type_line || '',
|
typeLine: card.typeLine || card.type_line || '',
|
||||||
oracleText: card.oracleText || card.oracle_text || '',
|
oracleText: card.oracleText || card.oracle_text || '',
|
||||||
manaCost: card.manaCost || card.mana_cost || ''
|
manaCost: card.manaCost || card.mana_cost || '',
|
||||||
|
keywords: card.keywords || [],
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -493,7 +499,10 @@ io.on('connection', (socket) => {
|
|||||||
zone: 'library',
|
zone: 'library',
|
||||||
typeLine: card.typeLine || card.type_line || '',
|
typeLine: card.typeLine || card.type_line || '',
|
||||||
oracleText: card.oracleText || card.oracle_text || '',
|
oracleText: card.oracleText || card.oracle_text || '',
|
||||||
manaCost: card.manaCost || card.mana_cost || ''
|
manaCost: card.manaCost || card.mana_cost || '',
|
||||||
|
keywords: card.keywords || [],
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -524,7 +533,10 @@ io.on('connection', (socket) => {
|
|||||||
zone: 'library',
|
zone: 'library',
|
||||||
typeLine: card.typeLine || card.type_line || '',
|
typeLine: card.typeLine || card.type_line || '',
|
||||||
oracleText: card.oracleText || card.oracle_text || '',
|
oracleText: card.oracleText || card.oracle_text || '',
|
||||||
manaCost: card.manaCost || card.mana_cost || ''
|
manaCost: card.manaCost || card.mana_cost || '',
|
||||||
|
keywords: card.keywords || [],
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export class GameManager {
|
|||||||
poison: 0,
|
poison: 0,
|
||||||
energy: 0,
|
energy: 0,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
hasPassed: false
|
hasPassed: false,
|
||||||
|
manaPool: { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ export class GameManager {
|
|||||||
activePlayerId: firstPlayerId,
|
activePlayerId: firstPlayerId,
|
||||||
priorityPlayerId: firstPlayerId,
|
priorityPlayerId: firstPlayerId,
|
||||||
|
|
||||||
phase: 'beginning',
|
phase: 'setup',
|
||||||
step: 'untap', // Will be skipped/advanced immediately on start usually
|
step: 'mulligan',
|
||||||
|
|
||||||
passedPriorityCount: 0,
|
passedPriorityCount: 0,
|
||||||
landsPlayedThisTurn: 0,
|
landsPlayedThisTurn: 0,
|
||||||
@@ -69,10 +70,25 @@ export class GameManager {
|
|||||||
engine.passPriority(actorId);
|
engine.passPriority(actorId);
|
||||||
break;
|
break;
|
||||||
case 'PLAY_LAND':
|
case 'PLAY_LAND':
|
||||||
engine.playLand(actorId, action.cardId);
|
engine.playLand(actorId, action.cardId, action.position);
|
||||||
|
break;
|
||||||
|
case 'ADD_MANA':
|
||||||
|
engine.addMana(actorId, action.mana); // action.mana = { color: 'R', amount: 1 }
|
||||||
break;
|
break;
|
||||||
case 'CAST_SPELL':
|
case 'CAST_SPELL':
|
||||||
engine.castSpell(actorId, action.cardId, action.targets);
|
engine.castSpell(actorId, action.cardId, action.targets, action.position);
|
||||||
|
break;
|
||||||
|
case 'DECLARE_ATTACKERS':
|
||||||
|
engine.declareAttackers(actorId, action.attackers);
|
||||||
|
break;
|
||||||
|
case 'DECLARE_BLOCKERS':
|
||||||
|
engine.declareBlockers(actorId, action.blockers);
|
||||||
|
break;
|
||||||
|
case 'CREATE_TOKEN':
|
||||||
|
engine.createToken(actorId, action.definition);
|
||||||
|
break;
|
||||||
|
case 'MULLIGAN_DECISION':
|
||||||
|
engine.resolveMulligan(actorId, action.keep, action.cardsToBottom);
|
||||||
break;
|
break;
|
||||||
// TODO: Activate Ability
|
// TODO: Activate Ability
|
||||||
default:
|
default:
|
||||||
@@ -125,7 +141,21 @@ export class GameManager {
|
|||||||
private tapCard(game: StrictGameState, action: any, actorId: string) {
|
private tapCard(game: StrictGameState, action: any, actorId: string) {
|
||||||
const card = game.cards[action.cardId];
|
const card = game.cards[action.cardId];
|
||||||
if (card && card.controllerId === actorId) {
|
if (card && card.controllerId === actorId) {
|
||||||
|
const wuzUntapped = !card.tapped;
|
||||||
card.tapped = !card.tapped;
|
card.tapped = !card.tapped;
|
||||||
|
|
||||||
|
// Auto-Add Mana for Basic Lands if we just tapped it
|
||||||
|
if (wuzUntapped && card.tapped && card.typeLine?.includes('Land')) {
|
||||||
|
const engine = new RulesEngine(game); // Re-instantiate engine just for this helper
|
||||||
|
// Infer color from type or oracle text or name?
|
||||||
|
// Simple: Basic Land Types
|
||||||
|
if (card.typeLine.includes('Plains')) engine.addMana(actorId, { color: 'W', amount: 1 });
|
||||||
|
else if (card.typeLine.includes('Island')) engine.addMana(actorId, { color: 'U', amount: 1 });
|
||||||
|
else if (card.typeLine.includes('Swamp')) engine.addMana(actorId, { color: 'B', amount: 1 });
|
||||||
|
else if (card.typeLine.includes('Mountain')) engine.addMana(actorId, { color: 'R', amount: 1 });
|
||||||
|
else if (card.typeLine.includes('Forest')) engine.addMana(actorId, { color: 'G', amount: 1 });
|
||||||
|
// TODO: Non-basic lands?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +171,8 @@ export class GameManager {
|
|||||||
tapped: false,
|
tapped: false,
|
||||||
faceDown: true,
|
faceDown: true,
|
||||||
counters: [],
|
counters: [],
|
||||||
|
keywords: [], // Default empty
|
||||||
|
modifiers: [],
|
||||||
colors: [],
|
colors: [],
|
||||||
types: [],
|
types: [],
|
||||||
subtypes: [],
|
subtypes: [],
|
||||||
@@ -154,7 +186,9 @@ export class GameManager {
|
|||||||
ownerId: '',
|
ownerId: '',
|
||||||
oracleId: '',
|
oracleId: '',
|
||||||
name: '',
|
name: '',
|
||||||
...cardData
|
...cardData,
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0 // Will be updated on draw/play
|
||||||
};
|
};
|
||||||
game.cards[card.instanceId] = card;
|
game.cards[card.instanceId] = card;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export class PackGeneratorService {
|
|||||||
finish: cardData.finish || 'normal',
|
finish: cardData.finish || 'normal',
|
||||||
oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '',
|
oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '',
|
||||||
manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '',
|
manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '',
|
||||||
|
damageMarked: 0,
|
||||||
|
controlledSinceTurn: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to pools
|
// Add to pools
|
||||||
|
|||||||
Reference in New Issue
Block a user