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"
|
"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"), {
|
||||||
|
|||||||
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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user