diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..254cfc5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..91c3872 --- /dev/null +++ b/GEMINI.md @@ -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 +``` diff --git a/app/page.tsx b/app/page.tsx index 553d0b6..bca466e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,6 +7,9 @@ import { VNEditor } from '@/components/VNEditor'; import { VNPreview } from '@/components/VNPreview'; 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() { const [scriptText, setScriptText] = useState(DEFAULT_SCRIPT); const [fileName, setFileName] = useState("scene_01.vns"); @@ -16,10 +19,29 @@ export default function VisualNovelEditorPage() { const [isParsing, setIsParsing] = useState(false); const editorRef = useRef(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) => { 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 useEffect(() => { const timer = setTimeout(() => { @@ -34,7 +56,7 @@ export default function VisualNovelEditorPage() { const parts = s.text.split(' '); const char = parts[1]; const id = parts[2]; - const url = parts[3]; + const url = parts[3]?.replace(/^"(.*)"$/, '$1'); if (char && id && url) { if (!newScriptBank[char]) newScriptBank[char] = []; if (!newScriptBank[char].some(sp => sp.id === id)) { @@ -68,12 +90,23 @@ export default function VisualNovelEditorPage() { }, [scriptText]); const previewStatements = useMemo(() => - statements.filter(s => s.type === 'say'), + statements.filter(s => s.type === 'say' || s.type === 'choice'), [statements] ); 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 blob = new Blob([scriptText], { type: 'text/plain' }); const url = URL.createObjectURL(blob); @@ -125,6 +158,7 @@ export default function VisualNovelEditorPage() { isParsing={isParsing} spriteBank={spriteBank} onUpdateSpriteBank={updateSpriteBank} + onAddSpriteToScript={addSpriteToScript} />
@@ -166,12 +200,13 @@ export default function VisualNovelEditorPage() { onNext={() => setCurrentSayIndex(prev => Math.min(previewStatements.length - 1, prev + 1))} statements={statements} spriteBank={spriteBank} + onChoiceSelect={handleChoiceSelect} />