Files
datingsim-visual-editor/components/VNPreview.tsx
Stanislaw Dzioba 86edc3bf88 add choice mechanic
2026-03-23 19:44:41 +01:00

244 lines
9.2 KiB
TypeScript

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[]>;
onChoiceSelect?: (jumpLabel: string) => void;
}
export const VNPreview: React.FC<VNPreviewProps> = ({
currentStatement,
currentSayIndex,
totalPreviewSteps,
onPrev,
onNext,
statements,
spriteBank,
onChoiceSelect,
}) => {
// 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",
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) 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">
{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 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-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">
{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 ? "0rem" : "0.5rem";
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-[95%] 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 || currentStatement?.type === 'choice'}
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>
);
};