testgemini/App.tsx

1301 lines
55 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;