feat: Introduce a modal for creating custom tokens with definable properties and board position.

This commit is contained in:
2025-12-23 01:52:13 +01:00
parent f8188875ac
commit 36fd89cda9
7 changed files with 359 additions and 71 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.jtdcrepbpeo" "revision": "0.6var2k6f1uc"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View 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>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { ChevronRight } from 'lucide-react';
import { CardInstance } from '../../types/game'; import { CardInstance } from '../../types/game';
export interface ContextMenuRequest { 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 })} /> <MenuItem label={card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: card.instanceId })} />
<div className="relative group"> <div className="relative group">
<MenuItem label="Add Counter" onClick={() => { }} /> <MenuItem label="Add Counter" hasSubmenu />
<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"> <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="-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="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 })} /> <MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
</div> </div>
</div> </div>
</div>
<MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', { <MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', {
tokenData: { tokenData: {
@@ -179,71 +182,85 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
{request.type === 'zone' && request.zone && renderZoneMenu(request.zone)} {request.type === 'zone' && request.zone && renderZoneMenu(request.zone)}
{request.type === 'background' && ( {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"> <div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
Battlefield Battlefield
</div> </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 <MenuItem
label="Create Token (1/1 Soldier)" label="1/1 Soldier"
onClick={() => handleAction('CREATE_TOKEN', { onClick={() => handleAction('CREATE_TOKEN', {
definition: { 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' },
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 Zombie)" label="2/2 Zombie"
onClick={() => handleAction('CREATE_TOKEN', { onClick={() => handleAction('CREATE_TOKEN', {
definition: { 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' },
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="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 <MenuItem
label="Add Mana..." 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 onClick={() => handleAction('MANA', { x: request.x, y: request.y })}
// 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 }
})}
/> />
<div className="h-px bg-slate-800 my-1 mx-2"></div> <div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem label="Untap All My Permanents" onClick={() => handleAction('UNTAP_ALL')} /> <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 <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} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
> >
{label} <span>{label}</span>
{hasSubmenu && <ChevronRight size={14} className="text-slate-500" />}
</div> </div>
); );

View File

@@ -16,6 +16,7 @@ import { GestureManager } from './GestureManager';
import { MulliganView } from './MulliganView'; import { MulliganView } from './MulliganView';
import { RadialMenu, RadialOption } from './RadialMenu'; import { RadialMenu, RadialOption } from './RadialMenu';
import { InspectorOverlay } from './InspectorOverlay'; import { InspectorOverlay } from './InspectorOverlay';
import { CreateTokenModal } from './CreateTokenModal'; // Import Modal
import { SidePanelPreview } from '../../components/SidePanelPreview'; import { SidePanelPreview } from '../../components/SidePanelPreview';
import { calculateAutoTap } from '../../utils/manaUtils'; 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 [previewTappedIds, setPreviewTappedIds] = useState<Set<string>>(new Set());
const [stopRequested, setStopRequested] = useState(false); 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 // Auto-Pass Priority if Yielding
useEffect(() => { useEffect(() => {
if (isYielding && gameState.priorityPlayerId === currentPlayerId) { if (isYielding && gameState.priorityPlayerId === currentPlayerId) {
@@ -370,6 +380,44 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
return; 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 // Default payload to object if undefined
const safePayload = payload || {}; 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 --- // --- Hooks & Services ---
// const { showToast } = useToast(); // Assuming useToast is defined elsewhere if needed // const { showToast } = useToast(); // Assuming useToast is defined elsewhere if needed
const { confirm } = useConfirm(); 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 */} {/* Zoom Sidebar */}
<SidePanelPreview <SidePanelPreview
ref={sidebarRef} ref={sidebarRef}
@@ -991,6 +1061,8 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
}} }}
isYielding={isYielding} isYielding={isYielding}
onYieldToggle={() => setIsYielding(!isYielding)} onYieldToggle={() => setIsYielding(!isYielding)}
stopRequested={stopRequested}
onToggleSuspend={onToggleSuspend}
/> />
</div> </div>

View File

@@ -120,6 +120,13 @@ export const PhaseStrip: React.FC<PhaseStripProps> = ({
const handleAction = (e: React.MouseEvent) => { const handleAction = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Special Case: Suspend (No Priority Needed)
if (actionType === 'TOGGLE_SUSPEND') {
onToggleSuspend?.();
return;
}
if (isYielding) { if (isYielding) {
onYieldToggle?.(); onYieldToggle?.();
return; return;

View File

@@ -499,7 +499,7 @@ export class RulesEngine {
toughness: number, toughness: number,
keywords?: string[], keywords?: string[],
imageUrl?: string imageUrl?: string
}) { }, position?: { x: number, y: number }) {
const token: any = { // Using any allowing partial CardObject construction const token: any = { // Using any allowing partial CardObject construction
instanceId: Math.random().toString(36).substring(7), instanceId: Math.random().toString(36).substring(7),
oracleId: 'token-' + Math.random(), oracleId: 'token-' + Math.random(),
@@ -523,7 +523,7 @@ export class RulesEngine {
imageUrl: definition.imageUrl || '', imageUrl: definition.imageUrl || '',
damageMarked: 0, damageMarked: 0,
controlledSinceTurn: this.state.turnCount, 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 // Type-safe assignment

View File

@@ -157,7 +157,7 @@ export class GameManager extends EventEmitter {
engine.declareBlockers(actorId, action.blockers); engine.declareBlockers(actorId, action.blockers);
break; break;
case 'CREATE_TOKEN': case 'CREATE_TOKEN':
engine.createToken(actorId, action.definition); engine.createToken(actorId, action.definition, action.position);
break; break;
case 'MULLIGAN_DECISION': case 'MULLIGAN_DECISION':
engine.resolveMulligan(actorId, action.keep, action.cardsToBottom); engine.resolveMulligan(actorId, action.keep, action.cardsToBottom);