first
This commit is contained in:
37
components/VNEditor.tsx
Normal file
37
components/VNEditor.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface VNEditorProps {
|
||||
scriptText: string;
|
||||
onScriptChange: (text: string) => void;
|
||||
}
|
||||
|
||||
export const VNEditor = forwardRef<HTMLTextAreaElement, VNEditorProps>(({
|
||||
scriptText,
|
||||
onScriptChange
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className="w-[40%] flex flex-col border-r border-gray-800">
|
||||
<div className="flex-1 relative font-mono text-sm leading-6">
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={scriptText}
|
||||
onChange={e => onScriptChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full bg-[#0d1117] p-6 pl-12 outline-none resize-none text-gray-400 caret-cyan-400 z-10 custom-scrollbar"
|
||||
style={{
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
whiteSpace: 'pre',
|
||||
lineHeight: '24px'
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 w-12 h-full bg-[#0b0e14] border-r border-gray-800 flex flex-col items-center py-6 text-[10px] text-gray-700 select-none z-0">
|
||||
{scriptText.split('\n').map((_, i) => (
|
||||
<div key={i} style={{ height: '24px' }}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VNEditor.displayName = 'VNEditor';
|
||||
29
components/VNIcons.tsx
Normal file
29
components/VNIcons.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Icons = {
|
||||
Wait: () => (
|
||||
<svg className="w-4 h-4 inline mr-1 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
Click: () => (
|
||||
<svg className="w-4 h-4 inline mr-1 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
),
|
||||
Lock: () => (
|
||||
<svg className="w-4 h-4 inline mr-1 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
ArrowLeft: () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
),
|
||||
ArrowRight: () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
164
components/VNOutline.tsx
Normal file
164
components/VNOutline.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Statement } from '@/lib/vn-parser';
|
||||
import { Sprite } from '@/lib/sprite-bank';
|
||||
|
||||
interface VNOutlineProps {
|
||||
statements: Statement[];
|
||||
currentSay: Statement | null;
|
||||
onSelectLine: (line: number) => void;
|
||||
isParsing: boolean;
|
||||
spriteBank: Record<string, Sprite[]>;
|
||||
onUpdateSpriteBank: (newBank: Record<string, Sprite[]>) => void;
|
||||
}
|
||||
|
||||
export const VNOutline: React.FC<VNOutlineProps> = ({
|
||||
statements,
|
||||
currentSay,
|
||||
onSelectLine,
|
||||
isParsing,
|
||||
spriteBank,
|
||||
onUpdateSpriteBank
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'outline' | 'sprites'>('outline');
|
||||
const [newCharName, setNewCharName] = useState('');
|
||||
|
||||
const addCharacter = () => {
|
||||
if (!newCharName || spriteBank[newCharName]) return;
|
||||
onUpdateSpriteBank({ ...spriteBank, [newCharName]: [] });
|
||||
setNewCharName('');
|
||||
};
|
||||
|
||||
const removeCharacter = (charName: string) => {
|
||||
const { [charName]: _, ...rest } = spriteBank;
|
||||
onUpdateSpriteBank(rest);
|
||||
};
|
||||
|
||||
const addSprite = (charName: string) => {
|
||||
const id = prompt('Sprite ID (e.g. happy):');
|
||||
if (!id) return;
|
||||
const url = prompt('Sprite Image URL:');
|
||||
if (!url) return;
|
||||
|
||||
const newSprites = [...spriteBank[charName], { id, name: id, url }];
|
||||
onUpdateSpriteBank({ ...spriteBank, [charName]: newSprites });
|
||||
};
|
||||
|
||||
const removeSprite = (charName: string, spriteId: string) => {
|
||||
const newSprites = spriteBank[charName].filter(s => s.id !== spriteId);
|
||||
onUpdateSpriteBank({ ...spriteBank, [charName]: newSprites });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 border-r border-gray-800 flex flex-col bg-[#0b0e14]">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('outline')}
|
||||
className={`flex-1 py-3 text-[10px] font-bold uppercase tracking-widest transition-colors ${activeTab === 'outline' ? 'text-cyan-400 bg-gray-800/50 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
Outline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sprites')}
|
||||
className={`flex-1 py-3 text-[10px] font-bold uppercase tracking-widest transition-colors ${activeTab === 'sprites' ? 'text-cyan-400 bg-gray-800/50 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
Sprite Bank
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{activeTab === 'outline' ? (
|
||||
<div className="p-2 space-y-0.5">
|
||||
{statements.map((s, idx) => {
|
||||
if (s.type === 'comment') return null;
|
||||
const isSay = s.type === 'say';
|
||||
const isActive = isSay && currentSay?.lineNumber === s.lineNumber;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onSelectLine(s.lineNumber)}
|
||||
className={`w-full text-left text-[11px] p-2 rounded transition-all group ${
|
||||
isActive ? 'bg-cyan-950/30 border-l-2 border-cyan-400' : 'hover:bg-gray-800/50 border-l-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-0.5">
|
||||
<span className={`font-bold uppercase text-[9px] tracking-tighter ${
|
||||
isActive ? 'text-cyan-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{s.type}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-700 font-mono">L{s.lineNumber}</span>
|
||||
</div>
|
||||
<div className={`truncate ${isActive ? 'text-cyan-100' : 'text-gray-400 group-hover:text-gray-200'}`}>
|
||||
{s.type === 'say' && (
|
||||
<span className="font-semibold text-cyan-300/80 mr-1">
|
||||
{s.character || 'Narrator'}:
|
||||
</span>
|
||||
)}
|
||||
{s.type === 'say' && (s.segments?.[0]?.text || s.continuations?.[0]?.text || "...")}
|
||||
{s.type === 'wait' && `${s.duration} ${s.skippable ? '(skippable)' : ''}`}
|
||||
{s.type === 'sprite' && `${s.character} (${s.emotion}) at ${s.value}`}
|
||||
{s.type === 'set' && `${s.variable} = ${s.value}`}
|
||||
{s.type === 'fire' && `${s.event} ${s.args?.join(' ') || ''}`}
|
||||
{s.type === 'nextfile' && `Next: ${s.filename}`}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-8">
|
||||
{/* Add Character UI */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newCharName}
|
||||
onChange={e => setNewCharName(e.target.value)}
|
||||
placeholder="Character Name..."
|
||||
className="flex-1 bg-gray-900 border border-gray-800 rounded px-2 py-1 text-xs outline-none focus:border-cyan-500"
|
||||
/>
|
||||
<button
|
||||
onClick={addCharacter}
|
||||
className="bg-cyan-600 hover:bg-cyan-500 text-white px-3 py-1 rounded text-[10px] font-bold"
|
||||
>
|
||||
ADD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{Object.entries(spriteBank).map(([charName, sprites]) => (
|
||||
<div key={charName} className="space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-cyan-900/50 pb-1">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-widest text-cyan-500">
|
||||
{charName}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addSprite(charName)} className="text-[8px] text-gray-400 hover:text-cyan-400 uppercase font-bold tracking-tighter">Add Sprite</button>
|
||||
<button onClick={() => removeCharacter(charName)} className="text-[8px] text-gray-400 hover:text-red-400 uppercase font-bold tracking-tighter">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sprites.map(sprite => (
|
||||
<div key={sprite.id} className="group relative">
|
||||
<div className="aspect-[2/3] bg-gray-900 rounded border border-gray-800 overflow-hidden">
|
||||
<img src={sprite.url} alt={sprite.name} className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/80 p-1 text-[8px] font-bold flex justify-between items-center group-hover:bg-cyan-950/90">
|
||||
<span className="truncate flex-1">{sprite.id}</span>
|
||||
<button
|
||||
onClick={() => removeSprite(charName, sprite.id)}
|
||||
className="text-red-500 hover:text-red-400 ml-1 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
214
components/VNPreview.tsx
Normal file
214
components/VNPreview.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React from "react";
|
||||
import { Statement, getCharColor } from "@/lib/vn-parser";
|
||||
import { Icons } from "./VNIcons";
|
||||
import { POSITION_MAP, Sprite, SpritePosition } from "@/lib/sprite-bank";
|
||||
|
||||
interface VNPreviewProps {
|
||||
currentStatement: Statement | null;
|
||||
currentSayIndex: number;
|
||||
totalPreviewSteps: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
statements: Statement[];
|
||||
spriteBank: Record<string, Sprite[]>;
|
||||
}
|
||||
|
||||
export const VNPreview: React.FC<VNPreviewProps> = ({
|
||||
currentStatement,
|
||||
currentSayIndex,
|
||||
totalPreviewSteps,
|
||||
onPrev,
|
||||
onNext,
|
||||
statements,
|
||||
spriteBank,
|
||||
}) => {
|
||||
// Track state of all characters on scene at the current line
|
||||
const activeSprites: Record<
|
||||
string,
|
||||
{ emotion: string; pos: SpritePosition }
|
||||
> = {};
|
||||
|
||||
statements.forEach((s) => {
|
||||
if (s.lineNumber <= (currentStatement?.lineNumber || 999999)) {
|
||||
if (s.type === "sprite" && s.character) {
|
||||
activeSprites[s.character] = {
|
||||
emotion: s.emotion || "default",
|
||||
pos: (s.value as SpritePosition) || "pos5",
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getSpriteUrlLocal = (char: string, emo: string) => {
|
||||
const bank = spriteBank[char];
|
||||
if (!bank) return null;
|
||||
return bank.find((s) => s.id === emo)?.url || bank[0]?.url || null;
|
||||
};
|
||||
|
||||
const renderDialogueBox = () => {
|
||||
if (!currentStatement || currentStatement.type !== "say") return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{currentStatement.character && (
|
||||
<div
|
||||
className="absolute -top-6 left-4 px-6 py-1 rounded-t-md text-xs font-bold tracking-widest uppercase border-t border-x border-white/20"
|
||||
style={{
|
||||
backgroundColor: `${getCharColor(currentStatement.character)}cc`,
|
||||
color: "#fff",
|
||||
backdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
{currentStatement.character}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-black/60 border border-white/10 p-6 pt-8 rounded-lg rounded-tl-none backdrop-blur-md shadow-2xl min-h-[140px]">
|
||||
<div className="text-xl leading-relaxed text-gray-100 italic font-serif">
|
||||
{[
|
||||
...(currentStatement.segments || []),
|
||||
...(currentStatement.continuations || []),
|
||||
].map((seg, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: seg.modifiers.color || "inherit",
|
||||
fontFamily: seg.modifiers.font || "inherit",
|
||||
fontWeight: seg.modifiers.bold ? "bold" : "normal",
|
||||
fontStyle: seg.modifiers.italic ? "italic" : "normal",
|
||||
textDecoration: `${
|
||||
seg.modifiers.underline ? "underline" : ""
|
||||
} ${
|
||||
seg.modifiers.strikethrough ? "line-through" : ""
|
||||
}`.trim(),
|
||||
}}
|
||||
className="relative group/seg"
|
||||
>
|
||||
{seg.modifiers.wait && <Icons.Wait />}
|
||||
{seg.modifiers.click && <Icons.Click />}
|
||||
{seg.modifiers.clickLock && <Icons.Lock />}
|
||||
{seg.text}{" "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-[#0b0e14] p-8 flex flex-col items-center justify-center relative">
|
||||
<div className="w-full max-w-4xl aspect-video bg-gradient-to-b from-gray-900 to-black rounded-lg overflow-hidden border border-gray-800 shadow-2xl relative group">
|
||||
<div className="absolute top-4 left-6 z-20 flex gap-2">
|
||||
<span className="text-[10px] tracking-[0.2em] uppercase text-gray-500 font-bold bg-black/40 px-3 py-1 rounded backdrop-blur border border-white/5">
|
||||
PREVIEW | {currentStatement?.character || "NARRATOR"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{Object.entries(activeSprites).map(([charName, data]) => {
|
||||
const url = getSpriteUrlLocal(charName, data.emotion);
|
||||
const isSpeaking = charName === currentStatement?.character;
|
||||
const isSmallPos = data.pos === "pos7" || data.pos === "pos8";
|
||||
const baseScale = isSmallPos ? 0.8 : 1.0;
|
||||
const finalScale = isSpeaking ? baseScale * 1.05 : baseScale;
|
||||
const translateY = isSpeaking ? "1rem" : "2rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={charName}
|
||||
style={{
|
||||
left: `${POSITION_MAP[data.pos]}%`,
|
||||
zIndex: isSpeaking ? 20 : 10,
|
||||
transform: `translateX(-50%) translateY(${translateY}) scale(${finalScale})`,
|
||||
}}
|
||||
className="absolute bottom-0 h-[85%] transition-all duration-700"
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={charName}
|
||||
className={`h-full object-contain drop-shadow-[0_0_30px_rgba(0,0,0,0.8)] transition-all duration-700 ${
|
||||
isSpeaking ? "brightness-110" : "brightness-50"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-48 h-full opacity-40"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${getCharColor(
|
||||
charName
|
||||
)}, transparent)`,
|
||||
clipPath: "polygon(20% 0%, 80% 0%, 100% 100%, 0% 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSpeaking && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-4 bg-black/60 px-2 py-0.5 rounded text-[8px] font-bold text-gray-400 border border-white/10 backdrop-blur whitespace-nowrap">
|
||||
{charName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-6 right-6 z-30">
|
||||
{renderDialogueBox()}
|
||||
{!currentStatement && (
|
||||
<div className="text-center text-gray-600 text-sm animate-pulse italic">
|
||||
No statement selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 left-2 flex items-center z-40">
|
||||
<button
|
||||
disabled={currentSayIndex <= 0}
|
||||
onClick={onPrev}
|
||||
className="p-2 text-white/30 hover:text-cyan-400 transition-colors disabled:opacity-0"
|
||||
>
|
||||
<Icons.ArrowLeft />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-2 flex items-center z-40">
|
||||
<button
|
||||
disabled={currentSayIndex >= totalPreviewSteps - 1}
|
||||
onClick={onNext}
|
||||
className="p-2 text-white/30 hover:text-cyan-400 transition-colors disabled:opacity-0"
|
||||
>
|
||||
<Icons.ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-4 text-[10px] font-mono text-gray-600 font-bold z-40">
|
||||
STEP {currentSayIndex + 1} OF {totalPreviewSteps}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center max-w-md">
|
||||
<p className="text-[10px] text-gray-600 uppercase tracking-widest font-bold mb-2">
|
||||
Controls
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-[11px] text-gray-500">
|
||||
<div className="bg-gray-900/50 p-2 rounded border border-gray-800">
|
||||
<span className="text-cyan-500 font-bold">TYPE</span> to edit
|
||||
script.
|
||||
</div>
|
||||
<div className="bg-gray-900/50 p-2 rounded border border-gray-800">
|
||||
<span className="text-cyan-500 font-bold">ARROWS</span> to preview
|
||||
dialogue steps.
|
||||
</div>
|
||||
<div className="bg-gray-900/50 p-2 rounded border border-gray-800">
|
||||
<span className="text-cyan-500 font-bold">OUTLINE</span> to jump to
|
||||
specific lines.
|
||||
</div>
|
||||
<div className="bg-gray-900/50 p-2 rounded border border-gray-800">
|
||||
<span className="text-cyan-500 font-bold">SAVE</span> to download
|
||||
raw script file.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user