194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { DEFAULT_SCRIPT, parseScript, Statement } from '@/lib/vn-parser';
|
|
import { VNOutline } from '@/components/VNOutline';
|
|
import { VNEditor } from '@/components/VNEditor';
|
|
import { VNPreview } from '@/components/VNPreview';
|
|
import { INITIAL_SPRITE_BANK, Sprite } from '@/lib/sprite-bank';
|
|
|
|
export default function VisualNovelEditorPage() {
|
|
const [scriptText, setScriptText] = useState(DEFAULT_SCRIPT);
|
|
const [fileName, setFileName] = useState("scene_01.vns");
|
|
const [statements, setStatements] = useState<Statement[]>([]);
|
|
const [spriteBank, setSpriteBank] = useState<Record<string, Sprite[]>>(INITIAL_SPRITE_BANK);
|
|
const [currentSayIndex, setCurrentSayIndex] = useState(0);
|
|
const [isParsing, setIsParsing] = useState(false);
|
|
const editorRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const updateSpriteBank = (newBank: Record<string, Sprite[]>) => {
|
|
setSpriteBank(newBank);
|
|
};
|
|
|
|
// Parse Debounce
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setIsParsing(true);
|
|
const parsed = parseScript(scriptText);
|
|
setStatements(parsed);
|
|
|
|
// Sync Sprite Bank from define_sprite commands in script
|
|
const newScriptBank: Record<string, Sprite[]> = {};
|
|
parsed.forEach(s => {
|
|
if (s.type === 'comment' && s.text?.startsWith('DEF_SPRITE')) {
|
|
const parts = s.text.split(' ');
|
|
const char = parts[1];
|
|
const id = parts[2];
|
|
const url = parts[3];
|
|
if (char && id && url) {
|
|
if (!newScriptBank[char]) newScriptBank[char] = [];
|
|
if (!newScriptBank[char].some(sp => sp.id === id)) {
|
|
newScriptBank[char].push({ id, name: id, url });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (Object.keys(newScriptBank).length > 0) {
|
|
setSpriteBank(prev => {
|
|
const merged = { ...prev };
|
|
Object.entries(newScriptBank).forEach(([char, sprites]) => {
|
|
if (!merged[char]) {
|
|
merged[char] = sprites;
|
|
} else {
|
|
const existingIds = new Set(sprites.map(s => s.id));
|
|
merged[char] = [
|
|
...merged[char].filter(s => !existingIds.has(s.id)),
|
|
...sprites
|
|
];
|
|
}
|
|
});
|
|
return merged;
|
|
});
|
|
}
|
|
|
|
setIsParsing(false);
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}, [scriptText]);
|
|
|
|
const previewStatements = useMemo(() =>
|
|
statements.filter(s => s.type === 'say'),
|
|
[statements]
|
|
);
|
|
|
|
const currentStatement = previewStatements[currentSayIndex] || null;
|
|
|
|
const handleSave = () => {
|
|
const blob = new Blob([scriptText], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = fileName;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
if (typeof e.target?.result === 'string') {
|
|
setScriptText(e.target.result);
|
|
setFileName(file.name);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const scrollToLine = (line: number) => {
|
|
if (!editorRef.current) return;
|
|
const lines = scriptText.split('\n');
|
|
let pos = 0;
|
|
for (let i = 0; i < line - 1; i++) {
|
|
pos += lines[i].length + 1;
|
|
}
|
|
editorRef.current.focus();
|
|
editorRef.current.setSelectionRange(pos, pos + (lines[line-1]?.length || 0));
|
|
|
|
const lineHeight = 24;
|
|
editorRef.current.scrollTop = (line - 5) * lineHeight;
|
|
|
|
const previewIdx = previewStatements.findIndex(s => s.lineNumber === line);
|
|
if (previewIdx !== -1) {
|
|
setCurrentSayIndex(previewIdx);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen w-full bg-[#0d1117] text-gray-300 font-sans overflow-hidden">
|
|
<VNOutline
|
|
statements={statements}
|
|
currentSay={currentStatement}
|
|
onSelectLine={scrollToLine}
|
|
isParsing={isParsing}
|
|
spriteBank={spriteBank}
|
|
onUpdateSpriteBank={updateSpriteBank}
|
|
/>
|
|
|
|
<div className="flex-1 flex flex-col">
|
|
{/* Header */}
|
|
<div className="h-14 border-b border-gray-800 flex items-center px-6 justify-between bg-[#0d1117]">
|
|
<div className="flex items-center gap-4">
|
|
<input
|
|
value={fileName}
|
|
onChange={e => setFileName(e.target.value)}
|
|
className="bg-transparent border-b border-gray-700 focus:border-cyan-400 outline-none px-2 py-1 text-sm font-mono text-cyan-400"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<label className="cursor-pointer bg-gray-800 hover:bg-gray-700 px-4 py-1.5 rounded text-xs font-bold transition-all border border-gray-700">
|
|
LOAD
|
|
<input type="file" className="hidden" onChange={handleLoad} accept=".vns,.txt" />
|
|
</label>
|
|
<button
|
|
onClick={handleSave}
|
|
className="bg-cyan-500 hover:bg-cyan-400 text-black px-4 py-1.5 rounded text-xs font-bold transition-all shadow-[0_0_15px_rgba(0,229,255,0.3)]"
|
|
>
|
|
SAVE SCRIPT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex overflow-hidden">
|
|
<VNEditor
|
|
ref={editorRef}
|
|
scriptText={scriptText}
|
|
onScriptChange={setScriptText}
|
|
/>
|
|
|
|
<VNPreview
|
|
currentStatement={currentStatement}
|
|
currentSayIndex={currentSayIndex}
|
|
totalPreviewSteps={previewStatements.length}
|
|
onPrev={() => setCurrentSayIndex(prev => Math.max(0, prev - 1))}
|
|
onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))}
|
|
statements={statements}
|
|
spriteBank={spriteBank}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<style jsx global>{`
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Serif:ital,wght@0,400;0,700;1,400&display=swap');
|
|
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
background: #0d1117;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
background: #21262d;
|
|
border-radius: 4px;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
background: #30363d;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|