From 36fd89cda9c856fc93d0328ab3cc3dbf4d9bcf8b Mon Sep 17 00:00:00 2001 From: dnviti Date: Tue, 23 Dec 2025 01:52:13 +0100 Subject: [PATCH] feat: Introduce a modal for creating custom tokens with definable properties and board position. --- src/client/dev-dist/sw.js | 2 +- .../src/modules/game/CreateTokenModal.tsx | 191 ++++++++++++++++++ .../src/modules/game/GameContextMenu.tsx | 152 ++++++++------ src/client/src/modules/game/GameView.tsx | 72 +++++++ src/client/src/modules/game/PhaseStrip.tsx | 7 + src/server/game/RulesEngine.ts | 4 +- src/server/managers/GameManager.ts | 2 +- 7 files changed, 359 insertions(+), 71 deletions(-) create mode 100644 src/client/src/modules/game/CreateTokenModal.tsx diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index da5f88a..23ae724 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.jtdcrepbpeo" + "revision": "0.6var2k6f1uc" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/client/src/modules/game/CreateTokenModal.tsx b/src/client/src/modules/game/CreateTokenModal.tsx new file mode 100644 index 0000000..72bbfcb --- /dev/null +++ b/src/client/src/modules/game/CreateTokenModal.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { Modal } from '../../components/Modal'; + +interface TokenDefinition { + name: string; + power: string; + toughness: string; + colors: string[]; + types: string; + subtypes: string; + imageUrl?: string; +} + +interface CreateTokenModalProps { + isOpen: boolean; + onClose: () => void; + onCreate: (definition: TokenDefinition) => void; +} + +const COLORS = [ + { id: 'W', label: 'White', bg: 'bg-yellow-100 text-yellow-900 border-yellow-300' }, + { id: 'U', label: 'Blue', bg: 'bg-blue-100 text-blue-900 border-blue-300' }, + { id: 'B', label: 'Black', bg: 'bg-slate-300 text-slate-900 border-slate-400' }, + { id: 'R', label: 'Red', bg: 'bg-red-100 text-red-900 border-red-300' }, + { id: 'G', label: 'Green', bg: 'bg-green-100 text-green-900 border-green-300' }, + { id: 'C', label: 'Colorless', bg: 'bg-gray-100 text-gray-900 border-gray-300' }, +]; + +export const CreateTokenModal: React.FC = ({ isOpen, onClose, onCreate }) => { + const [name, setName] = useState('Token'); + const [power, setPower] = useState('1'); + const [toughness, setToughness] = useState('1'); + const [selectedColors, setSelectedColors] = useState([]); + const [types, setTypes] = useState('Creature'); + const [subtypes, setSubtypes] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + + const toggleColor = (colorId: string) => { + setSelectedColors(prev => + prev.includes(colorId) + ? prev.filter(c => c !== colorId) + : [...prev, colorId] + ); + }; + + const handleCreate = () => { + onCreate({ + name, + power, + toughness, + colors: selectedColors, + types: types, + subtypes: subtypes, + imageUrl: imageUrl || undefined + }); + // Reset form roughly or keep? Usually reset. + resetForm(); + }; + + const resetForm = () => { + setName('Token'); + setPower('1'); + setToughness('1'); + setSelectedColors([]); + setTypes('Creature'); + setSubtypes(''); + setImageUrl(''); + }; + + return ( + +
+ {/* Name */} +
+ + setName(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors" + placeholder="e.g. Dragon, Soldier, Treasure" + /> +
+ + {/* P/T */} +
+
+ + setPower(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + setToughness(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + {/* Colors */} +
+ +
+ {COLORS.map(c => ( + + ))} +
+
+ + {/* Types */} +
+
+ + setTypes(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors" + placeholder="Creature, Artifact..." + /> +
+
+ + setSubtypes(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white focus:outline-none focus:border-emerald-500 transition-colors" + placeholder="Soldier, Drake..." + /> +
+
+ + {/* Image URL */} +
+ + setImageUrl(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-xs focus:outline-none focus:border-emerald-500 transition-colors" + placeholder="https://..." + /> +
+ + {/* Preview Summary */} +
+
+ {imageUrl ? ( + Preview e.currentTarget.style.display = 'none'} /> + ) : ( + ? + )} +
+
+
{name} {power}/{toughness}
+
+ {selectedColors.length > 0 ? selectedColors.join('/') : 'Colorless'} {types} {subtypes ? `— ${subtypes}` : ''} +
+
+
+ +
+
+ ); +}; diff --git a/src/client/src/modules/game/GameContextMenu.tsx b/src/client/src/modules/game/GameContextMenu.tsx index 751dae6..dfe7b9c 100644 --- a/src/client/src/modules/game/GameContextMenu.tsx +++ b/src/client/src/modules/game/GameContextMenu.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { ChevronRight } from 'lucide-react'; import { CardInstance } from '../../types/game'; export interface ContextMenuRequest { @@ -72,12 +73,14 @@ export const GameContextMenu: React.FC = ({ request, onClo handleAction('FLIP_CARD', { cardId: card.instanceId })} />
- { }} /> -
- handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} /> + +
+
+ handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} /> +
@@ -179,71 +182,85 @@ export const GameContextMenu: React.FC = ({ request, onClo {request.type === 'zone' && request.zone && renderZoneMenu(request.zone)} + {request.type === 'background' && ( <>
Battlefield
- handleAction('CREATE_TOKEN', { - definition: { - name: 'Soldier', - colors: ['W'], - types: ['Creature'], - subtypes: ['Soldier'], - power: 1, - toughness: 1, - imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Generic Soldier? - }, - position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } - })} - /> - handleAction('CREATE_TOKEN', { - 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 } - })} - /> + + {/* Token Submenu */} +
+ + {/* Wrapper for hover bridge */} +
+
+
+ Standard Tokens +
+ handleAction('CREATE_TOKEN', { + 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' }, + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } + })} + /> + handleAction('CREATE_TOKEN', { + 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' }, + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } + })} + /> + handleAction('CREATE_TOKEN', { + definition: { name: 'Beast', colors: ['G'], types: ['Creature'], subtypes: ['Beast'], power: 3, toughness: 3, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' }, + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } + })} + /> + handleAction('CREATE_TOKEN', { + definition: { name: 'Angel', colors: ['W'], types: ['Creature'], subtypes: ['Angel'], power: 4, toughness: 4, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' }, + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } + })} + /> +
+ handleAction('CREATE_TOKEN', { + definition: { name: 'Treasure', colors: [], types: ['Artifact'], subtypes: ['Treasure'], power: 0, toughness: 0, 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 } + })} + /> + handleAction('CREATE_TOKEN', { + definition: { name: 'Food', colors: [], types: ['Artifact'], subtypes: ['Food'], power: 0, toughness: 0, 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 } + })} + /> + handleAction('CREATE_TOKEN', { + definition: { name: 'Clue', colors: [], types: ['Artifact'], subtypes: ['Clue'], power: 0, toughness: 0, 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 } + })} + /> +
+ handleAction('OPEN_CUSTOM_TOKEN_MODAL')} + className="text-emerald-400 font-bold" + /> +
+
+
+ handleAction('MANA', { x: request.x, y: request.y })} // Adjusted to use request.x/y as MenuItem's onClick doesn't pass event - // icon={} // Zap is not defined in this scope. - /> - handleAction('INSPECT', {})} - // icon={} // Maximize and RotateCw are not defined in this scope. - /> - handleAction('TAP', {})} - // icon={} // Maximize and RotateCw are not defined in this scope. - /> - handleAction('CREATE_TOKEN', { - 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 } - })} + onClick={() => handleAction('MANA', { x: request.x, y: request.y })} />
handleAction('UNTAP_ALL')} /> @@ -253,12 +270,13 @@ export const GameContextMenu: React.FC = ({ request, onClo ); }; -const MenuItem: React.FC<{ label: string; onClick: () => void; className?: string; onMouseEnter?: () => void }> = ({ label, onClick, className = '', onMouseEnter }) => ( +const MenuItem: React.FC<{ label: string; onClick?: () => void; className?: string; onMouseEnter?: () => void; hasSubmenu?: boolean }> = ({ label, onClick, className = '', onMouseEnter, hasSubmenu }) => (
- {label} + {label} + {hasSubmenu && }
); diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 571475d..0ad745d 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -16,6 +16,7 @@ import { GestureManager } from './GestureManager'; import { MulliganView } from './MulliganView'; import { RadialMenu, RadialOption } from './RadialMenu'; import { InspectorOverlay } from './InspectorOverlay'; +import { CreateTokenModal } from './CreateTokenModal'; // Import Modal import { SidePanelPreview } from '../../components/SidePanelPreview'; import { calculateAutoTap } from '../../utils/manaUtils'; @@ -80,6 +81,15 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const [previewTappedIds, setPreviewTappedIds] = useState>(new Set()); const [stopRequested, setStopRequested] = useState(false); + const onToggleSuspend = () => { + setStopRequested(prev => !prev); + }; + + + // Custom Token Modal State + const [isTokenModalOpen, setIsTokenModalOpen] = useState(false); + const [pendingTokenPosition, setPendingTokenPosition] = useState<{ x: number, y: number } | null>(null); + // Auto-Pass Priority if Yielding useEffect(() => { if (isYielding && gameState.priorityPlayerId === currentPlayerId) { @@ -370,6 +380,44 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } return; } + if (actionType === 'OPEN_CUSTOM_TOKEN_MODAL') { + // Current mouse position from context or just center? + // Context Menu has request.x/y but we just closed it. + // But we can assume we want it roughly where the menu was. + // The context menu sets position relative to window. + // We can grab it from `contextMenu` state BEFORE we closed it? + // Actually handleMenuAction calls setContextMenu(null) immediately at start. + // We should pass the coords in payload if needed, or defaults. + // Let's rely on payload if passed, or default. + // For now, let's just use center if not provided, but context menu should provide it if we want precision. + // Actually ContextMenu request state is gone. + // But we can reconstruct: + // In the context menu, we can pass `x, y` to this action. + // Or we can just default to center. + // Let's assume we want it relative to the VIEWPORT center for the modal, + // but the TOKEN should spawn where? + // Ah, the user clicked "Custom Token" inside the menu. + // We want the token to spawn where the Right Click happened. + + // Let's assume the previous contextMenu request coords are relevant. + // We need to capture them before setContextMenu(null). + // Wait, we call setContextMenu(null) at line 340. + // So custom action needs to happen BEFORE? + // Or we make handleMenuAction smarter. + + // Better: we won't fix line 340, we'll just check `contextMenu` state here; + // React state updates are batched/async, so `contextMenu` *might* still be available + // inside this function scope if we closed it just now? + // No, safest is to pass coordinates from the Menu Component. + + setPendingTokenPosition({ + x: (contextMenu?.x || window.innerWidth / 2) / window.innerWidth * 100, + y: (contextMenu?.y || window.innerHeight / 2) / window.innerHeight * 100 + }); + setIsTokenModalOpen(true); + return; + } + // Default payload to object if undefined const safePayload = payload || {}; @@ -430,6 +478,22 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } } }; + const handleCreateCustomToken = (definition: any) => { + setIsTokenModalOpen(false); + if (!pendingTokenPosition) return; + + // Send Create Token Action + socketService.socket.emit('game_action', { + action: { + type: 'CREATE_TOKEN', + definition: definition, + position: pendingTokenPosition, + ownerId: currentPlayerId + } + }); + setPendingTokenPosition(null); + }; + // --- Hooks & Services --- // const { showToast } = useToast(); // Assuming useToast is defined elsewhere if needed const { confirm } = useConfirm(); @@ -631,6 +695,12 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } ) } + setIsTokenModalOpen(false)} + onCreate={handleCreateCustomToken} + /> + {/* Zoom Sidebar */} = ({ gameState, currentPlayerId } }} isYielding={isYielding} onYieldToggle={() => setIsYielding(!isYielding)} + stopRequested={stopRequested} + onToggleSuspend={onToggleSuspend} />
diff --git a/src/client/src/modules/game/PhaseStrip.tsx b/src/client/src/modules/game/PhaseStrip.tsx index f52e589..a8e86de 100644 --- a/src/client/src/modules/game/PhaseStrip.tsx +++ b/src/client/src/modules/game/PhaseStrip.tsx @@ -120,6 +120,13 @@ export const PhaseStrip: React.FC = ({ const handleAction = (e: React.MouseEvent) => { e.stopPropagation(); + + // Special Case: Suspend (No Priority Needed) + if (actionType === 'TOGGLE_SUSPEND') { + onToggleSuspend?.(); + return; + } + if (isYielding) { onYieldToggle?.(); return; diff --git a/src/server/game/RulesEngine.ts b/src/server/game/RulesEngine.ts index 98d6789..c4c0780 100644 --- a/src/server/game/RulesEngine.ts +++ b/src/server/game/RulesEngine.ts @@ -499,7 +499,7 @@ export class RulesEngine { toughness: number, keywords?: string[], imageUrl?: string - }) { + }, position?: { x: number, y: number }) { const token: any = { // Using any allowing partial CardObject construction instanceId: Math.random().toString(36).substring(7), oracleId: 'token-' + Math.random(), @@ -523,7 +523,7 @@ export class RulesEngine { imageUrl: definition.imageUrl || '', damageMarked: 0, controlledSinceTurn: this.state.turnCount, - position: { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ } + position: position ? { ...position, z: ++this.state.maxZ } : { x: Math.random() * 80, y: Math.random() * 80, z: ++this.state.maxZ } }; // Type-safe assignment diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index 382e9a2..eccabf0 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -157,7 +157,7 @@ export class GameManager extends EventEmitter { engine.declareBlockers(actorId, action.blockers); break; case 'CREATE_TOKEN': - engine.createToken(actorId, action.definition); + engine.createToken(actorId, action.definition, action.position); break; case 'MULLIGAN_DECISION': engine.resolveMulligan(actorId, action.keep, action.cardsToBottom);