first
This commit is contained in:
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal 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
34
app/layout.tsx
Normal 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
193
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user