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

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Statement } from '@/lib/vn-parser';
import { Sprite } from '@/lib/sprite-bank';
@@ -9,6 +9,7 @@ interface VNOutlineProps {
isParsing: boolean;
spriteBank: Record<string, Sprite[]>;
onUpdateSpriteBank: (newBank: Record<string, Sprite[]>) => void;
onAddSpriteToScript: (char: string, id: string, url: string) => void;
}
export const VNOutline: React.FC<VNOutlineProps> = ({
@@ -17,10 +18,13 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
onSelectLine,
isParsing,
spriteBank,
onUpdateSpriteBank
onUpdateSpriteBank,
onAddSpriteToScript
}) => {
const [activeTab, setActiveTab] = useState<'outline' | 'sprites'>('outline');
const [newCharName, setNewCharName] = useState('');
const [uploadingFor, setUploadingFor] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const addCharacter = () => {
if (!newCharName || spriteBank[newCharName]) return;
@@ -33,14 +37,39 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
onUpdateSpriteBank(rest);
};
const addSprite = (charName: string) => {
const addSpriteUrl = (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 });
onAddSpriteToScript(charName, id, url);
};
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) => {
@@ -132,15 +161,16 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
{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={() => 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>
</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 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-contain 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>
@@ -156,6 +186,13 @@ export const VNOutline: React.FC<VNOutlineProps> = ({
</div>
</div>
))}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
/>
</div>
)}
</div>

View File

@@ -11,6 +11,7 @@ interface VNPreviewProps {
onNext: () => void;
statements: Statement[];
spriteBank: Record<string, Sprite[]>;
onChoiceSelect?: (jumpLabel: string) => void;
}
export const VNPreview: React.FC<VNPreviewProps> = ({
@@ -21,15 +22,20 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
onNext,
statements,
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<
string,
{ emotion: string; pos: SpritePosition }
> = {};
let currentLabel: string | null = null;
statements.forEach((s) => {
if (s.lineNumber <= (currentStatement?.lineNumber || 999999)) {
if (s.type === "label") {
currentLabel = s.label || null;
}
if (s.type === "sprite" && s.character) {
activeSprites[s.character] = {
emotion: s.emotion || "default",
@@ -46,7 +52,25 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
};
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 (
<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="text-xl leading-relaxed text-gray-100 italic font-serif">
<div className="text-xl leading-relaxed text-gray-100 font-serif">
{[
...(currentStatement.segments || []),
...(currentStatement.continuations || []),
@@ -87,7 +111,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
{seg.modifiers.wait && <Icons.Wait />}
{seg.modifiers.click && <Icons.Click />}
{seg.modifiers.clickLock && <Icons.Lock />}
{seg.text}{" "}
{seg.text}
</span>
))}
</div>
@@ -99,10 +123,15 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
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">
<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">
PREVIEW | {currentStatement?.character || "NARRATOR"}
</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 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 baseScale = isSmallPos ? 0.8 : 1.0;
const finalScale = isSpeaking ? baseScale * 1.05 : baseScale;
const translateY = isSpeaking ? "1rem" : "2rem";
const translateY = isSpeaking ? "0rem" : "0.5rem";
return (
<div
@@ -122,7 +151,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
zIndex: isSpeaking ? 20 : 10,
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 ? (
<img
@@ -173,7 +202,7 @@ export const VNPreview: React.FC<VNPreviewProps> = ({
</div>
<div className="absolute inset-y-0 right-2 flex items-center z-40">
<button
disabled={currentSayIndex >= totalPreviewSteps - 1}
disabled={currentSayIndex >= totalPreviewSteps - 1 || currentStatement?.type === 'choice'}
onClick={onNext}
className="p-2 text-white/30 hover:text-cyan-400 transition-colors disabled:opacity-0"
>