commit 1373e1e128e97f1f86e0ac6fe847429d2c3f647a Author: unknown Date: Sun Mar 15 22:41:04 2026 +0100 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..553d0b6 --- /dev/null +++ b/app/page.tsx @@ -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([]); + const [spriteBank, setSpriteBank] = useState>(INITIAL_SPRITE_BANK); + const [currentSayIndex, setCurrentSayIndex] = useState(0); + const [isParsing, setIsParsing] = useState(false); + const editorRef = useRef(null); + + const updateSpriteBank = (newBank: Record) => { + 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 = {}; + 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) => { + 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 ( +
+ + +
+ {/* Header */} +
+
+ 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" + /> +
+
+ + +
+
+ +
+ + + setCurrentSayIndex(prev => Math.max(0, prev - 1))} + onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))} + statements={statements} + spriteBank={spriteBank} + /> +
+
+ + +
+ ); +} diff --git a/components/VNEditor.tsx b/components/VNEditor.tsx new file mode 100644 index 0000000..5d4dfe2 --- /dev/null +++ b/components/VNEditor.tsx @@ -0,0 +1,37 @@ +import React, { forwardRef } from 'react'; + +interface VNEditorProps { + scriptText: string; + onScriptChange: (text: string) => void; +} + +export const VNEditor = forwardRef(({ + scriptText, + onScriptChange +}, ref) => { + return ( +
+
+