add choice mechanic

This commit is contained in:
Stanislaw Dzioba
2026-03-23 19:44:41 +01:00
parent 1373e1e128
commit 86edc3bf88
8 changed files with 300 additions and 136 deletions

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

53
GEMINI.md Normal file
View File

@@ -0,0 +1,53 @@
# Visual Novel Dating Sim Editor
A web-based editor for creating and previewing Visual Novel scripts with real-time feedback.
## 🏗 Architecture
- **Frontend**: Next.js (TypeScript) + Tailwind CSS.
- **State Management**: React `useState` and `useEffect` in `app/page.tsx`.
- **Script Engine**: Custom regex-based parser in `lib/vn-parser.ts`.
- **Renderer**: Dynamic React component in `components/VNPreview.tsx` that reconstructs the scene state.
- **Persistence**:
- Automatic `localStorage` sync for current session.
- Script export/import as `.vns` files.
- Inline Base64 image embedding for portable project files.
## 📝 Script Syntax
### Basic Commands
- `say Character "Dialogue"`: Display dialogue for a character.
- `say "Dialogue"`: Narrator text.
- `sprite Char Emotion at Position`: Place a character on screen.
- Positions: `pos1` to `pos8`.
- `wait 2s`: Pause for duration (can add `skippable`).
- `label Name`: Define a jump point.
- `choice "Text" jump:Label`: Create a branching choice.
- `define_sprite Char ID "URL"`: Register a sprite asset.
### Rich Text Modifiers
- `b"Bold"`
- `i"Italic"`
- `u"Underline"`
- `c"Red"(c:red)`
- `w"Wait"(w:1s)`
## 🚀 Production Deployment
### Docker (Recommended)
The project is optimized for Docker using Next.js standalone output.
```bash
# Build the image
docker build -t vn-editor .
# Run the container
docker run -p 3000:3000 vn-editor
```
### Manual Build
```bash
npm install
npm run build
npm run start
```

View File

@@ -7,6 +7,9 @@ import { VNEditor } from '@/components/VNEditor';
import { VNPreview } from '@/components/VNPreview'; import { VNPreview } from '@/components/VNPreview';
import { INITIAL_SPRITE_BANK, Sprite } from '@/lib/sprite-bank'; import { INITIAL_SPRITE_BANK, Sprite } from '@/lib/sprite-bank';
const STORAGE_KEY_SCRIPT = 'vn_editor_script';
const STORAGE_KEY_FILENAME = 'vn_editor_filename';
export default function VisualNovelEditorPage() { export default function VisualNovelEditorPage() {
const [scriptText, setScriptText] = useState(DEFAULT_SCRIPT); const [scriptText, setScriptText] = useState(DEFAULT_SCRIPT);
const [fileName, setFileName] = useState("scene_01.vns"); const [fileName, setFileName] = useState("scene_01.vns");
@@ -16,10 +19,29 @@ export default function VisualNovelEditorPage() {
const [isParsing, setIsParsing] = useState(false); const [isParsing, setIsParsing] = useState(false);
const editorRef = useRef<HTMLTextAreaElement>(null); const editorRef = useRef<HTMLTextAreaElement>(null);
// Load from LocalStorage
useEffect(() => {
const savedScript = localStorage.getItem(STORAGE_KEY_SCRIPT);
const savedFileName = localStorage.getItem(STORAGE_KEY_FILENAME);
if (savedScript) setScriptText(savedScript);
if (savedFileName) setFileName(savedFileName);
}, []);
// Save to LocalStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SCRIPT, scriptText);
localStorage.setItem(STORAGE_KEY_FILENAME, fileName);
}, [scriptText, fileName]);
const updateSpriteBank = (newBank: Record<string, Sprite[]>) => { const updateSpriteBank = (newBank: Record<string, Sprite[]>) => {
setSpriteBank(newBank); setSpriteBank(newBank);
}; };
const addSpriteToScript = (char: string, id: string, url: string) => {
// Add to the top of the script or after existing defines
setScriptText(prev => `define_sprite ${char} ${id} "${url}"\n${prev}`);
};
// Parse Debounce // Parse Debounce
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -34,7 +56,7 @@ export default function VisualNovelEditorPage() {
const parts = s.text.split(' '); const parts = s.text.split(' ');
const char = parts[1]; const char = parts[1];
const id = parts[2]; const id = parts[2];
const url = parts[3]; const url = parts[3]?.replace(/^"(.*)"$/, '$1');
if (char && id && url) { if (char && id && url) {
if (!newScriptBank[char]) newScriptBank[char] = []; if (!newScriptBank[char]) newScriptBank[char] = [];
if (!newScriptBank[char].some(sp => sp.id === id)) { if (!newScriptBank[char].some(sp => sp.id === id)) {
@@ -68,12 +90,23 @@ export default function VisualNovelEditorPage() {
}, [scriptText]); }, [scriptText]);
const previewStatements = useMemo(() => const previewStatements = useMemo(() =>
statements.filter(s => s.type === 'say'), statements.filter(s => s.type === 'say' || s.type === 'choice'),
[statements] [statements]
); );
const currentStatement = previewStatements[currentSayIndex] || null; const currentStatement = previewStatements[currentSayIndex] || null;
const handleChoiceSelect = (jumpLabel: string) => {
const labelIdx = statements.findIndex(s => s.type === 'label' && s.label === jumpLabel);
if (labelIdx !== -1) {
// Find the first 'previewable' statement at or after the label
const nextPreviewIdx = previewStatements.findIndex(s => s.lineNumber >= statements[labelIdx].lineNumber);
if (nextPreviewIdx !== -1) {
setCurrentSayIndex(nextPreviewIdx);
}
}
};
const handleSave = () => { const handleSave = () => {
const blob = new Blob([scriptText], { type: 'text/plain' }); const blob = new Blob([scriptText], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -125,6 +158,7 @@ export default function VisualNovelEditorPage() {
isParsing={isParsing} isParsing={isParsing}
spriteBank={spriteBank} spriteBank={spriteBank}
onUpdateSpriteBank={updateSpriteBank} onUpdateSpriteBank={updateSpriteBank}
onAddSpriteToScript={addSpriteToScript}
/> />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
@@ -166,12 +200,13 @@ export default function VisualNovelEditorPage() {
onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))} onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))}
statements={statements} statements={statements}
spriteBank={spriteBank} spriteBank={spriteBank}
onChoiceSelect={handleChoiceSelect}
/> />
</div> </div>
</div> </div>
<style jsx global>{` <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'); @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { Statement } from '@/lib/vn-parser'; import { Statement } from '@/lib/vn-parser';
import { Sprite } from '@/lib/sprite-bank'; import { Sprite } from '@/lib/sprite-bank';
@@ -9,6 +9,7 @@ interface VNOutlineProps {
isParsing: boolean; isParsing: boolean;
spriteBank: Record<string, Sprite[]>; spriteBank: Record<string, Sprite[]>;
onUpdateSpriteBank: (newBank: Record<string, Sprite[]>) => void; onUpdateSpriteBank: (newBank: Record<string, Sprite[]>) => void;
onAddSpriteToScript: (char: string, id: string, url: string) => void;
} }
export const VNOutline: React.FC<VNOutlineProps> = ({ export const VNOutline: React.FC<VNOutlineProps> = ({
@@ -17,10 +18,13 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
onSelectLine, onSelectLine,
isParsing, isParsing,
spriteBank, spriteBank,
onUpdateSpriteBank onUpdateSpriteBank,
onAddSpriteToScript
}) => { }) => {
const [activeTab, setActiveTab] = useState<'outline' | 'sprites'>('outline'); const [activeTab, setActiveTab] = useState<'outline' | 'sprites'>('outline');
const [newCharName, setNewCharName] = useState(''); const [newCharName, setNewCharName] = useState('');
const [uploadingFor, setUploadingFor] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const addCharacter = () => { const addCharacter = () => {
if (!newCharName || spriteBank[newCharName]) return; if (!newCharName || spriteBank[newCharName]) return;
@@ -33,14 +37,39 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
onUpdateSpriteBank(rest); onUpdateSpriteBank(rest);
}; };
const addSprite = (charName: string) => { const addSpriteUrl = (charName: string) => {
const id = prompt('Sprite ID (e.g. happy):'); const id = prompt('Sprite ID (e.g. happy):');
if (!id) return; if (!id) return;
const url = prompt('Sprite Image URL:'); const url = prompt('Sprite Image URL:');
if (!url) return; if (!url) return;
const newSprites = [...spriteBank[charName], { id, name: id, url }]; onAddSpriteToScript(charName, id, url);
onUpdateSpriteBank({ ...spriteBank, [charName]: newSprites }); };
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !uploadingFor) return;
const id = prompt('Sprite ID (e.g. happy):');
if (!id) return;
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
if (url) {
onAddSpriteToScript(uploadingFor, id, url);
}
};
reader.readAsDataURL(file);
// Reset file input
if (fileInputRef.current) fileInputRef.current.value = '';
setUploadingFor(null);
};
const triggerFileUpload = (charName: string) => {
setUploadingFor(charName);
fileInputRef.current?.click();
}; };
const removeSprite = (charName: string, spriteId: string) => { const removeSprite = (charName: string, spriteId: string) => {
@@ -132,15 +161,16 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
{charName} {charName}
</h3> </h3>
<div className="flex gap-2"> <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={() => addSpriteUrl(charName)} className="text-[8px] text-gray-400 hover:text-cyan-400 uppercase font-bold tracking-tighter">URL</button>
<button onClick={() => triggerFileUpload(charName)} className="text-[8px] text-gray-400 hover:text-cyan-400 uppercase font-bold tracking-tighter">Upload</button>
<button onClick={() => removeCharacter(charName)} className="text-[8px] text-gray-400 hover:text-red-400 uppercase font-bold tracking-tighter">Delete</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> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{sprites.map(sprite => ( {sprites.map(sprite => (
<div key={sprite.id} className="group relative"> <div key={sprite.id} className="group relative">
<div className="aspect-[2/3] bg-gray-900 rounded border border-gray-800 overflow-hidden"> <div className="aspect-[2/3] bg-gray-900 rounded border border-gray-800 overflow-hidden p-1">
<img src={sprite.url} alt={sprite.name} className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity" /> <img src={sprite.url} alt={sprite.name} className="w-full h-full object-contain opacity-60 group-hover:opacity-100 transition-opacity" />
</div> </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"> <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> <span className="truncate flex-1">{sprite.id}</span>
@@ -156,6 +186,13 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
</div> </div>
</div> </div>
))} ))}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -11,6 +11,7 @@ interface VNPreviewProps {
onNext: () => void; onNext: () => void;
statements: Statement[]; statements: Statement[];
spriteBank: Record<string, Sprite[]>; spriteBank: Record<string, Sprite[]>;
onChoiceSelect?: (jumpLabel: string) => void;
} }
export const VNPreview: React.FC<VNPreviewProps> = ({ export const VNPreview: React.FC<VNPreviewProps> = ({
@@ -21,15 +22,20 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
onNext, onNext,
statements, statements,
spriteBank, spriteBank,
onChoiceSelect,
}) => { }) => {
// Track state of all characters on scene at the current line // Track state of all characters on scene and the current label
const activeSprites: Record< const activeSprites: Record<
string, string,
{ emotion: string; pos: SpritePosition } { emotion: string; pos: SpritePosition }
> = {}; > = {};
let currentLabel: string | null = null;
statements.forEach((s) => { statements.forEach((s) => {
if (s.lineNumber <= (currentStatement?.lineNumber || 999999)) { if (s.lineNumber <= (currentStatement?.lineNumber || 999999)) {
if (s.type === "label") {
currentLabel = s.label || null;
}
if (s.type === "sprite" && s.character) { if (s.type === "sprite" && s.character) {
activeSprites[s.character] = { activeSprites[s.character] = {
emotion: s.emotion || "default", emotion: s.emotion || "default",
@@ -46,7 +52,25 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
}; };
const renderDialogueBox = () => { const renderDialogueBox = () => {
if (!currentStatement || currentStatement.type !== "say") return null; if (!currentStatement) return null;
if (currentStatement.type === "choice") {
return (
<div className="flex flex-col gap-3 items-center">
{currentStatement.choices?.map((choice, i) => (
<button
key={i}
onClick={() => onChoiceSelect?.(choice.jumpLabel)}
className="w-full max-w-lg bg-black/80 hover:bg-cyan-900/80 border border-cyan-500/50 hover:border-cyan-400 p-4 rounded-md text-cyan-100 font-bold transition-all transform hover:scale-[1.02] active:scale-[0.98] shadow-lg backdrop-blur-md"
>
{choice.text}
</button>
))}
</div>
);
}
if (currentStatement.type !== "say") return null;
return ( return (
<div className="relative"> <div className="relative">
@@ -64,7 +88,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
)} )}
<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="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"> <div className="text-xl leading-relaxed text-gray-100 font-serif">
{[ {[
...(currentStatement.segments || []), ...(currentStatement.segments || []),
...(currentStatement.continuations || []), ...(currentStatement.continuations || []),
@@ -87,7 +111,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
{seg.modifiers.wait && <Icons.Wait />} {seg.modifiers.wait && <Icons.Wait />}
{seg.modifiers.click && <Icons.Click />} {seg.modifiers.click && <Icons.Click />}
{seg.modifiers.clickLock && <Icons.Lock />} {seg.modifiers.clickLock && <Icons.Lock />}
{seg.text}{" "} {seg.text}
</span> </span>
))} ))}
</div> </div>
@@ -99,10 +123,15 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
return ( return (
<div className="flex-1 bg-[#0b0e14] p-8 flex flex-col items-center justify-center relative"> <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="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"> <div className="absolute top-4 left-6 z-50 flex gap-2 items-center">
<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"> <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"} PREVIEW | {currentStatement?.character || "NARRATOR"}
</span> </span>
{currentLabel && (
<span className="text-[9px] tracking-widest uppercase text-cyan-400 font-bold bg-cyan-950/40 px-3 py-1 rounded backdrop-blur border border-cyan-500/20">
SCENE: {currentLabel}
</span>
)}
</div> </div>
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none">
@@ -112,7 +141,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
const isSmallPos = data.pos === "pos7" || data.pos === "pos8"; const isSmallPos = data.pos === "pos7" || data.pos === "pos8";
const baseScale = isSmallPos ? 0.8 : 1.0; const baseScale = isSmallPos ? 0.8 : 1.0;
const finalScale = isSpeaking ? baseScale * 1.05 : baseScale; const finalScale = isSpeaking ? baseScale * 1.05 : baseScale;
const translateY = isSpeaking ? "1rem" : "2rem"; const translateY = isSpeaking ? "0rem" : "0.5rem";
return ( return (
<div <div
@@ -122,7 +151,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
zIndex: isSpeaking ? 20 : 10, zIndex: isSpeaking ? 20 : 10,
transform: `translateX(-50%) translateY(${translateY}) scale(${finalScale})`, transform: `translateX(-50%) translateY(${translateY}) scale(${finalScale})`,
}} }}
className="absolute bottom-0 h-[85%] transition-all duration-700" className="absolute bottom-0 h-[95%] transition-all duration-700"
> >
{url ? ( {url ? (
<img <img
@@ -173,7 +202,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
</div> </div>
<div className="absolute inset-y-0 right-2 flex items-center z-40"> <div className="absolute inset-y-0 right-2 flex items-center z-40">
<button <button
disabled={currentSayIndex >= totalPreviewSteps - 1} disabled={currentSayIndex >= totalPreviewSteps - 1 || currentStatement?.type === 'choice'}
onClick={onNext} onClick={onNext}
className="p-2 text-white/30 hover:text-cyan-400 transition-colors disabled:opacity-0" className="p-2 text-white/30 hover:text-cyan-400 transition-colors disabled:opacity-0"
> >

View File

@@ -22,12 +22,23 @@ define_sprite Player shock "https://placehold.co/400x600/4d79ff/ffffff?text=Play
sprite Agnieszka happy at pos2 sprite Agnieszka happy at pos2
sprite Player normal at pos7 sprite Player normal at pos7
say Agnieszka "To jest najprostszy tekst w tekstboxie!" say Agnieszka "To jest najprostszy tekst w tekstboxie!"
say Agnieszka "Chcesz zobaczyć specjalny efekt?"
choice "Tak, pokaż mi!" jump:special_effect
choice "Nie, przejdźmy dalej" jump:continue_scene
label special_effect
sprite Agnieszka wave at pos3
say Agnieszka "Oto on! Pogrubiony tekst: " b"BUM!"
say Agnieszka "I teraz wracamy do głównej sceny."
label continue_scene
say Agnieszka "Kontynuujemy naszą rozmowę."
say Agnieszka "To jest najprostszy tekst w tekstboxie!" "Tu jest dużo tekstu." 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 "To by było trochę niekomfortowe trzymać w jednej linii." "Ale wszyscy wiedzą."
say "Że to nadal Agnieszka." say "Że to nadal Agnieszka."
say "To jest tekst bez osoby podanej!" say "To jest tekst bez osoby podanej!"
sprite Agnieszka wave at pos3 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 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 i"Hello! This is italicized" "And this isn't"
say Agnieszka u"This is underlined" "And this isn't." say Agnieszka u"This is underlined" "And this isn't."
@@ -67,8 +78,13 @@ export interface Segment {
modifiers: Modifier; modifiers: Modifier;
} }
export interface ChoiceOption {
text: string;
jumpLabel: string;
}
export interface Statement { export interface Statement {
type: 'say' | 'wait' | 'sprite' | 'set' | 'fire' | 'nextfile' | 'comment'; type: 'say' | 'wait' | 'sprite' | 'set' | 'fire' | 'nextfile' | 'comment' | 'choice' | 'label';
character?: string | null; character?: string | null;
segments?: Segment[]; segments?: Segment[];
continuations?: Segment[]; continuations?: Segment[];
@@ -82,6 +98,8 @@ export interface Statement {
filename?: string; filename?: string;
text?: string; text?: string;
lineNumber: number; lineNumber: number;
label?: string;
choices?: ChoiceOption[];
} }
// --- PARSER --- // --- PARSER ---
@@ -95,7 +113,23 @@ export function parseScript(text: string): Statement[] {
const segments: Segment[] = []; const segments: Segment[] = [];
const segmentRegex = /([biuswcftn]|click|clicknoskip)?"([^"]*)"(\(([^)]+)\))?/g; const segmentRegex = /([biuswcftn]|click|clicknoskip)?"([^"]*)"(\(([^)]+)\))?/g;
let match; let match;
let lastIndex = 0;
while ((match = segmentRegex.exec(lineText)) !== null) { while ((match = segmentRegex.exec(lineText)) !== null) {
// Add any text between matches (like spaces) as a plain segment
const gap = lineText.substring(lastIndex, match.index);
if (gap) {
segments.push({
text: gap,
modifiers: {
bold: false, italic: false, underline: false, strikethrough: false,
wait: false, waitDuration: null, color: null, font: null,
speed: null, clickLock: null, click: false, clicknoskip: false,
inlineBranch: []
}
});
}
const [full, mod, textValue, fullParens, params] = match; const [full, mod, textValue, fullParens, params] = match;
const modifiers: Modifier = { const modifiers: Modifier = {
bold: mod === 'b', bold: mod === 'b',
@@ -113,7 +147,23 @@ export function parseScript(text: string): Statement[] {
inlineBranch: [] inlineBranch: []
}; };
segments.push({ text: textValue, modifiers }); segments.push({ text: textValue, modifiers });
lastIndex = segmentRegex.lastIndex;
} }
// Add remaining text after the last match
const remaining = lineText.substring(lastIndex);
if (remaining) {
segments.push({
text: remaining,
modifiers: {
bold: false, italic: false, underline: false, strikethrough: false,
wait: false, waitDuration: null, color: null, font: null,
speed: null, clickLock: null, click: false, clicknoskip: false,
inlineBranch: []
}
});
}
return segments; return segments;
}; };
@@ -135,10 +185,12 @@ export function parseScript(text: string): Statement[] {
if (afterSay.startsWith('"')) { if (afterSay.startsWith('"')) {
character = null; character = null;
} else { } else {
const firstQuoteIndex = afterSay.indexOf('"'); // Find the start of the first segment.
if (firstQuoteIndex !== -1) { // A segment starts with a quote, or a modifier followed by a quote.
character = afterSay.substring(0, firstQuoteIndex).trim(); const segmentMatch = afterSay.match(/([biuswcftn]|click|clicknoskip)?"/);
segmentPart = afterSay.substring(firstQuoteIndex); if (segmentMatch && segmentMatch.index !== undefined) {
character = afterSay.substring(0, segmentMatch.index).trim();
segmentPart = afterSay.substring(segmentMatch.index);
} else { } else {
character = afterSay; character = afterSay;
segmentPart = ""; segmentPart = "";
@@ -159,10 +211,31 @@ export function parseScript(text: string): Statement[] {
continue; continue;
} }
const words = line.split(/\s+/); const words = line.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
let cmd = words[0]; let cmd = words[0];
if (cmd === 'wait') { if (cmd === 'label') {
statements.push({
type: 'label',
label: words[1],
lineNumber: originalLineNumber
});
} else if (cmd === 'choice') {
const text = words[1]?.replace(/^"(.*)"$/, '$1');
const jumpPart = words[2];
const jumpLabel = jumpPart?.startsWith('jump:') ? jumpPart.slice(5) : '';
const lastStatement = statements[statements.length - 1];
if (lastStatement?.type === 'choice') {
lastStatement.choices?.push({ text, jumpLabel });
} else {
statements.push({
type: 'choice',
choices: [{ text, jumpLabel }],
lineNumber: originalLineNumber
});
}
} else if (cmd === 'wait') {
statements.push({ statements.push({
type: 'wait', type: 'wait',
duration: words[1], duration: words[1],

View File

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

108
package-lock.json generated
View File

@@ -598,9 +598,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -617,9 +614,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -636,9 +630,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -655,9 +646,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -674,9 +662,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -693,9 +678,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -712,9 +694,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -731,9 +710,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -750,9 +726,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -775,9 +748,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -800,9 +770,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -825,9 +792,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -850,9 +814,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -875,9 +836,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -900,9 +858,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -925,9 +880,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1137,9 +1089,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1156,9 +1105,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1175,9 +1121,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1194,9 +1137,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1435,9 +1375,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1455,9 +1392,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1475,9 +1409,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1495,9 +1426,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2048,9 +1976,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2065,9 +1990,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2082,9 +2004,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2099,9 +2018,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2116,9 +2032,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2133,9 +2046,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2150,9 +2060,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2167,9 +2074,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4800,9 +4704,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4824,9 +4725,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4848,9 +4746,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4872,9 +4767,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [