feat: Introduce a modal for creating custom tokens with definable properties and board position.
This commit is contained in:
@@ -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"), {
|
||||
|
||||
191
src/client/src/modules/game/CreateTokenModal.tsx
Normal file
191
src/client/src/modules/game/CreateTokenModal.tsx
Normal file
@@ -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<CreateTokenModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('Token');
|
||||
const [power, setPower] = useState('1');
|
||||
const [toughness, setToughness] = useState('1');
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create Custom Token"
|
||||
confirmLabel="Create Token"
|
||||
onConfirm={handleCreate}
|
||||
cancelLabel="Cancel"
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Token Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* P/T */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Power</label>
|
||||
<input
|
||||
type="text"
|
||||
value={power}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Toughness</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toughness}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Colors</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggleColor(c.id)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm border-2 transition-all
|
||||
${selectedColors.includes(c.id) ? c.bg + ' ring-2 ring-white ring-offset-2 ring-offset-slate-900 scale-110' : 'bg-slate-800 border-slate-600 text-slate-500 hover:bg-slate-700'}
|
||||
`}
|
||||
title={c.label}
|
||||
>
|
||||
{c.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Types */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={types}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Subtypes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subtypes}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image URL */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Image URL (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={imageUrl}
|
||||
onChange={(e) => 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://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview Summary */}
|
||||
<div className="mt-2 p-3 bg-slate-800/50 rounded border border-slate-700 flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-slate-900 border border-slate-600 rounded flex items-center justify-center text-xs text-slate-500 overflow-hidden relative">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="Preview" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||
) : (
|
||||
<span>?</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-bold text-white text-sm">{name} {power}/{toughness}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{selectedColors.length > 0 ? selectedColors.join('/') : 'Colorless'} {types} {subtypes ? `— ${subtypes}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
|
||||
export interface ContextMenuRequest {
|
||||
@@ -72,14 +73,16 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
<MenuItem label={card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: card.instanceId })} />
|
||||
|
||||
<div className="relative group">
|
||||
<MenuItem label="Add Counter ▸" onClick={() => { }} />
|
||||
<div className="absolute left-full top-0 ml-1 w-40 bg-slate-900 border border-slate-700 rounded shadow-lg hidden group-hover:block z-50">
|
||||
<MenuItem label="Add Counter" hasSubmenu />
|
||||
<div className="absolute left-full top-0 pl-1 hidden group-hover:block z-50 w-40">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded shadow-lg">
|
||||
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} />
|
||||
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} />
|
||||
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} />
|
||||
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: {
|
||||
@@ -179,71 +182,85 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
|
||||
{request.type === 'zone' && request.zone && renderZoneMenu(request.zone)}
|
||||
|
||||
|
||||
{request.type === 'background' && (
|
||||
<>
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
|
||||
Battlefield
|
||||
</div>
|
||||
|
||||
{/* Token Submenu */}
|
||||
<div className="relative group">
|
||||
<MenuItem label="Create Token" hasSubmenu />
|
||||
{/* Wrapper for hover bridge */}
|
||||
<div className="absolute left-full top-0 pl-1 hidden group-hover:block z-50 w-56">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded shadow-lg p-1">
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
|
||||
Standard Tokens
|
||||
</div>
|
||||
<MenuItem
|
||||
label="Create Token (1/1 Soldier)"
|
||||
label="1/1 Soldier"
|
||||
onClick={() => 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?
|
||||
},
|
||||
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 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Create Token (2/2 Zombie)"
|
||||
label="2/2 Zombie"
|
||||
onClick={() => 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
|
||||
},
|
||||
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 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="3/3 Beast"
|
||||
onClick={() => 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 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="4/4 Angel"
|
||||
onClick={() => 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 }
|
||||
})}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem
|
||||
label="Treasure"
|
||||
onClick={() => 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 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Food"
|
||||
onClick={() => 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 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Clue"
|
||||
onClick={() => 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 }
|
||||
})}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem
|
||||
label="Custom Token..."
|
||||
onClick={() => handleAction('OPEN_CUSTOM_TOKEN_MODAL')}
|
||||
className="text-emerald-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
label="Create Treasure"
|
||||
onClick={() => 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 })}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem label="Untap All My Permanents" onClick={() => handleAction('UNTAP_ALL')} />
|
||||
@@ -253,12 +270,13 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ 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 }) => (
|
||||
<div
|
||||
className={`px-4 py-2 hover:bg-emerald-600/20 hover:text-emerald-300 cursor-pointer transition-colors ${className}`}
|
||||
className={`px-4 py-2 hover:bg-emerald-600/20 hover:text-emerald-300 cursor-pointer transition-colors flex justify-between items-center ${className}`}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
{hasSubmenu && <ChevronRight size={14} className="text-slate-500" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
const [previewTappedIds, setPreviewTappedIds] = useState<Set<string>>(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<GameViewProps> = ({ 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<GameViewProps> = ({ 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<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
)
|
||||
}
|
||||
|
||||
<CreateTokenModal
|
||||
isOpen={isTokenModalOpen}
|
||||
onClose={() => setIsTokenModalOpen(false)}
|
||||
onCreate={handleCreateCustomToken}
|
||||
/>
|
||||
|
||||
{/* Zoom Sidebar */}
|
||||
<SidePanelPreview
|
||||
ref={sidebarRef}
|
||||
@@ -991,6 +1061,8 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
}}
|
||||
isYielding={isYielding}
|
||||
onYieldToggle={() => setIsYielding(!isYielding)}
|
||||
stopRequested={stopRequested}
|
||||
onToggleSuspend={onToggleSuspend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,6 +120,13 @@ export const PhaseStrip: React.FC<PhaseStripProps> = ({
|
||||
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Special Case: Suspend (No Priority Needed)
|
||||
if (actionType === 'TOGGLE_SUSPEND') {
|
||||
onToggleSuspend?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isYielding) {
|
||||
onYieldToggle?.();
|
||||
return;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user