ralph/collaboration-and-character-variables #5
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue