first
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
36
README.md
Normal 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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/VNEditor.tsx
Normal file
37
components/VNEditor.tsx
Normal 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
29
components/VNIcons.tsx
Normal 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
164
components/VNOutline.tsx
Normal 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
214
components/VNPreview.tsx
Normal 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
18
eslint.config.mjs
Normal 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
35
lib/sprite-bank.ts
Normal 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
217
lib/vn-parser.ts
Normal 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
7
next.config.ts
Normal 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
6700
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
34
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user