Files
discord-rpg-concept/web/src/components/loot-table-builder.tsx

282 lines
14 KiB
TypeScript

/**
* LootTableBuilder Component
* Visual editor for configuring lootbox drop pools with items and currencies.
*/
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Plus, Trash2, Package, Coins, Sparkles, GripVertical, Percent } from "lucide-react";
import { cn } from "@/lib/utils";
// Loot drop types
type LootType = "ITEM" | "CURRENCY" | "XP";
interface LootDrop {
type: LootType;
itemId?: number;
itemName?: string;
amount?: number;
minAmount?: number;
maxAmount?: number;
weight: number;
}
interface LootTableBuilderProps {
pool: LootDrop[];
onChange: (pool: LootDrop[]) => void;
}
const LOOT_TYPES = [
{ value: "ITEM" as LootType, label: "Item", icon: Package, color: "text-purple-400" },
{ value: "CURRENCY" as LootType, label: "Currency", icon: Coins, color: "text-amber-400" },
{ value: "XP" as LootType, label: "XP", icon: Sparkles, color: "text-blue-400" },
];
const getDefaultDrop = (type: LootType): LootDrop => {
switch (type) {
case "ITEM":
return { type: "ITEM", itemId: 0, itemName: "", weight: 10 };
case "CURRENCY":
return { type: "CURRENCY", minAmount: 100, maxAmount: 500, weight: 30 };
case "XP":
return { type: "XP", minAmount: 50, maxAmount: 200, weight: 20 };
}
};
export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
// Calculate total weight for probability display
const totalWeight = pool.reduce((sum, drop) => sum + drop.weight, 0);
const addDrop = useCallback(() => {
onChange([...pool, getDefaultDrop("CURRENCY")]);
}, [pool, onChange]);
const removeDrop = useCallback((index: number) => {
onChange(pool.filter((_, i) => i !== index));
}, [pool, onChange]);
const updateDrop = useCallback((index: number, updates: Partial<LootDrop>) => {
onChange(pool.map((drop, i) => i === index ? { ...drop, ...updates } : drop));
}, [pool, onChange]);
const changeDropType = useCallback((index: number, newType: LootType) => {
onChange(pool.map((drop, i) => i === index ? getDefaultDrop(newType) : drop));
}, [pool, onChange]);
const getProbability = (weight: number): string => {
if (totalWeight === 0) return "0%";
return ((weight / totalWeight) * 100).toFixed(1) + "%";
};
if (pool.length === 0) {
return (
<div className="border border-dashed border-border/50 rounded-lg p-6 text-center">
<Package className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
No drops configured yet
</p>
<Button type="button" variant="outline" size="sm" onClick={addDrop}>
<Plus className="h-4 w-4 mr-2" />
Add Drop
</Button>
</div>
);
}
return (
<div className="space-y-3">
{/* Probability Summary Bar */}
{pool.length > 0 && (
<div className="flex h-6 rounded-lg overflow-hidden">
{pool.map((drop, index) => {
const lootType = LOOT_TYPES.find(t => t.value === drop.type);
const probability = totalWeight > 0 ? (drop.weight / totalWeight) * 100 : 0;
if (probability < 1) return null;
const colors: Record<LootType, string> = {
ITEM: "bg-purple-500",
CURRENCY: "bg-amber-500",
XP: "bg-blue-500",
};
return (
<div
key={index}
className={cn(
"h-full transition-all duration-300 relative group cursor-pointer",
colors[drop.type]
)}
style={{ width: `${probability}%` }}
title={`${lootType?.label}: ${probability.toFixed(1)}%`}
>
{probability > 10 && (
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-white">
{probability.toFixed(0)}%
</span>
)}
</div>
);
})}
</div>
)}
{/* Drop List */}
<div className="space-y-2">
{pool.map((drop, index) => {
return (
<Card key={index} className="bg-muted/20 border-border/30">
<CardContent className="p-4 space-y-3">
{/* Header Row */}
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-muted-foreground/50 cursor-grab" />
{/* Type Selector */}
<Select
value={drop.type}
onValueChange={(value) => changeDropType(index, value as LootType)}
>
<SelectTrigger className="w-32 bg-background/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<type.icon className={cn("h-4 w-4", type.color)} />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Probability Badge */}
<div className="flex items-center gap-1 text-sm text-muted-foreground ml-auto">
<Percent className="h-3 w-3" />
{getProbability(drop.weight)}
</div>
{/* Delete Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDrop(index)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Type-Specific Fields */}
<div className="grid grid-cols-2 gap-3">
{drop.type === "ITEM" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Item ID</Label>
<Input
type="number"
value={drop.itemId || ""}
onChange={(e) => updateDrop(index, { itemId: parseInt(e.target.value) || 0 })}
placeholder="Item ID"
className="bg-background/50 h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Item Name (Optional)</Label>
<Input
value={drop.itemName || ""}
onChange={(e) => updateDrop(index, { itemName: e.target.value })}
placeholder="For reference only"
className="bg-background/50 h-8 text-sm"
/>
</div>
</>
)}
{(drop.type === "CURRENCY" || drop.type === "XP") && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Min Amount</Label>
<Input
type="number"
value={drop.minAmount ?? drop.amount ?? 0}
onChange={(e) => {
const min = parseInt(e.target.value) || 0;
const currentMax = drop.maxAmount ?? drop.amount ?? min;
updateDrop(index, {
minAmount: min,
maxAmount: Math.max(min, currentMax),
amount: undefined // Clear amount
});
}}
className="bg-background/50 h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Amount</Label>
<Input
type="number"
value={drop.maxAmount ?? drop.amount ?? 0}
onChange={(e) => {
const max = parseInt(e.target.value) || 0;
const currentMin = drop.minAmount ?? drop.amount ?? max;
updateDrop(index, {
minAmount: Math.min(currentMin, max),
maxAmount: max,
amount: undefined // Clear amount
});
}}
className="bg-background/50 h-8 text-sm"
/>
</div>
</>
)}
</div>
{/* Weight Slider */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Weight</Label>
<span className="text-xs text-muted-foreground">{drop.weight}</span>
</div>
<Slider
value={[drop.weight]}
onValueChange={(values) => updateDrop(index, { weight: values[0] ?? drop.weight })}
min={1}
max={100}
step={1}
className="py-1"
/>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Add Button */}
<Button type="button" variant="outline" size="sm" onClick={addDrop}>
<Plus className="h-4 w-4 mr-2" />
Add Drop
</Button>
</div>
);
}
export default LootTableBuilder;