This commit is contained in:
unknown
2026-03-15 22:41:04 +01:00
commit 1373e1e128
23 changed files with 7823 additions and 0 deletions

193
app/page.tsx Normal file
View File

@@ -0,0 +1,193 @@
"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>
);
}