testgemini/App.tsx

1301 lines
55 KiB
TypeScript
Raw Normal View History

2026-01-26 00:57:06 +08:00
import React, { useState } from 'react';
import { UploadZone } from './components/UploadZone';
import { AnimationPreview } from './components/AnimationPreview';
import { generateSpriteSheet, generateCharacterPortrait, generateDirectionalSpriteSheet, generateGameMap, MapType, generateGameItem, ItemType } from './services/geminiService';
import { sliceSpriteSheet } from './utils/imageUtils';
import { AppState } from './types';
import { Wand2, Zap, LayoutGrid, Loader2, RotateCcw, Image, Type, User, Film, Download, Move, Map, Sword } from 'lucide-react';
const App: React.FC = () => {
const [appState, setAppState] = useState<AppState>(AppState.IDLE);
const [originalImage, setOriginalImage] = useState<string | null>(null);
const [actionPrompt, setActionPrompt] = useState<string>("");
const [generatedFrames, setGeneratedFrames] = useState<string[]>([]);
const [fps, setFps] = useState<number>(6);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Configuration state - Default to 4 for stability
const [frameCount, setFrameCount] = useState<number>(4);
// Mode: 'image' = with reference image, 'text' = text-only generation
const [mode, setMode] = useState<'image' | 'text'>('text');
const [characterDescription, setCharacterDescription] = useState<string>("");
// Feature: 'animation', 'portrait', 'directional', 'map', or 'item'
const [feature, setFeature] = useState<'animation' | 'portrait' | 'directional' | 'map' | 'item'>('animation');
const [portraitImage, setPortraitImage] = useState<string | null>(null);
const [portraitStyle, setPortraitStyle] = useState<string>('2D卡通风格');
// Directional sprite sheet state
const [directionalSpriteSheet, setDirectionalSpriteSheet] = useState<string | null>(null);
const [directionalFrames, setDirectionalFrames] = useState<string[][]>([[], [], [], []]);
const [selectedDirection, setSelectedDirection] = useState<number>(0);
const [directionalAction, setDirectionalAction] = useState<string>('行走');
const [directionalFps, setDirectionalFps] = useState<number>(6);
// Map generation state
const [mapImage, setMapImage] = useState<string | null>(null);
const [mapType, setMapType] = useState<MapType>('rpg');
const [mapDescription, setMapDescription] = useState<string>('');
const [mapStyle, setMapStyle] = useState<string>('像素风格');
// Item generation state
const [itemImage, setItemImage] = useState<string | null>(null);
const [itemType, setItemType] = useState<ItemType>('weapon');
const [itemDescription, setItemDescription] = useState<string>('');
const [itemStyle, setItemStyle] = useState<string>('像素风格');
const [itemQuantity, setItemQuantity] = useState<'single' | 'set'>('single');
// Step 1: Handle Image Upload
const handleImageSelected = (base64: string) => {
setOriginalImage(base64);
setAppState(AppState.READY_TO_GENERATE);
setErrorMsg(null);
// Pre-fill a default action if empty to encourage user
if (!actionPrompt) setActionPrompt("闲置动作");
};
// Step 2: Generate Animation
const handleGenerate = async () => {
if (mode === 'image' && !originalImage) {
setErrorMsg("请先上传图片");
return;
}
if (mode === 'text' && !characterDescription) {
setErrorMsg("请输入角色描述");
return;
}
if (!actionPrompt) {
setErrorMsg("请输入动作描述");
return;
}
setAppState(AppState.GENERATING);
setErrorMsg(null);
setGeneratedFrames([]);
// Calculate rows and cols based on frame count
// NOTE: This must match the logic in geminiService implicitly via params
let rows = 2;
let cols = 2; // Default 4 frames (2x2)
if (frameCount === 4) { rows = 2; cols = 2; }
if (frameCount === 6) { rows = 2; cols = 3; }
if (frameCount === 8) { rows = 2; cols = 4; }
if (frameCount === 9) { rows = 3; cols = 3; }
if (frameCount === 16) { rows = 4; cols = 4; }
try {
const spriteSheetBase64 = await generateSpriteSheet(
mode === 'image' ? originalImage : null,
actionPrompt,
rows,
cols,
mode === 'text' ? characterDescription : undefined
);
// Slice the grid using Fixed Grid slicing
const frames = await sliceSpriteSheet(spriteSheetBase64, rows, cols, true);
if (!frames || frames.length === 0) {
throw new Error("图像处理失败,请重试。");
}
setGeneratedFrames(frames);
setAppState(AppState.COMPLETE);
} catch (err: any) {
console.error("Generation Error:", err);
setErrorMsg(err.message || "生成失败,请尝试不同的提示词。");
setAppState(AppState.ERROR);
}
};
const handleReset = () => {
setAppState(AppState.IDLE);
setGeneratedFrames([]);
setPortraitImage(null);
setDirectionalSpriteSheet(null);
setDirectionalFrames([[], [], [], []]);
setMapImage(null);
setItemImage(null);
setErrorMsg(null);
};
// Generate Portrait
const handleGeneratePortrait = async () => {
if (!characterDescription) {
setErrorMsg("请输入角色描述");
return;
}
setAppState(AppState.GENERATING);
setErrorMsg(null);
setPortraitImage(null);
try {
const portrait = await generateCharacterPortrait(
characterDescription,
portraitStyle,
'3:4'
);
setPortraitImage(portrait);
setAppState(AppState.COMPLETE);
} catch (err: any) {
console.error("Portrait Generation Error:", err);
setErrorMsg(err.message || "立绘生成失败,请重试。");
setAppState(AppState.ERROR);
}
};
// Download portrait
const handleDownloadPortrait = () => {
if (!portraitImage) return;
const link = document.createElement('a');
link.href = portraitImage;
link.download = 'character-portrait.png';
link.click();
};
// Generate Directional Sprite Sheet
const handleGenerateDirectional = async () => {
if (mode === 'image' && !originalImage) {
setErrorMsg("请先上传图片");
return;
}
if (mode === 'text' && !characterDescription) {
setErrorMsg("请输入角色描述");
return;
}
setAppState(AppState.GENERATING);
setErrorMsg(null);
setDirectionalSpriteSheet(null);
setDirectionalFrames([[], [], [], []]);
try {
const spriteSheetBase64 = await generateDirectionalSpriteSheet(
mode === 'image' ? originalImage : null,
directionalAction,
mode === 'text' ? characterDescription : undefined
);
setDirectionalSpriteSheet(spriteSheetBase64);
// Slice into 4 rows (directions), each with 4 frames
const allFrames = await sliceSpriteSheet(spriteSheetBase64, 4, 4, true);
// Split into 4 directions
const dirFrames: string[][] = [
allFrames.slice(0, 4), // Down
allFrames.slice(4, 8), // Left
allFrames.slice(8, 12), // Right
allFrames.slice(12, 16) // Up
];
setDirectionalFrames(dirFrames);
setAppState(AppState.COMPLETE);
} catch (err: any) {
console.error("Directional Generation Error:", err);
setErrorMsg(err.message || "序列帧生成失败,请重试。");
setAppState(AppState.ERROR);
}
};
// Download directional sprite sheet
const handleDownloadDirectional = () => {
if (!directionalSpriteSheet) return;
const link = document.createElement('a');
link.href = directionalSpriteSheet;
link.download = 'directional-sprite-sheet.png';
link.click();
};
const directionLabels = ['向下 (正面)', '向左', '向右', '向上 (背面)'];
// Generate Map
const handleGenerateMap = async () => {
if (!mapDescription) {
setErrorMsg("请输入地图描述");
return;
}
setAppState(AppState.GENERATING);
setErrorMsg(null);
setMapImage(null);
try {
const map = await generateGameMap(mapType, mapDescription, mapStyle);
setMapImage(map);
setAppState(AppState.COMPLETE);
} catch (err: any) {
console.error("Map Generation Error:", err);
setErrorMsg(err.message || "地图生成失败,请重试。");
setAppState(AppState.ERROR);
}
};
// Download map
const handleDownloadMap = () => {
if (!mapImage) return;
const link = document.createElement('a');
link.href = mapImage;
link.download = `game-map-${mapType}.png`;
link.click();
};
const mapTypeLabels: Record<MapType, string> = {
rpg: 'RPG 场景',
tilemap: '瓦片素材',
platformer: '横版关卡',
strategy: '战棋地图',
world: '世界地图'
};
const mapStyleOptions = ['像素风格', '卡通风格', '写实风格', '手绘风格'];
// Generate Item
const handleGenerateItem = async () => {
if (!itemDescription) {
setErrorMsg("请输入道具描述");
return;
}
setAppState(AppState.GENERATING);
setErrorMsg(null);
setItemImage(null);
try {
const item = await generateGameItem(itemType, itemDescription, itemStyle, itemQuantity);
setItemImage(item);
setAppState(AppState.COMPLETE);
} catch (err: any) {
console.error("Item Generation Error:", err);
setErrorMsg(err.message || "道具生成失败,请重试。");
setAppState(AppState.ERROR);
}
};
// Download item
const handleDownloadItem = () => {
if (!itemImage) return;
const link = document.createElement('a');
link.href = itemImage;
link.download = `game-item-${itemType}.png`;
link.click();
};
const itemTypeLabels: Record<ItemType, string> = {
weapon: '武器',
armor: '防具',
potion: '药水/消耗品',
material: '材料',
treasure: '宝箱/财宝',
accessory: '饰品'
};
const itemStyleOptions = ['像素风格', '卡通风格', '写实风格', '手绘风格'];
return (
<div className="min-h-screen bg-[#F8FAFC] flex flex-col font-sans pb-10">
{/* Header */}
<header className="bg-white border-b border-gray-100 sticky top-0 z-10 shadow-sm">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-lime-400 rounded-lg flex items-center justify-center shadow-sm">
<Zap className="text-black fill-current" size={20} />
</div>
<span className="font-bold text-xl tracking-tight text-gray-800">ToonMotion</span>
</div>
<div className="text-sm text-gray-600 font-medium bg-gray-100 px-3 py-1 rounded-full border border-gray-200">
Powered by Gemini 2.5
</div>
</div>
</header>
<main className="flex-1 max-w-6xl mx-auto px-6 py-8 w-full">
{/* Feature Tabs */}
<div className="mb-6 flex gap-4 border-b border-gray-200">
<button
onClick={() => setFeature('animation')}
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
feature === 'animation'
? 'border-lime-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Film size={18} />
</button>
<button
onClick={() => setFeature('portrait')}
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
feature === 'portrait'
? 'border-lime-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<User size={18} />
</button>
<button
onClick={() => setFeature('directional')}
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
feature === 'directional'
? 'border-lime-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Move size={18} />
</button>
<button
onClick={() => setFeature('map')}
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
feature === 'map'
? 'border-lime-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Map size={18} />
</button>
<button
onClick={() => setFeature('item')}
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
feature === 'item'
? 'border-lime-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Sword size={18} />
</button>
</div>
{/* Intro */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{feature === 'animation' ? '让你的角色动起来' : feature === 'portrait' ? '生成角色立绘' : feature === 'directional' ? '四方向序列帧' : feature === 'map' ? '游戏地图生成' : '武器道具生成'}
</h1>
<p className="text-gray-600 max-w-2xl">
{feature === 'portrait'
? '描述你想要的角色Gemini 将为你生成高质量的角色立绘。'
: feature === 'directional'
? '生成角色的四个方向(上下左右)动画序列帧,适用于 RPG 游戏开发。'
: feature === 'map'
? '生成各类游戏地图,包括 RPG 场景、瓦片素材、横版关卡、战棋地图和世界地图。'
: feature === 'item'
? '生成游戏中的武器、防具、药水、材料等道具图标,支持单个或批量生成。'
: mode === 'image'
? '上传静态角色图片输入动作描述Gemini 将为你生成逐帧动画。'
: '描述你想要的角色和动作Gemini 将从零开始生成逐帧动画。'}
</p>
</div>
{feature === 'animation' ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Input */}
<div className="lg:col-span-5 space-y-6">
{/* Mode Switch */}
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
<div className="flex gap-2">
<button
onClick={() => setMode('text')}
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
mode === 'text'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
}`}
>
<Type size={18} />
</button>
<button
onClick={() => setMode('image')}
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
mode === 'image'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
}`}
>
<Image size={18} />
</button>
</div>
</div>
{/* Conditional: Upload or Character Description */}
{mode === 'image' ? (
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<UploadZone
selectedImage={originalImage}
onImageSelected={handleImageSelected}
onClear={() => {
setOriginalImage(null);
setAppState(AppState.IDLE);
setGeneratedFrames([]);
setErrorMsg(null);
}}
/>
</div>
) : (
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<textarea
placeholder="例如:一只橙色的小猫,大眼睛,戴着红色围巾"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={characterDescription}
onChange={(e) => setCharacterDescription(e.target.value)}
/>
{/* Quick Character Prompts */}
<div className="flex gap-2 mt-3 flex-wrap">
{['可爱的小猫咪', '像素风格勇士', '圆滚滚的机器人', 'Q版小女孩'].map((p) => (
<button
key={p}
onClick={() => setCharacterDescription(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
)}
{/* Step 2: Settings Card */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{appState === AppState.ERROR && (
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
<RotateCcw size={12} />
</button>
)}
</div>
<div className="space-y-6">
{/* Motion Prompt */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<textarea
placeholder="例如:奔跑循环, 开心地跳跃, 施法动作"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-24
${!actionPrompt && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={actionPrompt}
onChange={(e) => setActionPrompt(e.target.value)}
/>
</div>
{/* Quick Prompts */}
<div className="flex gap-2 mt-2 flex-wrap">
{['奔跑循环', '发送爱心', '攻击动作', '受伤倒地'].map((p) => (
<button
key={p}
onClick={() => setActionPrompt(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
{/* Frame Count & FPS Row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">
</label>
<div className="relative">
<select
value={frameCount}
onChange={(e) => setFrameCount(Number(e.target.value))}
className="w-full bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-xl focus:ring-lime-500 focus:border-lime-500 block p-2.5 outline-none appearance-none"
>
<option value={4}>4 ()</option>
<option value={6}>6 </option>
<option value={8}>8 </option>
<option value={9}>9 </option>
<option value={16}>16 </option>
</select>
<LayoutGrid size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase mb-2 flex justify-between">
<span></span>
<span className="text-lime-600">{fps} FPS</span>
</label>
<input
type="range"
min="1"
max="12"
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
className="w-full accent-lime-500 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer mt-2"
/>
</div>
</div>
<button
onClick={handleGenerate}
disabled={appState === AppState.GENERATING}
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-2 shadow-md transition-all transform active:scale-[0.99]
${(appState === AppState.GENERATING)
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
>
{appState === AppState.GENERATING ? (
<>
<Loader2 size={20} className="animate-spin" /> ...
</>
) : (
<>
<Wand2 size={20} />
</>
)}
</button>
{errorMsg && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-2 animate-pulse">
{errorMsg}
</div>
)}
</div>
</div>
</div>
{/* Right Column: Result */}
<div className="lg:col-span-7 space-y-6">
<div className="sticky top-24">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<AnimationPreview
frames={generatedFrames}
fps={fps}
isLoading={appState === AppState.GENERATING}
/>
</div>
</div>
</div>
) : feature === 'portrait' ? (
/* Portrait Feature UI */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Input */}
<div className="lg:col-span-5 space-y-6">
{/* Character Description */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<textarea
placeholder="例如:一位身穿蓝色铠甲的女骑士,金色长发,手持银色长剑,英姿飒爽"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-32
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={characterDescription}
onChange={(e) => setCharacterDescription(e.target.value)}
/>
{/* Quick Character Prompts */}
<div className="flex gap-2 mt-3 flex-wrap">
{['蓝发魔法少女', '可爱的精灵', '帅气的剑士', '萌系兽耳娘'].map((p) => (
<button
key={p}
onClick={() => setCharacterDescription(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
{/* Style Selection */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<div className="grid grid-cols-2 gap-2">
{['2D卡通风格', '日系动漫风格', '像素风格', 'Q版可爱风格'].map((style) => (
<button
key={style}
onClick={() => setPortraitStyle(style)}
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
portraitStyle === style
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{style}
</button>
))}
</div>
<button
onClick={handleGeneratePortrait}
disabled={appState === AppState.GENERATING || !characterDescription}
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
${(appState === AppState.GENERATING || !characterDescription)
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
>
{appState === AppState.GENERATING ? (
<>
<Loader2 size={20} className="animate-spin" /> ...
</>
) : (
<>
<Wand2 size={20} />
</>
)}
</button>
{errorMsg && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
{errorMsg}
</div>
)}
</div>
</div>
{/* Right Column: Portrait Result */}
<div className="lg:col-span-7 space-y-6">
<div className="sticky top-24">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{portraitImage && (
<button
onClick={handleDownloadPortrait}
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
>
<Download size={16} />
</button>
)}
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
{appState === AppState.GENERATING ? (
<div className="text-center text-gray-400">
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
<p>...</p>
</div>
) : portraitImage ? (
<img
src={portraitImage}
alt="Generated Portrait"
className="max-w-full max-h-[500px] rounded-xl shadow-lg"
/>
) : (
<div className="text-center text-gray-400">
<User size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
</div>
) : feature === 'directional' ? (
/* Directional Sprite Sheet Feature UI */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Input */}
<div className="lg:col-span-5 space-y-6">
{/* Mode Switch */}
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
<div className="flex gap-2">
<button
onClick={() => setMode('text')}
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
mode === 'text'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
}`}
>
<Type size={18} />
</button>
<button
onClick={() => setMode('image')}
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
mode === 'image'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
}`}
>
<Image size={18} />
</button>
</div>
</div>
{/* Conditional: Upload or Character Description */}
{mode === 'image' ? (
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<UploadZone
selectedImage={originalImage}
onImageSelected={handleImageSelected}
onClear={() => {
setOriginalImage(null);
setAppState(AppState.IDLE);
setDirectionalSpriteSheet(null);
setDirectionalFrames([[], [], [], []]);
setErrorMsg(null);
}}
/>
</div>
) : (
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<textarea
placeholder="例如:一只橙色的小猫,大眼睛,戴着红色围巾"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={characterDescription}
onChange={(e) => setCharacterDescription(e.target.value)}
/>
<div className="flex gap-2 mt-3 flex-wrap">
{['像素风格勇士', 'Q版小女孩', '可爱的小猫咪', '圆滚滚的机器人'].map((p) => (
<button
key={p}
onClick={() => setCharacterDescription(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
)}
{/* Action Settings */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{appState === AppState.ERROR && (
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
<RotateCcw size={12} />
</button>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-2">
{['行走', '奔跑', '攻击', '闲置'].map((action) => (
<button
key={action}
onClick={() => setDirectionalAction(action)}
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
directionalAction === action
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{action}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase mb-2 flex justify-between">
<span></span>
<span className="text-lime-600">{directionalFps} FPS</span>
</label>
<input
type="range"
min="1"
max="12"
value={directionalFps}
onChange={(e) => setDirectionalFps(Number(e.target.value))}
className="w-full accent-lime-500 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
<button
onClick={handleGenerateDirectional}
disabled={appState === AppState.GENERATING}
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-2 shadow-md transition-all transform active:scale-[0.99]
${(appState === AppState.GENERATING)
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
>
{appState === AppState.GENERATING ? (
<>
<Loader2 size={20} className="animate-spin" /> ...
</>
) : (
<>
<Wand2 size={20} />
</>
)}
</button>
{errorMsg && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-2 animate-pulse">
{errorMsg}
</div>
)}
</div>
</div>
</div>
{/* Right Column: Result */}
<div className="lg:col-span-7 space-y-6">
<div className="sticky top-24">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{directionalSpriteSheet && (
<button
onClick={handleDownloadDirectional}
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
>
<Download size={16} />
</button>
)}
</div>
{/* Direction Tabs */}
{directionalFrames[0].length > 0 && (
<div className="flex gap-2 mb-4">
{directionLabels.map((label, idx) => (
<button
key={idx}
onClick={() => setSelectedDirection(idx)}
className={`flex-1 py-2 px-3 rounded-lg font-medium text-sm transition-all ${
selectedDirection === idx
? 'bg-lime-400 text-black shadow-md'
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'
}`}
>
{label}
</button>
))}
</div>
)}
{/* Preview */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
{appState === AppState.GENERATING ? (
<div className="text-center text-gray-400 py-20">
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
<p>...</p>
</div>
) : directionalFrames[0].length > 0 ? (
<div className="space-y-4">
<div className="text-center text-sm text-gray-500 mb-2">
{directionLabels[selectedDirection]} -
</div>
<AnimationPreview
frames={directionalFrames[selectedDirection]}
fps={directionalFps}
isLoading={false}
/>
</div>
) : (
<div className="text-center text-gray-400 py-20">
<Move size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
{/* Full Sprite Sheet Preview */}
{directionalSpriteSheet && (
<div className="mt-4 bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h3 className="font-semibold text-gray-800 mb-3"></h3>
<img
src={directionalSpriteSheet}
alt="Full Sprite Sheet"
className="w-full rounded-lg border border-gray-200"
/>
</div>
)}
</div>
</div>
</div>
) : feature === 'map' ? (
/* Map Generation Feature UI */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Input */}
<div className="lg:col-span-5 space-y-6">
{/* Map Type Selection */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(mapTypeLabels) as MapType[]).map((type) => (
<button
key={type}
onClick={() => setMapType(type)}
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
mapType === type
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{mapTypeLabels[type]}
</button>
))}
</div>
</div>
{/* Map Description */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<textarea
placeholder="例如:森林中的小村庄,有河流穿过,远处有山脉"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
${!mapDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={mapDescription}
onChange={(e) => setMapDescription(e.target.value)}
/>
<div className="flex gap-2 mt-3 flex-wrap">
{['森林村庄', '沙漠遗迹', '雪山城堡', '海边港口', '地下洞穴'].map((p) => (
<button
key={p}
onClick={() => setMapDescription(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
{/* Style Selection */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{appState === AppState.ERROR && (
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
<RotateCcw size={12} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{mapStyleOptions.map((style) => (
<button
key={style}
onClick={() => setMapStyle(style)}
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
mapStyle === style
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{style}
</button>
))}
</div>
<button
onClick={handleGenerateMap}
disabled={appState === AppState.GENERATING || !mapDescription}
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
${(appState === AppState.GENERATING || !mapDescription)
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
>
{appState === AppState.GENERATING ? (
<>
<Loader2 size={20} className="animate-spin" /> ...
</>
) : (
<>
<Wand2 size={20} />
</>
)}
</button>
{errorMsg && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
{errorMsg}
</div>
)}
</div>
</div>
{/* Right Column: Map Result */}
<div className="lg:col-span-7 space-y-6">
<div className="sticky top-24">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{mapImage && (
<button
onClick={handleDownloadMap}
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
>
<Download size={16} />
</button>
)}
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
{appState === AppState.GENERATING ? (
<div className="text-center text-gray-400">
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
<p>{mapTypeLabels[mapType]}...</p>
</div>
) : mapImage ? (
<img
src={mapImage}
alt="Generated Map"
className="max-w-full max-h-[600px] rounded-xl shadow-lg"
/>
) : (
<div className="text-center text-gray-400">
<Map size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
</div>
) : (
/* Item Generation Feature UI */
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Input */}
<div className="lg:col-span-5 space-y-6">
{/* Item Type Selection */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(itemTypeLabels) as ItemType[]).map((type) => (
<button
key={type}
onClick={() => setItemType(type)}
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
itemType === type
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{itemTypeLabels[type]}
</button>
))}
</div>
</div>
{/* Item Description */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
</div>
<textarea
placeholder="例如:火焰之剑,发出红色光芒,剑身刻有古老符文"
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
${!itemDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
`}
value={itemDescription}
onChange={(e) => setItemDescription(e.target.value)}
/>
<div className="flex gap-2 mt-3 flex-wrap">
{['传说之剑', '治疗药水', '魔法宝石', '黄金宝箱', '皮革护甲'].map((p) => (
<button
key={p}
onClick={() => setItemDescription(p)}
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
>
{p}
</button>
))}
</div>
</div>
{/* Style & Quantity Selection */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{appState === AppState.ERROR && (
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
<RotateCcw size={12} />
</button>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-2">
{itemStyleOptions.map((style) => (
<button
key={style}
onClick={() => setItemStyle(style)}
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
itemStyle === style
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{style}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setItemQuantity('single')}
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
itemQuantity === 'single'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
</button>
<button
onClick={() => setItemQuantity('set')}
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
itemQuantity === 'set'
? 'bg-lime-400 text-black shadow-md'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
(6)
</button>
</div>
</div>
</div>
<button
onClick={handleGenerateItem}
disabled={appState === AppState.GENERATING || !itemDescription}
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
${(appState === AppState.GENERATING || !itemDescription)
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
>
{appState === AppState.GENERATING ? (
<>
<Loader2 size={20} className="animate-spin" /> ...
</>
) : (
<>
<Wand2 size={20} />
</>
)}
</button>
{errorMsg && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
{errorMsg}
</div>
)}
</div>
</div>
{/* Right Column: Item Result */}
<div className="lg:col-span-7 space-y-6">
<div className="sticky top-24">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
</h2>
{itemImage && (
<button
onClick={handleDownloadItem}
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
>
<Download size={16} />
</button>
)}
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
{appState === AppState.GENERATING ? (
<div className="text-center text-gray-400">
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
<p>{itemTypeLabels[itemType]}...</p>
</div>
) : itemImage ? (
<img
src={itemImage}
alt="Generated Item"
className="max-w-full max-h-[600px] rounded-xl shadow-lg"
/>
) : (
<div className="text-center text-gray-400">
<Sword size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
};
export default App;