feat: [US-037] - Export to Ren'Py JSON format
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ee0a6f9424
commit
f8f8048e53
|
|
@ -154,6 +154,239 @@ function isValidFlowchartData(data: unknown): data is FlowchartData {
|
||||||
return true
|
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
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
|
|
@ -394,6 +627,45 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [nodes, edges, projectName])
|
}, [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
|
// Check if current flowchart has unsaved changes
|
||||||
const hasUnsavedChanges = useCallback(() => {
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
const currentData: FlowchartData = {
|
const currentData: FlowchartData = {
|
||||||
|
|
@ -668,6 +940,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
onAddVariable={handleAddVariable}
|
onAddVariable={handleAddVariable}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
|
onExportRenpy={handleExportRenpy}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ type ToolbarProps = {
|
||||||
onAddVariable: () => void
|
onAddVariable: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onExportRenpy: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
isSaving?: boolean
|
isSaving?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +17,7 @@ export default function Toolbar({
|
||||||
onAddVariable,
|
onAddVariable,
|
||||||
onSave,
|
onSave,
|
||||||
onExport,
|
onExport,
|
||||||
|
onExportRenpy,
|
||||||
onImport,
|
onImport,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
|
|
@ -81,6 +83,12 @@ export default function Toolbar({
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</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'Py
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onImport}
|
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"
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue