extends Node class_name BotController # BotController - Standalone modular bot AI system (no Beehave dependency) # Handles all bot decision-making: movement, grabbing, putting, arranging, and sabotage # Configuration @export var tick_rate: int = 60 # Ticks between AI updates (in frames) @export var action_delay: float = 0.5 # Delay between actions # References var actor: Node3D # The player character this controller is attached to var enhanced_gridmap: Node var strategic_planner: RefCounted # State tracking var _tick_counter: int = 0 var _is_processing_action: bool = false var _current_action: String = "idle" # Tile constants const GOAL_TILES = [7, 8, 9, 10] const HOLO_TILES = [11, 12, 13, 14] func _ready(): # print("[BotController] _ready called for parent: ", get_parent().name) if Engine.is_editor_hint(): return # Get parent (should be player character) actor = get_parent() if not actor: push_error("[BotController] No parent node found") queue_free() return # Only run for bots # DEBUG: Print exact state of checks # print("[BotController] Checking if %s is bot. in_group(Bots): %s, is_bot: %s" % [actor.name, actor.is_in_group("Bots"), actor.get("is_bot")]) if not actor.is_in_group("Bots") and not actor.get("is_bot"): # print("[BotController] Actor is not a bot, removing controller.") queue_free() return # Wait for actor to be fully ready await get_tree().create_timer(1.0).timeout # Increased wait time to 1.0s to be safe enhanced_gridmap = actor.enhanced_gridmap if not enhanced_gridmap: push_error("[BotController] EnhancedGridMap not found for " + actor.name) return # Don't crash, just stop # Initialize strategic planner var BotStrategicPlanner = load("res://scripts/bot_strategic_planner.gd") strategic_planner = BotStrategicPlanner.new(actor, enhanced_gridmap) # Disable input processing for bots actor.set_process_input(false) actor.set_process_unhandled_input(false) print("[BotController] SUCCESFULLY STARTED for bot: %s (Authority: %s)" % [actor.name, actor.get_multiplayer_authority()]) func _exit_tree(): # Ensure explicit cleanup to assist RefCounted cycle breaking strategic_planner = null actor = null enhanced_gridmap = null func _physics_process(_delta): if not is_instance_valid(actor) or not strategic_planner: return # Only run on server/authority (Authority 1) # NOTE: If we are not the server, we should not run logic if not multiplayer.is_server(): return # Rate limiting _tick_counter += 1 if _tick_counter < tick_rate: return _tick_counter = 0 # Don't process if already doing something if _is_processing_action: return # Run AI decision loop print("[BotController] Running AI Tick for ", actor.name) _run_ai_tick() func _run_ai_tick(): """Main AI decision loop - replaces Beehave behavior tree.""" if not is_instance_valid(actor) or _is_processing_action: return print("[BotController] AI Tick: evaluating priorities...") # Priority 1: Use power-up sabotage if conditions are met if await _try_use_powerup(): print("[BotController] Action Taken: PowerUp") return # Priority 2: Grab tiles (goal tiles or holo tiles) if await _try_grab(): print("[BotController] Action Taken: Grab") return # Priority 3: Move toward needed tiles if await _try_move(): print("[BotController] Action Taken: Move") return # Priority 4: Put tiles back on grid (rarely needed) if await _try_put(): print("[BotController] Action Taken: Put") return # Priority 5: Arrange playerboard if await _try_arrange(): print("[BotController] Action Taken: Arrange") return print("[BotController] No action taken (Idle). AP: %d, GoalsAchieved: %s" % [actor.action_points, _is_goals_achieved()]) # ============================================================================= # Power-Up / Sabotage # ============================================================================= func _try_use_powerup() -> bool: """Check and execute power-up sabotage.""" var powerup_manager = actor.get_node_or_null("PowerUpManager") if not powerup_manager or not powerup_manager.can_use_special(): return false # Evaluate sabotage opportunity var eval = strategic_planner.evaluate_sabotage_opportunity() if not eval.should_sabotage: return false # Execute sabotage _is_processing_action = true _current_action = "sabotaging" var success = powerup_manager.use_special_effect() if success: print("[BotController] %s used power-up (reason: %s)" % [actor.name, eval.reason]) var main = get_tree().get_root().get_node_or_null("Main") if main and main.has_method("broadcast_message"): main.rpc("broadcast_message", actor.display_name, "Used a special power!") await get_tree().create_timer(action_delay).timeout if not is_instance_valid(self): return true # Early exit if deleted _is_processing_action = false _current_action = "idle" return success # ============================================================================= # Grab Tiles # ============================================================================= func _try_grab() -> bool: """Try to grab a tile from the grid.""" # Check AP only if turn-based if TurnManager.turn_based_mode and actor.action_points <= 0: return false if _is_playerboard_full(): # print("[BotController] Grab failed: Board full") return false # Check if goals already achieved if _is_goals_achieved(): return false # Get tiles we need var tiles_needed = strategic_planner.get_tiles_needed() # Check current position and adjacent cells for tiles var grab_info = _find_tile_to_grab(tiles_needed) if not grab_info.position: # print("[BotController] Grab failed: No valid tile found in range") return false # Execute grab _is_processing_action = true _current_action = "grabbing" var cell = Vector3i(grab_info.position.x, 1, grab_info.position.y) var item = enhanced_gridmap.get_cell_item(cell) # Handle holo tiles (power-up) if item in HOLO_TILES: if actor.is_multiplayer_authority(): actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) var powerup_manager = actor.get_node_or_null("PowerUpManager") if powerup_manager: powerup_manager.add_holo_pickup() # Only consume AP in turn-based mode if TurnManager.turn_based_mode: actor.action_points -= 1 print("[BotController] %s collected holo tile!" % actor.name) else: # Regular tile - place in playerboard var target_slot = strategic_planner.find_best_slot_for_tile(item) if target_slot != -1 and actor.is_multiplayer_authority(): actor.playerboard[target_slot] = item actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) actor.rpc("sync_playerboard", actor.playerboard) if TurnManager.turn_based_mode: actor.action_points -= 1 print("[BotController] %s grabbed tile %d -> slot %d" % [actor.name, item, target_slot]) # Check goal completion if _is_goals_achieved(): _handle_goal_completion() await get_tree().create_timer(action_delay).timeout if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" return true func _find_tile_to_grab(tiles_needed: Array) -> Dictionary: """Find best tile to grab from current or adjacent positions.""" var result = {"position": null, "type": - 1} # Check current position first var current_cell = Vector3i(actor.current_position.x, 1, actor.current_position.y) var item = enhanced_gridmap.get_cell_item(current_cell) # Priority: needed tiles > holo tiles > any goal tile if item in tiles_needed: return {"position": actor.current_position, "type": item} if item in HOLO_TILES: result = {"position": actor.current_position, "type": item} # Check adjacent cells var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) for neighbor in neighbors: if not neighbor.is_walkable: continue var cell = Vector3i(neighbor.position.x, 1, neighbor.position.y) item = enhanced_gridmap.get_cell_item(cell) if item in tiles_needed: return {"position": neighbor.position, "type": item} if item in HOLO_TILES and not result.position: result = {"position": neighbor.position, "type": item} # Third pass: any goal tile if not result.position: for neighbor in neighbors: if not neighbor.is_walkable: continue var cell = Vector3i(neighbor.position.x, 1, neighbor.position.y) item = enhanced_gridmap.get_cell_item(cell) if item in actor.goals and item != -1: return {"position": neighbor.position, "type": item} return result # ============================================================================= # Movement # ============================================================================= func _try_move() -> bool: """Try to move toward needed tiles.""" if TurnManager.turn_based_mode and actor.action_points <= 0: return false if _is_goals_achieved(): return false # Find optimal movement target var target_pos = strategic_planner.find_optimal_move_target() if target_pos == Vector2i(-1, -1) or target_pos == actor.current_position: print("[BotController] Move failed: No valid target or already at target. Pos: %s" % actor.current_position) return false # Check if within movement range if not actor.is_within_movement_range(target_pos): print("[BotController] Move failed: Target %s out of range" % target_pos) return false # Execute movement _is_processing_action = true _current_action = "moving" if actor.is_multiplayer_authority(): var path = enhanced_gridmap.find_path( Vector2(actor.current_position), Vector2(target_pos), 0, false ) if path.size() > 1: path.pop_front() actor.rpc("start_movement_along_path", path, false) if TurnManager.turn_based_mode: actor.action_points -= 1 var tiles_needed = strategic_planner.get_tiles_needed() print("[BotController] %s moving toward tiles %s (Target: %s)" % [actor.name, tiles_needed, target_pos]) await get_tree().create_timer(action_delay * 2).timeout # Movement takes longer if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" return true # ============================================================================= # Put Tiles Back # ============================================================================= func _try_put() -> bool: """Try to put a tile from playerboard onto grid.""" if actor.action_points <= 0: return false # Find a tile in playerboard that we could put var put_slot = -1 for i in range(actor.playerboard.size()): if actor.playerboard[i] in GOAL_TILES: put_slot = i break if put_slot == -1: return false # Find empty adjacent cell var put_position = _find_empty_adjacent_cell() if not put_position: return false # Execute put _is_processing_action = true _current_action = "putting" if actor.is_multiplayer_authority(): var item = actor.playerboard[put_slot] var cell = Vector3i(put_position.x, 1, put_position.y) actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item) actor.playerboard[put_slot] = -1 actor.rpc("sync_playerboard", actor.playerboard) actor.action_points -= 1 print("[BotController] %s put tile %d at %s" % [actor.name, item, put_position]) await get_tree().create_timer(action_delay).timeout if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" return true func _find_empty_adjacent_cell() -> Vector2i: """Find an empty cell adjacent to player.""" var current_cell = Vector3i(actor.current_position.x, 1, actor.current_position.y) if enhanced_gridmap.get_cell_item(current_cell) == -1: return actor.current_position var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) for neighbor in neighbors: if not neighbor.is_walkable: continue var cell = Vector3i(neighbor.position.x, 1, neighbor.position.y) if enhanced_gridmap.get_cell_item(cell) == -1: return neighbor.position return Vector2i(-1, -1) # ============================================================================= # Arrange Playerboard # ============================================================================= func _try_arrange() -> bool: """Try to rearrange tiles in playerboard.""" if actor.action_points < 2: return false if _is_goals_achieved(): return false # Find misplaced item and better position var arrangement = _find_best_arrangement() if arrangement.is_empty(): return false # Execute arrangement _is_processing_action = true _current_action = "arranging" if actor.is_multiplayer_authority(): var item = actor.playerboard[arrangement.source_slot] actor.playerboard[arrangement.target_slot] = item actor.playerboard[arrangement.source_slot] = -1 actor.rpc("sync_playerboard", actor.playerboard) actor.action_points -= 2 print("[BotController] %s arranged slot %d -> %d" % [actor.name, arrangement.source_slot, arrangement.target_slot]) await get_tree().create_timer(action_delay).timeout if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" return true func _find_best_arrangement() -> Dictionary: """Find a tile that can be moved to a better position.""" for i in range(1, 4): # Check central 3x3 for j in range(1, 4): var board_idx = i * 5 + j var goal_idx = (i - 1) * 3 + (j - 1) var current_item = actor.playerboard[board_idx] var goal_item = actor.goals[goal_idx] if goal_idx < actor.goals.size() else -1 if current_item != goal_item and current_item != -1: # Find better position for this item for gi in range(3): for gj in range(3): if actor.goals[gi * 3 + gj] == current_item: var target_slot = (gi + 1) * 5 + (gj + 1) if actor.playerboard[target_slot] == -1: return {"source_slot": board_idx, "target_slot": target_slot} return {} # ============================================================================= # Utility Functions # ============================================================================= func _is_playerboard_full() -> bool: for item in actor.playerboard: if item == -1: return false return true func _is_goals_achieved() -> bool: """Check if goal pattern is complete in any 3x3 region of playerboard.""" var goals_2d = [] for i in range(3): var row = [] for j in range(3): row.append(actor.goals[i * 3 + j] if i * 3 + j < actor.goals.size() else -1) goals_2d.append(row) var board_2d = [] for i in range(5): var row = [] for j in range(5): row.append(actor.playerboard[i * 5 + j] if i * 5 + j < actor.playerboard.size() else -1) board_2d.append(row) for start_row in range(3): for start_col in range(3): var matches = true for i in range(3): for j in range(3): var board_item = board_2d[start_row + i][start_col + j] var goal_item = goals_2d[i][j] if goal_item != -1 and goal_item != board_item: matches = false break elif goal_item == -1 and board_item != -1: matches = false break if not matches: break if matches: return true return false func _handle_goal_completion(): """Handle goal completion - trigger scoring.""" var main = get_tree().get_root().get_node_or_null("Main") if main: var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") if goals_cycle_manager: var time_remaining = goals_cycle_manager.get_time_remaining() goals_cycle_manager.on_goal_completed(actor, time_remaining) var powerup_manager = actor.get_node_or_null("PowerUpManager") if powerup_manager: powerup_manager.add_goal_completion_reward() print("[BotController] %s COMPLETED GOAL!" % actor.name)