feat: [US-037] - Export to Ren'Py JSON format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-22 18:26:25 -03:00
parent ee0a6f9424
commit f8f8048e53
2 changed files with 281 additions and 0 deletions

View File

@ -154,6 +154,239 @@ function isValidFlowchartData(data: unknown): data is FlowchartData {
return true
}
// Ren'Py export types
type RenpyDialogueNode = {
type: 'dialogue'
speaker: string
text: string
next?: string
condition?: Condition
}
type RenpyMenuChoice = {
label: string
next?: string
condition?: Condition
}
type RenpyMenuNode = {
type: 'menu'
prompt: string
choices: RenpyMenuChoice[]
}
type RenpyVariableNode = {
type: 'variable'
name: string
operation: 'set' | 'add' | 'subtract'
value: number
next?: string
condition?: Condition
}
type RenpyNode = RenpyDialogueNode | RenpyMenuNode | RenpyVariableNode
type RenpyExport = {
projectName: string
exportedAt: string
sections: Record<string, RenpyNode[]>
}
// Find the first node (node with no incoming edges)
function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null {
const targetIds = new Set(edges.map((e) => e.target))
const startNodes = nodes.filter((n) => !targetIds.has(n.id))
// Return the first start node, or the first node if all have incoming edges
return startNodes[0] || nodes[0] || null
}
// Get outgoing edge from a node (for dialogue and variable nodes)
function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined {
return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle))
}
// Get all outgoing edges from a node (for choice nodes)
function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] {
return edges.filter((e) => e.source === nodeId)
}
// Convert flowchart to Ren'Py format using graph traversal
function convertToRenpyFormat(
nodes: FlowchartNode[],
edges: FlowchartEdge[],
projectName: string
): RenpyExport {
const nodeMap = new Map<string, FlowchartNode>(nodes.map((n) => [n.id, n]))
const visited = new Set<string>()
const sections: Record<string, RenpyNode[]> = {}
let currentSectionName = 'start'
let currentSection: RenpyNode[] = []
// Helper to get or create a label for a node
const nodeLabels = new Map<string, string>()
let labelCounter = 0
function getNodeLabel(nodeId: string): string {
if (!nodeLabels.has(nodeId)) {
const node = nodeMap.get(nodeId)
if (node?.type === 'dialogue' && node.data.speaker) {
// Use speaker name as part of label if available
nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`)
} else {
nodeLabels.set(nodeId, `section_${labelCounter++}`)
}
}
return nodeLabels.get(nodeId)!
}
// Process a node and its successors
function processNode(nodeId: string): void {
if (visited.has(nodeId)) return
visited.add(nodeId)
const node = nodeMap.get(nodeId)
if (!node) return
if (node.type === 'dialogue') {
const outgoingEdge = getOutgoingEdge(nodeId, edges)
const renpyNode: RenpyDialogueNode = {
type: 'dialogue',
speaker: node.data.speaker || '',
text: node.data.text,
}
if (outgoingEdge) {
// Check if target node is already visited (creates a jump)
if (visited.has(outgoingEdge.target)) {
renpyNode.next = getNodeLabel(outgoingEdge.target)
} else {
renpyNode.next = outgoingEdge.target
}
if (outgoingEdge.data?.condition) {
renpyNode.condition = outgoingEdge.data.condition
}
}
currentSection.push(renpyNode)
// Process next node if not visited
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
processNode(outgoingEdge.target)
}
} else if (node.type === 'choice') {
const outgoingEdges = getOutgoingEdges(nodeId, edges)
// Map options to their corresponding edges
const choices: RenpyMenuChoice[] = node.data.options.map((option, index) => {
// Find edge for this option handle
const optionEdge = outgoingEdges.find((e) => e.sourceHandle === `option-${index}`)
const choice: RenpyMenuChoice = {
label: option.label || `Option ${index + 1}`,
}
if (optionEdge) {
// If target is visited, use label; otherwise use target id
if (visited.has(optionEdge.target)) {
choice.next = getNodeLabel(optionEdge.target)
} else {
choice.next = optionEdge.target
}
if (optionEdge.data?.condition) {
choice.condition = optionEdge.data.condition
}
}
return choice
})
const renpyNode: RenpyMenuNode = {
type: 'menu',
prompt: node.data.prompt || '',
choices,
}
currentSection.push(renpyNode)
// Save current section before processing branches
sections[currentSectionName] = currentSection
// Process each branch in a new section
for (const choice of choices) {
if (choice.next && !visited.has(choice.next)) {
const targetNode = nodeMap.get(choice.next)
if (targetNode) {
// Start new section for this branch
currentSectionName = getNodeLabel(choice.next)
currentSection = []
processNode(choice.next)
if (currentSection.length > 0) {
sections[currentSectionName] = currentSection
}
}
}
}
} else if (node.type === 'variable') {
const outgoingEdge = getOutgoingEdge(nodeId, edges)
const renpyNode: RenpyVariableNode = {
type: 'variable',
name: node.data.variableName,
operation: node.data.operation,
value: node.data.value,
}
if (outgoingEdge) {
if (visited.has(outgoingEdge.target)) {
renpyNode.next = getNodeLabel(outgoingEdge.target)
} else {
renpyNode.next = outgoingEdge.target
}
if (outgoingEdge.data?.condition) {
renpyNode.condition = outgoingEdge.data.condition
}
}
currentSection.push(renpyNode)
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
processNode(outgoingEdge.target)
}
}
}
// Find and process starting from the first node
const firstNode = findFirstNode(nodes, edges)
if (firstNode) {
processNode(firstNode.id)
// Save the final section if it has content
if (currentSection.length > 0 && !sections[currentSectionName]) {
sections[currentSectionName] = currentSection
}
}
// Replace node IDs in next fields with proper labels
for (const sectionNodes of Object.values(sections)) {
for (const renpyNode of sectionNodes) {
if (renpyNode.type === 'dialogue' || renpyNode.type === 'variable') {
if (renpyNode.next && nodeLabels.has(renpyNode.next)) {
renpyNode.next = nodeLabels.get(renpyNode.next)
}
} else if (renpyNode.type === 'menu') {
for (const choice of renpyNode.choices) {
if (choice.next && nodeLabels.has(choice.next)) {
choice.next = nodeLabels.get(choice.next)
}
}
}
}
}
return {
projectName,
exportedAt: new Date().toISOString(),
sections,
}
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
@ -394,6 +627,45 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
URL.revokeObjectURL(url)
}, [nodes, edges, projectName])
const handleExportRenpy = useCallback(() => {
// Convert React Flow state to our flowchart types
const flowchartNodes = fromReactFlowNodes(nodes)
const flowchartEdges = fromReactFlowEdges(edges)
// Convert to Ren'Py format
const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName)
// Create pretty-printed JSON
const jsonContent = JSON.stringify(renpyExport, null, 2)
// Verify JSON is valid
try {
JSON.parse(jsonContent)
} catch {
setToast({ message: 'Failed to generate valid JSON', type: 'error' })
return
}
// Create blob with JSON content
const blob = new Blob([jsonContent], { type: 'application/json' })
// Create download URL
const url = URL.createObjectURL(blob)
// Create temporary link element and trigger download
const link = document.createElement('a')
link.href = url
link.download = `${projectName}-renpy.json`
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
URL.revokeObjectURL(url)
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
}, [nodes, edges, projectName])
// Check if current flowchart has unsaved changes
const hasUnsavedChanges = useCallback(() => {
const currentData: FlowchartData = {
@ -668,6 +940,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onExportRenpy={handleExportRenpy}
onImport={handleImport}
isSaving={isSaving}
/>

View File

@ -6,6 +6,7 @@ type ToolbarProps = {
onAddVariable: () => void
onSave: () => void
onExport: () => void
onExportRenpy: () => void
onImport: () => void
isSaving?: boolean
}
@ -16,6 +17,7 @@ export default function Toolbar({
onAddVariable,
onSave,
onExport,
onExportRenpy,
onImport,
isSaving = false,
}: ToolbarProps) {
@ -81,6 +83,12 @@ export default function Toolbar({
>
Export
</button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"