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

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

34
app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

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>
);
}