feat: Add GM library for battle maps and NPC templates
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
- Add library page with tabs for maps and combatants - Create map upload modal with grid configuration - Create NPC/monster template modal with abilities - Add library link to campaign page (GM only) - Add battle feature TODO documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
||||
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
||||
import { CharacterSheetPage } from '@/features/characters';
|
||||
import { BattlePage } from '@/features/battle';
|
||||
import { LibraryPage } from '@/features/library';
|
||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||
import { Layout } from '@/shared/components/layout';
|
||||
|
||||
@@ -48,7 +49,7 @@ function AppContent() {
|
||||
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
||||
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/campaigns/:id/battle" element={<BattlePage />} />
|
||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||
<Route path="/campaigns/:id/library" element={<LibraryPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Crown,
|
||||
Shield,
|
||||
Heart,
|
||||
FileJson
|
||||
FileJson,
|
||||
Library
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -268,7 +269,7 @@ export function CampaignDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/battle`)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
@@ -280,6 +281,19 @@ export function CampaignDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{canManage && (
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors active:scale-[0.98]" onClick={() => navigate(`/campaigns/${id}/library`)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Library className="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-primary">Bibliothek</p>
|
||||
<p className="text-sm text-text-secondary">Karten & NPCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Card className="p-4 hover:border-border-hover cursor-pointer transition-colors opacity-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Plus, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface CombatantAbility {
|
||||
name: string;
|
||||
actionCost: number;
|
||||
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||
description: string;
|
||||
damage?: string;
|
||||
traits?: string[];
|
||||
}
|
||||
|
||||
interface CreateCombatantModalProps {
|
||||
onClose: () => void;
|
||||
onCreate: (data: {
|
||||
name: string;
|
||||
type: 'NPC' | 'MONSTER';
|
||||
level: number;
|
||||
hpMax: number;
|
||||
ac: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
perception: number;
|
||||
speed: number;
|
||||
description?: string;
|
||||
abilities: CombatantAbility[];
|
||||
}) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CreateCombatantModal({ onClose, onCreate, isLoading }: CreateCombatantModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'NPC' | 'MONSTER'>('NPC');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [hpMax, setHpMax] = useState(20);
|
||||
const [ac, setAc] = useState(15);
|
||||
const [fortitude, setFortitude] = useState(5);
|
||||
const [reflex, setReflex] = useState(5);
|
||||
const [will, setWill] = useState(5);
|
||||
const [perception, setPerception] = useState(5);
|
||||
const [speed, setSpeed] = useState(25);
|
||||
const [description, setDescription] = useState('');
|
||||
const [abilities, setAbilities] = useState<CombatantAbility[]>([]);
|
||||
const [showAddAbility, setShowAddAbility] = useState(false);
|
||||
|
||||
// Ability form state
|
||||
const [abilityName, setAbilityName] = useState('');
|
||||
const [abilityActionCost, setAbilityActionCost] = useState(1);
|
||||
const [abilityActionType, setAbilityActionType] = useState<'ACTION' | 'REACTION' | 'FREE'>('ACTION');
|
||||
const [abilityDescription, setAbilityDescription] = useState('');
|
||||
const [abilityDamage, setAbilityDamage] = useState('');
|
||||
|
||||
const handleAddAbility = () => {
|
||||
if (!abilityName.trim() || !abilityDescription.trim()) return;
|
||||
|
||||
setAbilities([
|
||||
...abilities,
|
||||
{
|
||||
name: abilityName.trim(),
|
||||
actionCost: abilityActionCost,
|
||||
actionType: abilityActionType,
|
||||
description: abilityDescription.trim(),
|
||||
damage: abilityDamage.trim() || undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
// Reset ability form
|
||||
setAbilityName('');
|
||||
setAbilityActionCost(1);
|
||||
setAbilityActionType('ACTION');
|
||||
setAbilityDescription('');
|
||||
setAbilityDamage('');
|
||||
setShowAddAbility(false);
|
||||
};
|
||||
|
||||
const handleRemoveAbility = (index: number) => {
|
||||
setAbilities(abilities.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
onCreate({
|
||||
name: name.trim(),
|
||||
type,
|
||||
level,
|
||||
hpMax,
|
||||
ac,
|
||||
fortitude,
|
||||
reflex,
|
||||
will,
|
||||
perception,
|
||||
speed,
|
||||
description: description.trim() || undefined,
|
||||
abilities,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||
<div className="bg-bg-secondary border border-border-default rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-default sticky top-0 bg-bg-secondary z-10">
|
||||
<h2 className="text-lg font-semibold text-text-primary">NPC/Monster erstellen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Goblin Krieger"
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Typ</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'NPC' | 'MONSTER')}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="NPC">NPC</option>
|
||||
<option value="MONSTER">Monster</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row 1 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(parseInt(e.target.value) || 0)}
|
||||
min={-1}
|
||||
max={30}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">HP</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hpMax}
|
||||
onChange={(e) => setHpMax(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">RK</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saves */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Zähigkeit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={fortitude}
|
||||
onChange={(e) => setFortitude(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Reflex</label>
|
||||
<input
|
||||
type="number"
|
||||
value={reflex}
|
||||
onChange={(e) => setReflex(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Wille</label>
|
||||
<input
|
||||
type="number"
|
||||
value={will}
|
||||
onChange={(e) => setWill(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Perception & Speed */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Wahrnehmung</label>
|
||||
<input
|
||||
type="number"
|
||||
value={perception}
|
||||
onChange={(e) => setPerception(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Geschwindigkeit (ft)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
step={5}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Notizen, Taktiken, besondere Merkmale..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Abilities */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Fähigkeiten</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddAbility(!showAddAbility)}
|
||||
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Ability Form */}
|
||||
{showAddAbility && (
|
||||
<div className="p-3 bg-bg-tertiary rounded-lg space-y-3 mb-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={abilityName}
|
||||
onChange={(e) => setAbilityName(e.target.value)}
|
||||
placeholder="Name (z.B. Schwerthieb)"
|
||||
className="px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={abilityActionCost}
|
||||
onChange={(e) => setAbilityActionCost(parseInt(e.target.value))}
|
||||
className="flex-1 px-2 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value={0}>0</option>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
</select>
|
||||
<select
|
||||
value={abilityActionType}
|
||||
onChange={(e) => setAbilityActionType(e.target.value as 'ACTION' | 'REACTION' | 'FREE')}
|
||||
className="flex-1 px-2 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="ACTION">Aktion</option>
|
||||
<option value="REACTION">Reaktion</option>
|
||||
<option value="FREE">Frei</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={abilityDamage}
|
||||
onChange={(e) => setAbilityDamage(e.target.value)}
|
||||
placeholder="Schaden (z.B. 1d8+4 Hieb)"
|
||||
className="w-full px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<textarea
|
||||
value={abilityDescription}
|
||||
onChange={(e) => setAbilityDescription(e.target.value)}
|
||||
placeholder="Beschreibung..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-bg-secondary border border-border-default rounded-lg text-text-primary text-sm focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddAbility(false)}
|
||||
className="flex-1 px-3 py-1.5 bg-bg-secondary text-text-secondary rounded-lg text-sm hover:bg-white/10"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAbility}
|
||||
disabled={!abilityName.trim() || !abilityDescription.trim()}
|
||||
className="flex-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/80 disabled:opacity-50"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ability List */}
|
||||
{abilities.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{abilities.map((ability, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start justify-between gap-2 p-2 bg-bg-tertiary rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-text-primary text-sm">{ability.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{ability.actionCost > 0 ? `${ability.actionCost} Aktion${ability.actionCost > 1 ? 'en' : ''}` : ability.actionType === 'REACTION' ? 'Reaktion' : 'Frei'}
|
||||
</span>
|
||||
</div>
|
||||
{ability.damage && (
|
||||
<p className="text-xs text-red-400 mt-0.5">{ability.damage}</p>
|
||||
)}
|
||||
<p className="text-xs text-text-secondary mt-0.5 line-clamp-2">{ability.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAbility(index)}
|
||||
className="p-1 text-text-secondary hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-secondary text-center py-4 bg-bg-tertiary rounded-lg">
|
||||
Keine Fähigkeiten hinzugefügt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isLoading}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
name.trim() && !isLoading
|
||||
? 'bg-primary text-white hover:bg-primary/80'
|
||||
: 'bg-primary/50 text-white/50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Erstellen...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
client/src/features/library/components/library-page.tsx
Normal file
324
client/src/features/library/components/library-page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Map, Users, Plus, Trash2, Image, Swords } from 'lucide-react';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { UploadMapModal } from './upload-map-modal';
|
||||
import { CreateCombatantModal } from './create-combatant-modal';
|
||||
import type { BattleMap, Combatant } from '@/shared/types';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
type Tab = 'maps' | 'combatants';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id: campaignId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('maps');
|
||||
const [showUploadMap, setShowUploadMap] = useState(false);
|
||||
const [showCreateCombatant, setShowCreateCombatant] = useState(false);
|
||||
|
||||
// Fetch campaign to check GM status
|
||||
const { data: campaign } = useQuery({
|
||||
queryKey: ['campaign', campaignId],
|
||||
queryFn: () => api.getCampaign(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
const isGM = campaign?.gmId === user?.id;
|
||||
|
||||
// Redirect non-GMs
|
||||
if (campaign && !isGM) {
|
||||
navigate(`/campaigns/${campaignId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch battle maps
|
||||
const { data: maps = [], isLoading: mapsLoading } = useQuery({
|
||||
queryKey: ['battleMaps', campaignId],
|
||||
queryFn: () => api.getBattleMaps(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
// Fetch combatants
|
||||
const { data: combatants = [], isLoading: combatantsLoading } = useQuery({
|
||||
queryKey: ['combatants', campaignId],
|
||||
queryFn: () => api.getCombatants(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
// Upload map mutation
|
||||
const uploadMapMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof api.uploadBattleMap>[1]) =>
|
||||
api.uploadBattleMap(campaignId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleMaps', campaignId] });
|
||||
setShowUploadMap(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete map mutation
|
||||
const deleteMapMutation = useMutation({
|
||||
mutationFn: (mapId: string) => api.deleteBattleMap(campaignId!, mapId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleMaps', campaignId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Create combatant mutation
|
||||
const createCombatantMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof api.createCombatant>[1]) =>
|
||||
api.createCombatant(campaignId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['combatants', campaignId] });
|
||||
setShowCreateCombatant(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete combatant mutation
|
||||
const deleteCombatantMutation = useMutation({
|
||||
mutationFn: (combatantId: string) => api.deleteCombatant(campaignId!, combatantId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['combatants', campaignId] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-bg-secondary border-b border-border-default">
|
||||
<div className="max-w-6xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/campaigns/${campaignId}`)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-text-primary">Bibliothek</h1>
|
||||
<p className="text-sm text-text-secondary">{campaign?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('maps')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === 'maps'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
Karten ({maps.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('combatants')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === 'combatants'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
NPCs ({combatants.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-6xl mx-auto px-4 py-6">
|
||||
{activeTab === 'maps' ? (
|
||||
<div>
|
||||
{/* Add Map Button */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() => setShowUploadMap(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Karte hochladen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Maps Grid */}
|
||||
{mapsLoading ? (
|
||||
<div className="text-center py-12 text-text-secondary">Laden...</div>
|
||||
) : maps.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Image className="w-12 h-12 text-text-secondary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">Noch keine Karten hochgeladen</p>
|
||||
<button
|
||||
onClick={() => setShowUploadMap(true)}
|
||||
className="mt-4 text-primary hover:text-primary/80"
|
||||
>
|
||||
Erste Karte hochladen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{maps.map((map: BattleMap) => (
|
||||
<div
|
||||
key={map.id}
|
||||
className="bg-bg-secondary border border-border-default rounded-lg overflow-hidden group"
|
||||
>
|
||||
{/* Map Preview */}
|
||||
<div className="aspect-video bg-black/30 relative">
|
||||
<img
|
||||
src={`${API_URL}${map.imageUrl}`}
|
||||
alt={map.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Overlay with actions */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Karte wirklich löschen?')) {
|
||||
deleteMapMutation.mutate(map.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Map Info */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-text-primary truncate">{map.name}</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{map.gridSizeX} x {map.gridSizeY} Grid
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Add Combatant Button */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() => setShowCreateCombatant(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
NPC erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Combatants List */}
|
||||
{combatantsLoading ? (
|
||||
<div className="text-center py-12 text-text-secondary">Laden...</div>
|
||||
) : combatants.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Swords className="w-12 h-12 text-text-secondary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">Noch keine NPCs erstellt</p>
|
||||
<button
|
||||
onClick={() => setShowCreateCombatant(true)}
|
||||
className="mt-4 text-primary hover:text-primary/80"
|
||||
>
|
||||
Ersten NPC erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{combatants.map((combatant: Combatant) => (
|
||||
<div
|
||||
key={combatant.id}
|
||||
className="bg-bg-secondary border border-border-default rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
'w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
combatant.type === 'MONSTER' ? 'bg-red-500/20' : 'bg-blue-500/20'
|
||||
)}>
|
||||
<Swords className={cn(
|
||||
'w-6 h-6',
|
||||
combatant.type === 'MONSTER' ? 'text-red-400' : 'text-blue-400'
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-text-primary truncate">{combatant.name}</h3>
|
||||
<span className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
combatant.type === 'MONSTER'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
)}>
|
||||
{combatant.type === 'MONSTER' ? 'Monster' : 'NPC'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-text-secondary mt-1">
|
||||
<span>Lvl {combatant.level}</span>
|
||||
<span>HP {combatant.hpMax}</span>
|
||||
<span>RK {combatant.ac}</span>
|
||||
<span className="hidden sm:inline">
|
||||
Rett: +{combatant.fortitude}/+{combatant.reflex}/+{combatant.will}
|
||||
</span>
|
||||
</div>
|
||||
{combatant.abilities && combatant.abilities.length > 0 && (
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
{combatant.abilities.length} Fähigkeit{combatant.abilities.length > 1 ? 'en' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('NPC wirklich löschen?')) {
|
||||
deleteCombatantMutation.mutate(combatant.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 text-text-secondary hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
{showUploadMap && (
|
||||
<UploadMapModal
|
||||
onClose={() => setShowUploadMap(false)}
|
||||
onUpload={(data) => uploadMapMutation.mutate(data)}
|
||||
isLoading={uploadMapMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateCombatant && (
|
||||
<CreateCombatantModal
|
||||
onClose={() => setShowCreateCombatant(false)}
|
||||
onCreate={(data) => createCombatantMutation.mutate(data)}
|
||||
isLoading={createCombatantMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
client/src/features/library/components/upload-map-modal.tsx
Normal file
231
client/src/features/library/components/upload-map-modal.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, Upload, Image } from 'lucide-react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface UploadMapModalProps {
|
||||
onClose: () => void;
|
||||
onUpload: (data: {
|
||||
name: string;
|
||||
gridSizeX: number;
|
||||
gridSizeY: number;
|
||||
gridOffsetX: number;
|
||||
gridOffsetY: number;
|
||||
image: File;
|
||||
}) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UploadMapModal({ onClose, onUpload, isLoading }: UploadMapModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [gridSizeX, setGridSizeX] = useState(20);
|
||||
const [gridSizeY, setGridSizeY] = useState(20);
|
||||
const [gridOffsetX, setGridOffsetX] = useState(0);
|
||||
const [gridOffsetY, setGridOffsetY] = useState(0);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Auto-fill name from filename if empty
|
||||
if (!name) {
|
||||
setName(file.name.replace(/\.[^/.]+$/, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile || !name.trim()) return;
|
||||
|
||||
onUpload({
|
||||
name: name.trim(),
|
||||
gridSizeX,
|
||||
gridSizeY,
|
||||
gridOffsetX,
|
||||
gridOffsetY,
|
||||
image: selectedFile,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||
<div className="bg-bg-secondary border border-border-default rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-default">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Karte hochladen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-white/10 text-text-secondary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Kartenbild
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{preview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-contain bg-black/30 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setPreview(null);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 rounded-full bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full h-48 border-2 border-dashed border-border-default rounded-lg flex flex-col items-center justify-center gap-2 hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Image className="w-8 h-8 text-text-secondary" />
|
||||
<span className="text-sm text-text-secondary">Klicken zum Auswählen</span>
|
||||
<span className="text-xs text-text-secondary">PNG, JPG, WebP (max. 10MB)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Taverne im Wald"
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid Size */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Grid Spalten (X)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gridSizeX}
|
||||
onChange={(e) => setGridSizeX(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Grid Reihen (Y)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gridSizeY}
|
||||
onChange={(e) => setGridSizeY(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Offset */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Offset X (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gridOffsetX}
|
||||
onChange={(e) => setGridOffsetX(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Offset Y (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gridOffsetY}
|
||||
onChange={(e) => setGridOffsetY(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-secondary">
|
||||
Offset ist nützlich für Karten mit vorgezeichnetem Grid, um das digitale Grid auszurichten.
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-bg-tertiary text-text-primary rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedFile || !name.trim() || isLoading}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedFile && name.trim() && !isLoading
|
||||
? 'bg-primary text-white hover:bg-primary/80'
|
||||
: 'bg-primary/50 text-white/50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Hochladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Hochladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
client/src/features/library/index.ts
Normal file
3
client/src/features/library/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LibraryPage } from './components/library-page';
|
||||
export { UploadMapModal } from './components/upload-map-modal';
|
||||
export { CreateCombatantModal } from './components/create-combatant-modal';
|
||||
134
docs/BATTLE_TODO.md
Normal file
134
docs/BATTLE_TODO.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Battle Screen - TODO
|
||||
|
||||
## Phase 2: Grid System
|
||||
|
||||
### Frontend
|
||||
- [ ] `grid-overlay.tsx` - Verbessertes Grid-Rendering mit Snap-Preview
|
||||
- [ ] `grid-config-modal.tsx` - Grid konfigurieren (nur GM)
|
||||
- Grid-Größe anpassen (Spalten x Reihen)
|
||||
- Grid-Offset für vorgezeichnete Maps einstellen
|
||||
- Live-Preview beim Anpassen
|
||||
- Zellenfarbe und Transparenz
|
||||
- [ ] Snap-to-Grid beim Token-Platzieren
|
||||
- [ ] Grid Ein-/Ausblenden Toggle (bereits vorhanden, verbessern)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Token Management
|
||||
|
||||
### Frontend
|
||||
- [ ] `add-pc-modal.tsx` - PCs aus Kampagne hinzufügen (mit Filter/Suche)
|
||||
- [ ] `add-npc-modal.tsx` - NPCs aus Templates hinzufügen
|
||||
- [ ] `create-combatant-modal.tsx` - Neues NPC-Template erstellen
|
||||
- Name, Level, HP, AC
|
||||
- Rettungswürfe (Fort, Ref, Will)
|
||||
- Wahrnehmung, Geschwindigkeit
|
||||
- Fähigkeiten mit Aktionskosten
|
||||
- Avatar-Upload
|
||||
- [ ] `token-detail-panel.tsx` - Seitenpanel für Token-Details
|
||||
- HP-Management (Schaden/Heilung)
|
||||
- Zustand hinzufügen/entfernen
|
||||
- Notizen
|
||||
- Statistiken anzeigen (für NPCs)
|
||||
- [ ] `initiative-tracker.tsx` - Initiativreihenfolge
|
||||
- Sortierte Liste aller Tokens
|
||||
- Aktueller Zug markiert
|
||||
- Nächster/Vorheriger Zug
|
||||
- Verzögern-Funktion
|
||||
|
||||
### Backend WebSocket Events
|
||||
- [ ] `token_conditions_changed` - Zustände aktualisiert
|
||||
- [ ] `turn_started` - Zug beginnt (für aktiven Token)
|
||||
- [ ] `delay_turn` - Zug verzögern
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Player View
|
||||
|
||||
### Frontend
|
||||
- [ ] `battle-player-view.tsx` - Route: `/campaigns/:id/battle/view`
|
||||
- Vollbild-Ansicht für Spieler (TV-Modus)
|
||||
- Read-only (keine Token-Manipulation)
|
||||
- Sync via gleicher WebSocket-Room
|
||||
- [ ] `battle-tv-window.tsx` - Pop-out Window Logik
|
||||
- "TV-Modus öffnen" Button
|
||||
- Separates Browser-Fenster
|
||||
- Automatische Größenanpassung
|
||||
|
||||
### Berechtigungen
|
||||
| Feature | GM View | Player View |
|
||||
|---------|---------|-------------|
|
||||
| Tokens bewegen | Ja | Nein |
|
||||
| PC HP sehen | Ja (Zahlen) | Ja (Zahlen) |
|
||||
| NPC HP sehen | Ja (Zahlen) | Nein (nur Blut-Effekt bei <20%) |
|
||||
| NPC-Stats sehen | Ja | Nein |
|
||||
| Initiative bearbeiten | Ja | Nein |
|
||||
| Grid konfigurieren | Ja | Nein |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Erweiterte Features
|
||||
|
||||
### Drag & Drop
|
||||
- [ ] Touch-Support für mobile Geräte
|
||||
- [ ] Drag-Preview mit Zielzelle hervorheben
|
||||
- [ ] Multi-Select (Shift+Klick)
|
||||
- [ ] Mehrere Tokens gleichzeitig bewegen
|
||||
|
||||
### Visuelle Verbesserungen
|
||||
- [ ] Condition-Badges auf Tokens
|
||||
- Icon für häufige Zustände (Frightened, Slowed, etc.)
|
||||
- Anzahl bei gestuften Zuständen
|
||||
- [ ] Blut-Effekt bei niedrigen HP (<20%)
|
||||
- [ ] Token-Avatar anstatt Initialen (wenn vorhanden)
|
||||
- [ ] Verschiedene Token-Formen (rund, eckig)
|
||||
- [ ] Größen-Indikator für Large/Huge/Gargantuan
|
||||
|
||||
### Kampf-Tools
|
||||
- [ ] `combat-log.tsx` - Letzte Aktionen anzeigen
|
||||
- Token bewegt
|
||||
- Schaden erhalten
|
||||
- Zustand hinzugefügt
|
||||
- Initiative geändert
|
||||
- [ ] `measure-tool.tsx` - Entfernung messen
|
||||
- Klick & Drag für Linie
|
||||
- Entfernung in Fuß anzeigen
|
||||
- Diagonal-Bewegung nach PF2e-Regeln
|
||||
- [ ] `area-tool.tsx` - Flächeneffekte
|
||||
- Kegel, Linie, Emanation, Burst
|
||||
- Größe einstellen
|
||||
- Betroffene Felder hervorheben
|
||||
|
||||
### Map-Features
|
||||
- [ ] Fog of War (nur GM sieht alles)
|
||||
- [ ] Mehrere Ebenen (Untergrund, Obergeschoss)
|
||||
- [ ] Zoom & Pan
|
||||
- [ ] Map-Marker / Notizen
|
||||
|
||||
---
|
||||
|
||||
## Technische Verbesserungen
|
||||
|
||||
### Performance
|
||||
- [ ] Canvas-basiertes Rendering statt DOM für große Maps
|
||||
- [ ] Token-Position nur bei Release senden (nicht während Drag)
|
||||
- [ ] Lazy Loading für Token-Avatare
|
||||
|
||||
### Backend
|
||||
- [ ] Combat Log in Datenbank speichern
|
||||
- [ ] Battle Session Snapshots (Undo/Redo)
|
||||
- [ ] Automatisches Aufräumen inaktiver Sessions
|
||||
|
||||
---
|
||||
|
||||
## Prioritäten
|
||||
|
||||
1. **Hoch** - Grid Config Modal (Phase 2)
|
||||
2. **Hoch** - Initiative Tracker (Phase 3)
|
||||
3. **Hoch** - Player View / TV-Modus (Phase 4)
|
||||
4. **Mittel** - Token Detail Panel mit HP-Management
|
||||
5. **Mittel** - Condition Badges
|
||||
6. **Mittel** - Combatant Template erstellen
|
||||
7. **Niedrig** - Measure Tool
|
||||
8. **Niedrig** - Combat Log
|
||||
9. **Niedrig** - Fog of War
|
||||
Reference in New Issue
Block a user