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

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

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

37
components/VNEditor.tsx Normal file
View 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
View 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
View 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
View 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>
);
};

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

35
lib/sprite-bank.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface Sprite {
id: string;
name: string;
url: string;
}
export type SpritePosition = 'pos1' | 'pos2' | 'pos3' | 'pos4' | 'pos5' | 'pos6' | 'pos7' | 'pos8';
export const SPRITE_POSITIONS: SpritePosition[] = ['pos1', 'pos2', 'pos3', 'pos4', 'pos5', 'pos6', 'pos7', 'pos8'];
export const POSITION_MAP: Record<SpritePosition, number> = {
pos1: 5,
pos2: 17,
pos3: 30,
pos4: 42,
pos5: 55,
pos6: 67,
pos7: 80,
pos8: 92,
};
export const INITIAL_SPRITE_BANK: Record<string, Sprite[]> = {
Agnieszka: [
{ id: 'happy', name: 'Happy', url: 'https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Happy' },
{ id: 'sad', name: 'Sad', url: 'https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Sad' },
{ id: 'angry', name: 'Angry', url: 'https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Angry' },
{ id: 'neutral', name: 'Neutral', url: 'https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Neutral' },
],
Player: [
{ id: 'normal', name: 'Normal', url: 'https://placehold.co/400x600/4d79ff/ffffff?text=Player' },
],
Teacher: [
{ id: 'neutral', name: 'Neutral', url: 'https://placehold.co/400x600/4dff4d/ffffff?text=Teacher' },
]
};

217
lib/vn-parser.ts Normal file
View File

@@ -0,0 +1,217 @@
// --- CONSTANTS ---
export const CHARACTER_COLORS = [
'#ff4d4d', '#4d79ff', '#4dff4d', '#ffff4d', '#ff4dff', '#4dffff',
'#ffa64d', '#a64dff', '#ff8080', '#80ff80', '#8080ff'
];
export const getCharColor = (name: string | null) => {
if (!name) return '#cccccc';
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return CHARACTER_COLORS[Math.abs(hash) % CHARACTER_COLORS.length];
};
export const DEFAULT_SCRIPT = `// Define Assets
define_sprite Agnieszka wave "https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Wave"
define_sprite Player shock "https://placehold.co/400x600/4d79ff/ffffff?text=Player+Shock"
// Scene 1: The Rooftop Meeting
sprite Agnieszka happy at pos2
sprite Player normal at pos7
say Agnieszka "To jest najprostszy tekst w tekstboxie!"
say Agnieszka "To jest najprostszy tekst w tekstboxie!" "Tu jest dużo tekstu."
say "To by było trochę niekomfortowe trzymać w jednej linii." "Ale wszyscy wiedzą."
say "Że to nadal Agnieszka."
say "To jest tekst bez osoby podanej!"
sprite Agnieszka wave at pos3
say Agnieszka "Look, I defined this 'wave' sprite right in the script!"
say Agnieszka b"To jest pogrubione" "A to nie!" b"P""ierwsza litera tylko"
say Agnieszka i"Hello! This is italicized" "And this isn't"
say Agnieszka u"This is underlined" "And this isn't."
say Agnieszka s"This is crossed out" "And this isn't."
say Agnieszka "This line is instant" w"This line shows up after 1s"
say Agnieszka "This line is instant" w"This line shows up after 3s"(w:3s)
say Agnieszka "This line is default colored" c"This line isn't"(c:green)
say Agnieszka "This line is default font" f"This line isn't"(f:TimesNewRoman)
wait 5s skippable
sprite Agnieszka happy at pos5
set AGNIESZKA_MET true
fire ROOFTOP_SCENE_START
say Agnieszka "This is instant" click "Gotta click to read this one"
say Agnieszka "This line is instant" clicknoskip "Can't even autoclick this one."
nextfile scena_koncowa`;
// --- TYPES ---
export interface Modifier {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
wait: boolean;
waitDuration: string | null;
color: string | null;
font: string | null;
speed: string | null;
clickLock: string | null;
click: boolean;
clicknoskip: boolean;
inlineBranch: any[];
}
export interface Segment {
text: string;
modifiers: Modifier;
}
export interface Statement {
type: 'say' | 'wait' | 'sprite' | 'set' | 'fire' | 'nextfile' | 'comment';
character?: string | null;
segments?: Segment[];
continuations?: Segment[];
duration?: string;
skippable?: boolean;
emotion?: string;
variable?: string;
value?: string;
event?: string;
args?: string[];
filename?: string;
text?: string;
lineNumber: number;
}
// --- PARSER ---
export function parseScript(text: string): Statement[] {
const lines = text.split('\n');
const statements: Statement[] = [];
let currentSay: Statement | null = null;
const parseSegments = (lineText: string): Segment[] => {
const segments: Segment[] = [];
const segmentRegex = /([biuswcftn]|click|clicknoskip)?"([^"]*)"(\(([^)]+)\))?/g;
let match;
while ((match = segmentRegex.exec(lineText)) !== null) {
const [full, mod, textValue, fullParens, params] = match;
const modifiers: Modifier = {
bold: mod === 'b',
italic: mod === 'i',
underline: mod === 'u',
strikethrough: mod === 's',
wait: mod === 'w',
waitDuration: mod === 'w' ? (params?.match(/w:([\d.s]+)/)?.[1] || '1s') : null,
color: mod === 'c' ? (params?.match(/c:([^)]+)/)?.[1] || null) : null,
font: mod === 'f' ? (params?.match(/f:([^)]+)/)?.[1] || null) : null,
speed: mod === 't' ? (params?.match(/t:([^)]+)/)?.[1] || null) : null,
clickLock: mod === 'n' ? (params?.match(/n:([^)]+)/)?.[1] || null) : null,
click: mod === 'click',
clicknoskip: mod === 'clicknoskip',
inlineBranch: []
};
segments.push({ text: textValue, modifiers });
}
return segments;
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
const originalLineNumber = i + 1;
if (!line || line.startsWith('//')) {
statements.push({ type: 'comment', text: line, lineNumber: originalLineNumber });
continue;
}
if (line.startsWith('say ') || line.startsWith('"')) {
if (line.startsWith('say ')) {
let character: string | null = null;
const afterSay = line.slice(4).trim();
let segmentPart = afterSay;
if (afterSay.startsWith('"')) {
character = null;
} else {
const firstQuoteIndex = afterSay.indexOf('"');
if (firstQuoteIndex !== -1) {
character = afterSay.substring(0, firstQuoteIndex).trim();
segmentPart = afterSay.substring(firstQuoteIndex);
} else {
character = afterSay;
segmentPart = "";
}
}
currentSay = {
type: 'say',
character,
segments: parseSegments(segmentPart),
continuations: [],
lineNumber: originalLineNumber
};
statements.push(currentSay);
} else if (line.startsWith('"') && currentSay) {
currentSay.continuations?.push(...parseSegments(line));
}
continue;
}
const words = line.split(/\s+/);
let cmd = words[0];
if (cmd === 'wait') {
statements.push({
type: 'wait',
duration: words[1],
skippable: words.includes('skippable'),
lineNumber: originalLineNumber
});
} else if (cmd === 'define_sprite') {
const char = words[1];
const id = words[2];
const url = words[3]?.replace(/^"(.*)"$/, '$1');
statements.push({
type: 'comment',
text: `DEF_SPRITE ${char} ${id} ${url}`,
lineNumber: originalLineNumber
});
} else if (cmd === 'sprite') {
const char = words[1];
const emotion = words[2];
const hasAt = words.indexOf('at');
const position = hasAt !== -1 ? words[hasAt + 1] : 'pos5';
statements.push({
type: 'sprite',
character: char,
emotion: emotion,
value: position,
lineNumber: originalLineNumber
});
} else if (cmd === 'set') {
statements.push({
type: 'set',
variable: words[1],
value: words.slice(2).join(' '),
lineNumber: originalLineNumber
});
} else if (cmd === 'fire') {
statements.push({
type: 'fire',
event: words[1],
args: words.slice(2),
lineNumber: originalLineNumber
});
} else if (cmd === 'nextfile') {
statements.push({
type: 'nextfile',
filename: words[1],
lineNumber: originalLineNumber
});
}
}
return statements;
}

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6700
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "gdatingeditor",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}