@tool class_name LogicGraph extends GraphEdit signal scenario_mapping_changed signal edit_rejected( message: String ) signal logic_path_selected( cabinet_id: StringName, event_id: StringName, action_id: StringName, device_id: StringName ) var room: RoomDefinition var scenario: ScenarioDefinition var graph_nodes: Dictionary = {} var game_port_events: Dictionary = {} var hardware_node_data: Dictionary = {} var mapping_node_data: Dictionary = {} func _ready() -> void: minimap_enabled = true show_grid = true snapping_enabled = true snapping_distance = 20 connection_lines_thickness = 4.0 add_theme_color_override( "activity", Color(0.0, 1.0, 0.278, 1.0) ) if not connection_request.is_connected( _on_connection_request ): connection_request.connect( _on_connection_request ) if not disconnection_request.is_connected( _on_disconnection_request ): disconnection_request.connect( _on_disconnection_request ) func configure( room_definition: RoomDefinition, scenario_definition: ScenarioDefinition ) -> void: room = room_definition scenario = scenario_definition rebuild() func rebuild() -> void: clear_connections() var children_to_remove: Array[Node] = [] for child in get_children(): if child is GraphNode: children_to_remove.append(child) for child in children_to_remove: # # Remove it from the tree immediately so its # node name becomes available before we rebuild. # remove_child(child) child.queue_free() graph_nodes.clear() game_port_events.clear() hardware_node_data.clear() mapping_node_data.clear() if room == null: return if scenario == null: return _build_graph() func _create_graph_node( node_name: StringName, title: String, position: Vector2 ) -> GraphNode: var node := GraphNode.new() node.name = node_name node.title = title node.position_offset = position node.custom_minimum_size = Vector2( 220.0, 100.0 ) add_child(node) # # Always register using the actual Node name # after it enters the tree. # graph_nodes[node.name] = node return node func _add_row( node: GraphNode, text: String ) -> Label: var label := Label.new() label.text = text label.custom_minimum_size.y = 28.0 node.add_child(label) return label func _set_ports( node: GraphNode, row_index: int, left_enabled: bool, right_enabled: bool ) -> void: node.set_slot( row_index, left_enabled, 0, Color.WHITE, right_enabled, 0, Color.WHITE ) func _build_graph() -> void: var row_index := 0 for assignment in scenario.assignments: if assignment == null: continue if assignment.cabinet == null: continue if assignment.game == null: continue _build_assignment( assignment, row_index ) row_index += 1 func _build_assignment( assignment: ScenarioAssignment, row_index: int ) -> void: var base_y := ( float(row_index) * 500.0 ) var game_node := _create_game_node( assignment, Vector2( 50.0, base_y ) ) var hardware_nodes := ( _build_available_hardware( assignment, base_y ) ) var mapping_number := 0 for mapping in assignment.event_mappings: if mapping == null: continue for action_id in mapping.hardware_actions: if not hardware_nodes.has( action_id ): continue var offset_y := ( base_y + float(mapping_number) * 150.0 ) var mapping_node := ( _create_mapping_node( assignment, mapping, action_id, Vector2( 380.0, offset_y ) ) ) var hardware_node: GraphNode = ( hardware_nodes[ action_id ] ) var device := ( room.get_device_for_action( assignment.cabinet.cabinet_id, action_id ) ) var device_node: GraphNode = null if device != null: device_node = ( _create_device_node( device, assignment.cabinet.cabinet_id, action_id, Vector2( 1050.0, hardware_node.position_offset.y ) ) ) _connect_chain( game_node, mapping, mapping_node, hardware_node, device_node ) mapping_number += 1 func _create_game_node( assignment: ScenarioAssignment, position: Vector2 ) -> GraphNode: var node_name := StringName( "game__%s" % assignment.cabinet.cabinet_id ) var existing := graph_nodes.get( node_name ) as GraphNode if existing != null: return existing var node := _create_graph_node( node_name, "GAME — %s" % assignment.cabinet.display_name, position ) _add_row( node, assignment.game.display_name ) var port_events: Array[StringName] = [] for event_definition in assignment.game.events: if event_definition == null: continue var event_row := _add_row( node, "%s [%s]" % [ event_definition.display_name, event_definition.event_id ] ) _set_ports( node, event_row.get_index(), false, true ) port_events.append( event_definition.event_id ) game_port_events[ node.name ] = port_events return node func _create_mapping_node( assignment: ScenarioAssignment, mapping: ScenarioEventMapping, action_id: StringName, position: Vector2 ) -> GraphNode: var node_name := StringName( "mapping__%s__%s__%s" % [ assignment.cabinet.cabinet_id, mapping.game_event, action_id ] ) var node := _create_graph_node( node_name, "SCENARIO MAPPING", position ) mapping_node_data[ node.name ] = { "cabinet_id": assignment.cabinet.cabinet_id, "event_id": mapping.game_event, "action_id": action_id } var event_row := _add_row( node, String(mapping.game_event) ) _set_ports( node, event_row.get_index(), true, false ) var action_row := _add_row( node, String(action_id) ) _set_ports( node, action_row.get_index(), false, true ) node.node_selected.connect( func() -> void: _emit_mapping_selection( node ) ) return node func _create_hardware_node( assignment: ScenarioAssignment, action_id: StringName, position: Vector2 ) -> GraphNode: var cabinet := assignment.cabinet var hardware := cabinet.hardware_config var action: HardwareAction = null if hardware != null: action = hardware.get_action( action_id ) var node_name := StringName( "hardware__%s__%s" % [ cabinet.cabinet_id, action_id ] ) var node := _create_graph_node( node_name, "HARDWARE", position ) hardware_node_data[ node.name ] = { "cabinet_id": cabinet.cabinet_id, "action_id": action_id } var input_row := _add_row( node, String(action_id) ) _set_ports( node, input_row.get_index(), true, false ) var description := ( "Cabinet: %s" % cabinet.display_name ) _add_row( node, description ) var gpio_text := "GPIO: ?" if action != null: gpio_text = ( "GPIO: %d" % action.gpio_pin ) var output_row := _add_row( node, gpio_text ) _set_ports( node, output_row.get_index(), false, true ) return node func _create_device_node( device: RoomDeviceDefinition, cabinet_id: StringName, action_id: StringName, position: Vector2 ) -> GraphNode: var node_name := StringName( "device__%s__%s__%s" % [ cabinet_id, action_id, device.device_id ] ) var node := _create_graph_node( node_name, "ROOM DEVICE", position ) var row := _add_row( node, device.display_name ) _set_ports( node, row.get_index(), true, false ) return node func _connect_chain( game_node: GraphNode, mapping: ScenarioEventMapping, mapping_node: GraphNode, hardware_node: GraphNode, device_node: GraphNode ) -> void: var game_port := _get_game_event_port( game_node.name, mapping.game_event ) if game_port >= 0: connect_node( game_node.name, game_port, mapping_node.name, 0 ) connect_node( mapping_node.name, 0, hardware_node.name, 0 ) if device_node != null: connect_node( hardware_node.name, 0, device_node.name, 0 ) func _get_game_event_port( game_node_name: StringName, event_id: StringName ) -> int: if not game_port_events.has( game_node_name ): return -1 var events: Array = ( game_port_events[ game_node_name ] ) for index in range( events.size() ): if events[index] == event_id: return index return -1 func set_path_active( cabinet_id: StringName, event_id: StringName, action_id: StringName, device_id: StringName, active: bool ) -> void: var game_name := StringName( "game__%s" % cabinet_id ) var mapping_name := StringName( "mapping__%s__%s__%s" % [ cabinet_id, event_id, action_id ] ) var hardware_name := StringName( "hardware__%s__%s" % [ cabinet_id, action_id ] ) var device_name := StringName( "device__%s__%s__%s" % [ cabinet_id, action_id, device_id ] ) var amount := ( 1.0 if active else 0.0 ) # # The game node has one output port per GameEventDefinition, # so find the correct one for this event. # var game_port := _get_game_event_port( game_name, event_id ) if ( game_port >= 0 and graph_nodes.has(game_name) and graph_nodes.has(mapping_name) ): print( "[LOGIC PATH] cabinet=%s event=%s action=%s device=%s game_port=%d active=%s" % [ cabinet_id, event_id, action_id, device_id, game_port, active ] ) set_connection_activity( game_name, game_port, mapping_name, 0, amount ) print("") print("======= PATH HIGHLIGHT DEBUG =======") print("game_name: ", game_name) print("mapping_name: ", mapping_name) print("hardware_name: ", hardware_name) print("device_name: ", device_name) print("game_port: ", game_port) print( "game->mapping exists: ", is_node_connected( game_name, game_port, mapping_name, 0 ) ) print( "mapping->hardware exists: ", is_node_connected( mapping_name, 0, hardware_name, 0 ) ) print( "hardware->device exists: ", is_node_connected( hardware_name, 0, device_name, 0 ) ) print("activity: ", amount) print("====================================") print("") if ( graph_nodes.has(mapping_name) and graph_nodes.has(hardware_name) ): set_connection_activity( mapping_name, 0, hardware_name, 0, amount ) if ( graph_nodes.has(hardware_name) and graph_nodes.has(device_name) ): set_connection_activity( hardware_name, 0, device_name, 0, amount ) func _build_available_hardware( assignment: ScenarioAssignment, base_y: float ) -> Dictionary: var result: Dictionary = {} var cabinet := assignment.cabinet if cabinet.hardware_config == null: return result var index := 0 for action in cabinet.hardware_config.actions: if action == null: continue var position := Vector2( 720.0, base_y + float(index) * 140.0 ) var node := _create_hardware_node( assignment, action.action_id, position ) result[ action.action_id ] = node index += 1 return result func _on_connection_request( from_node: StringName, from_port: int, to_node: StringName, to_port: int ) -> void: print("") print("======= CONNECTION REQUEST =======") print("FROM NODE: ", from_node) print("FROM PORT: ", from_port) print("TO NODE: ", to_node) print("TO PORT: ", to_port) print("Game nodes known: ", game_port_events.keys()) print("Hardware nodes known: ", hardware_node_data.keys()) print("==================================") print("") # ... existing function continues ... if scenario == null: return if not game_port_events.has( from_node ): _reject_edit( "Connections must start from a game event." ) return if not hardware_node_data.has( to_node ): _reject_edit( "Game events can only connect to cabinet hardware actions." ) return var events: Array = ( game_port_events[from_node] ) if ( from_port < 0 or from_port >= events.size() ): _reject_edit( "Invalid game event port." ) return var event_id: StringName = ( events[from_port] ) var hardware_data: Dictionary = ( hardware_node_data[to_node] ) var cabinet_id: StringName = ( hardware_data["cabinet_id"] ) var action_id: StringName = ( hardware_data["action_id"] ) var source_cabinet_id := ( _get_game_node_cabinet_id( from_node ) ) if source_cabinet_id != cabinet_id: _reject_edit( "Cannot connect events between different cabinets." ) return if _mapping_exists( cabinet_id, event_id, action_id ): _reject_edit( "This scenario mapping already exists." ) return _add_mapping( cabinet_id, event_id, action_id ) func _get_game_node_cabinet_id( game_node_name: StringName ) -> StringName: for assignment in scenario.assignments: if assignment == null: continue if assignment.cabinet == null: continue var expected_name := StringName( "game__%s" % assignment.cabinet.cabinet_id ) if expected_name == game_node_name: return assignment.cabinet.cabinet_id return &"" func _mapping_exists( cabinet_id: StringName, event_id: StringName, action_id: StringName ) -> bool: var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: return false for mapping in assignment.event_mappings: if mapping == null: continue if mapping.game_event != event_id: continue if mapping.hardware_actions.has( action_id ): return true return false func _add_mapping( cabinet_id: StringName, event_id: StringName, action_id: StringName ) -> void: var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: _reject_edit( "Cabinet assignment could not be found." ) return if assignment.game == null: return if not assignment.game.has_event( event_id ): _reject_edit( "Game '%s' does not define event '%s'." % [ assignment.game.game_id, event_id ] ) return if assignment.cabinet.hardware_config == null: return if not assignment.cabinet.hardware_config.has_action( action_id ): _reject_edit( "Cabinet '%s' does not define hardware action '%s'." % [ cabinet_id, action_id ] ) return var target_mapping: ScenarioEventMapping = null for mapping in assignment.event_mappings: if mapping == null: continue if mapping.game_event == event_id: target_mapping = mapping break if target_mapping == null: target_mapping = ( ScenarioEventMapping.new() ) target_mapping.game_event = event_id assignment.event_mappings.append( target_mapping ) target_mapping.hardware_actions.append( action_id ) print( "[LOGIC EDITOR] Added mapping: %s :: %s -> %s" % [ cabinet_id, event_id, action_id ] ) scenario_mapping_changed.emit() rebuild() func _reject_edit( message: String ) -> void: print( "[LOGIC EDITOR] REJECTED: ", message ) edit_rejected.emit( message ) func _on_disconnection_request( from_node: StringName, from_port: int, to_node: StringName, to_port: int ) -> void: var mapping_node_name: StringName = &"" if mapping_node_data.has( from_node ): mapping_node_name = from_node elif mapping_node_data.has( to_node ): mapping_node_name = to_node else: _reject_edit( "This connection belongs to the RoomDefinition and cannot be removed here." ) return var data: Dictionary = ( mapping_node_data[ mapping_node_name ] ) _remove_mapping( data["cabinet_id"], data["event_id"], data["action_id"] ) func _remove_mapping( cabinet_id: StringName, event_id: StringName, action_id: StringName ) -> void: var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: return for mapping in assignment.event_mappings: if mapping == null: continue if mapping.game_event != event_id: continue mapping.hardware_actions.erase( action_id ) # # Remove empty mapping Resources. # if mapping.hardware_actions.is_empty(): assignment.event_mappings.erase( mapping ) print( "[LOGIC EDITOR] Removed mapping: %s :: %s -> %s" % [ cabinet_id, event_id, action_id ] ) scenario_mapping_changed.emit() rebuild() return func _emit_mapping_selection( node: GraphNode ) -> void: if node == null: return if not mapping_node_data.has( node.name ): return var data: Dictionary = ( mapping_node_data[ node.name ] ) var cabinet_id: StringName = ( data["cabinet_id"] ) var event_id: StringName = ( data["event_id"] ) var action_id: StringName = ( data["action_id"] ) var device_id: StringName = &"" if room != null: var device := ( room.get_device_for_action( cabinet_id, action_id ) ) if device != null: device_id = device.device_id logic_path_selected.emit( cabinet_id, event_id, action_id, device_id ) func apply_editor_selection( selection: EscapeRoomEditorSelection ) -> void: if selection == null: return _clear_editor_selection_highlight() if selection.cabinet_id != &"": _highlight_cabinet_logic( selection.cabinet_id ) if ( selection.event_id != &"" and selection.action_id != &"" ): _highlight_specific_logic_path( selection.cabinet_id, selection.event_id, selection.action_id, selection.device_id ) func _highlight_cabinet_logic( cabinet_id: StringName ) -> void: for node_name in graph_nodes: var node: GraphNode = ( graph_nodes[ node_name ] ) if node == null: continue var node_cabinet_id := ( _get_graph_node_cabinet_id( node_name ) ) if node_cabinet_id == cabinet_id: node.modulate = Color.WHITE else: node.modulate = Color( 0.5, 0.5, 0.5, 1.0 ) func _clear_editor_selection_highlight() -> void: for node_name in graph_nodes: var node: GraphNode = graph_nodes[node_name] if node == null: continue node.modulate = Color.WHITE func _highlight_specific_logic_path( _cabinet_id: StringName, _event_id: StringName, _action_id: StringName, _device_id: StringName ) -> void: # # Exact editor-selection connection styling # will be separate from simulation activity. # pass func _get_graph_node_cabinet_id( node_name: StringName ) -> StringName: # # Game nodes already have a reliable lookup. # if game_port_events.has( node_name ): return _get_game_node_cabinet_id( node_name ) # # Scenario mapping nodes. # if mapping_node_data.has( node_name ): var mapping_data: Dictionary = ( mapping_node_data[ node_name ] ) return mapping_data[ "cabinet_id" ] # # Hardware nodes. # if hardware_node_data.has( node_name ): var hardware_data: Dictionary = ( hardware_node_data[ node_name ] ) return hardware_data[ "cabinet_id" ] # # Device node names are: # device__cabinet_id__action_id__device_id # var node_text := String( node_name ) if node_text.begins_with( "device__" ): var parts := node_text.split( "__" ) if parts.size() >= 4: return StringName( parts[1] ) return &""