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 = 12 # Ticks between AI updates (in frames) @export var action_delay: float = 0.15 # 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" var _stuck_timer: float = 0.0 var _tekton_spawn_cooldown: float = 0.0 # Prevent spamming spawn actions var _tekton_held_timer: float = 0.0 # Force drop/action after 8s var _idle_stuck_timer: float = 0.0 # Emergency unstuck if sitting still too long var _last_idle_pos: Vector2i = Vector2i(-1, -1) var rng = RandomNumberGenerator.new() # Tile constants const GOAL_TILES = [7, 8, 9, 10] const HOLO_TILES = [11, 12, 13, 14] func _normalize_tile(tile: int) -> int: """Normal tiles 7-10 are goals. 11-14 are powerups and not goals.""" # Treat holo tiles as their normal counterparts for goal matching if tile >= 11 and tile <= 14: return tile - 4 # 11->7, 12->8, etc. return tile func _ready(): # print("[BotController] _ready called for parent: ", get_parent().name) if Engine.is_editor_hint(): return # Desync bots by randomizing their tick counter start rng.seed = name.hash() _tick_counter = rng.randi() % tick_rate # Mobile Optimization: Throttling if OS.has_feature("mobile") or OS.has_feature("android") or OS.has_feature("ios"): tick_rate = int(tick_rate * 1.5) # 50% slower updates on mobile print("[BotController] Mobile detected! Throttling tick rate to: ", tick_rate) # Get parent (should be player character) actor = get_parent() # ... (rest of _ready) ... if not actor: push_error("[BotController] No parent node found") queue_free() return # Only run for bots if not actor.is_in_group("Bots") and not actor.get("is_bot"): queue_free() return # Wait for actor to be fully ready (poll for EnhancedGridMap) var wait_timeout = 3.0 var wait_elapsed = 0.0 while wait_elapsed < wait_timeout: await get_tree().create_timer(0.1).timeout wait_elapsed += 0.1 if actor and actor.get("enhanced_gridmap") and actor.enhanced_gridmap: break enhanced_gridmap = actor.enhanced_gridmap if not enhanced_gridmap: push_error("[BotController] EnhancedGridMap not found for " + actor.name) return # 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) # Guard against peer being torn down (e.g. after host quits a solo match) if not multiplayer.has_multiplayer_peer(): return if not multiplayer.is_server(): return # Only run if game has started if not GameStateManager.is_game_started(): return # STUCK PREVENTION if actor.is_player_moving: _stuck_timer += delta if _stuck_timer > 2.0: print("[BotController] %s stuck in moving state! Force resetting." % actor.name) actor.is_player_moving = false _stuck_timer = 0.0 return # Update Tekton held timer if actor.get("is_carrying_tekton"): _tekton_held_timer += delta if _tekton_held_timer >= 4.0: print("[BotController] %s held Tekton for 4s! Forcing action." % actor.name) _force_tekton_release() _tekton_held_timer = 0.0 else: _tekton_held_timer = 0.0 # IDLE STUCK PREVENTION (If sitting in same spot doing NOTHING) if not actor.is_player_moving and not _is_processing_action: if actor.current_position == _last_idle_pos: _idle_stuck_timer += delta if _idle_stuck_timer >= 4.0: print("[BotController] %s stuck at %s for 4s! Forcing unstuck." % [actor.name, actor.current_position]) _try_unstuck_move() _idle_stuck_timer = 0.0 else: _last_idle_pos = actor.current_position _idle_stuck_timer = 0.0 else: _idle_stuck_timer = 0.0 # Rate limiting (with difficulty scaling for Stop n Go) var current_tick_rate = tick_rate if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 10: current_tick_rate = int(tick_rate * 0.6) # 40% faster updates after column 10 _tick_counter += 1 if _tick_counter < current_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 # Don't make new decisions while moving if actor.is_player_moving: return if TurnManager.turn_based_mode: _tick_counter = tick_rate # Force immediate evaluation in turn-based # Update cooldowns if _tekton_spawn_cooldown > 0: _tekton_spawn_cooldown -= get_process_delta_time() # STOP N GO: Don't process if already at finish line if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x >= 21: return # STOP N GO: Red light freezing logic if _should_freeze_for_stop_n_go() or actor.get("is_frozen") or actor.get("is_stop_frozen"): # print("[BotController] %s is frozen! Skipping tick." % actor.name) return # print("[BotController] AI Tick: evaluating priorities...") # Evaluate board status var board_fullness = _get_board_fullness_ratio() var full_board_priority_mode = board_fullness > 0.6 print("[BotController] %s AI Tick. Goals: %s, Fullness: %.2f" % [actor.name, actor.goals, board_fullness]) # 0. BOT AGGRESSION THRESHOLD (Stop n Go) var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) var can_be_aggressive = not is_sng or actor.current_position.x > 10 # PRIORITY OVERRIDE: If board is getting full, prioritize clearing space! if full_board_priority_mode: print("[BotController] Board fullness %.2f > 0.6! Prioritizing PUT." % board_fullness) if await _try_put(true): # Pass true to indicate high priority/panic check print("[BotController] Action Taken: Put (Priority)") return # Priority 0: Attack Mode (Aggressive Chase) if can_be_aggressive and actor.get("is_attack_mode"): print("[BotController] Attack Mode Active! Hunting targets...") if await _try_attack_chase(): print("[BotController] Action Taken: Attack Pursuit") return # Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying) # Spawning while carrying is high priority; Hunting is medium priority. if await _try_tekton_action(): print("[BotController] Action Taken: Tekton") return # Priority 2: Move toward needed tiles if await _try_move(): print("[BotController] Action Taken: Move") return # Priority 3: Use power-up sabotage if conditions are met if await _try_use_powerup(): print("[BotController] Action Taken: PowerUp") return # Priority 4: Arrange playerboard (Finish what we have FIRST) if await _try_arrange(): print("[BotController] Action Taken: Arrange") return # Priority 5: Grab tiles (goal tiles or holo tiles) if await _try_grab(): print("[BotController] Action Taken: Grab") return # Priority 6: Put tiles back on grid (Standard priority) if not full_board_priority_mode: if await _try_put(): print("[BotController] Action Taken: Put") return if not is_instance_valid(actor): return var goals_achv = _is_goals_achieved() # Only stop completely if objectives are met and at finish (or standard mode) if goals_achv: if is_sng and actor.current_position.x >= 21: return elif not is_sng: return # ============================================================================= # Power-Up / Sabotage # ============================================================================= func _try_tekton_action() -> bool: """Handle Tekton-specific actions (Grab or Spawn).""" var powerup_manager = actor.get_node_or_null("PowerUpManager") if not powerup_manager: return false # CASE 1: Already carrying a Tekton - SPAWN TILES if actor.get("is_carrying_tekton"): # Only spawn if NOT on cooldown and we actually need tiles if _tekton_spawn_cooldown <= 0 and powerup_manager.can_use_special(): var board_fullness = _get_board_fullness_ratio() var needs_tiles = strategic_planner.get_tiles_needed().size() > 0 # Don't spawn if board is too full (> 80% now) or if we don't need any more tiles if board_fullness < 0.8 and needs_tiles: _is_processing_action = true _current_action = "spawning" print("[BotController] %s is carrying Tekton. Spawning tiles!" % actor.name) if powerup_manager.has_method("spawn_boost_reward"): var success = powerup_manager.spawn_boost_reward() _tekton_spawn_cooldown = 8.0 # Reduced cooldown await _wait_with_variance(action_delay * 2.0) _is_processing_action = false return success return false # CASE 2: Not carrying, but full boost - GO GRAB TEKTON (With 60% chance) if powerup_manager.get_bars() >= 2 and rng.randf() < 0.6: var tekton = strategic_planner.find_nearest_roaming_tekton() if tekton: var tekton_pos = tekton.get("current_position") if not tekton_pos: return false var dist = abs(tekton_pos.x - actor.current_position.x) + abs(tekton_pos.y - actor.current_position.y) if dist <= 1: # Adjacent! Grab it print("[BotController] %s is adjacent to Tekton. Grabbing!" % actor.name) actor.grab_tekton() await _wait_with_variance(action_delay) return true else: # Move towards it var path = enhanced_gridmap.find_path( Vector2(actor.current_position), Vector2(tekton_pos), 0, false, false ) if path.size() >= 2: var next_step = Vector2i(path[1].x, path[1].y) print("[BotController] %s moving towards Tekton at %s" % [actor.name, next_step]) if actor.movement_manager.simple_move_to(next_step): _is_processing_action = true _current_action = "moving_to_tekton" # Wait for move while is_instance_valid(actor) and actor.is_player_moving: await get_tree().process_frame _is_processing_action = false return true return false func _force_tekton_release(): """Force the bot to either spawn tiles or drop the Tekton if stuck holding it.""" var powerup_manager = actor.get_node_or_null("PowerUpManager") # Try to spawn tiles first if possible (even if board is a bit full, we are 'forcing' it) if powerup_manager and powerup_manager.can_use_special(): if powerup_manager.has_method("spawn_boost_reward"): print("[BotController] %s forcing spawn instead of drop." % actor.name) powerup_manager.spawn_boost_reward() _tekton_spawn_cooldown = 8.0 return # If can't spawn (no power), just throw it away (drop) if actor.has_method("throw_tekton"): print("[BotController] %s forcing drop/throw." % actor.name) actor.throw_tekton() 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 # If we are carrying a Tekton, _try_tekton_action handles it if actor.get("is_carrying_tekton"): return false # PREVENT LOOP: If already in Attack Mode, do not try to use it again if actor.get("is_attack_mode"): 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" # 50/50 Chance: Attack Mode vs Spawn Item (If not going for Tekton) var success = false if rng.randf() > 0.4: # Slightly higher weight to Attack Mode if explicitly sabotaging # Attack Mode success = powerup_manager.use_special_effect() print("[BotController] %s chose ATTACK MODE." % actor.name) else: # Spawn Item (if supported) if powerup_manager.has_method("spawn_boost_reward"): success = powerup_manager.spawn_boost_reward() print("[BotController] %s chose SPAWN ITEM." % actor.name) else: success = powerup_manager.use_special_effect() if success: print("[BotController] %s used power-up (reason: %s)" % [actor.name, eval.reason]) NotificationManager.send_message(actor, NotificationManager.MESSAGES.USED_SPECIAL_POWER, NotificationManager.MessageType.POWERUP) await _wait_with_variance(action_delay) _is_processing_action = false _current_action = "idle" return success # ============================================================================= # Attack Mode Logic (Aggressive Chase) # ============================================================================= func _try_attack_chase() -> bool: """Find nearest player and move towards them to RAM them.""" var victim = _find_nearest_victim() if not victim: # No victim found? Just behave normally (grab tiles etc) return false # 1. Adjacency Check: If already touching or on same tile, attack directly! var dist_manhattan = abs(victim.current_position.x - actor.current_position.x) + abs(victim.current_position.y - actor.current_position.y) if dist_manhattan <= 1: print("[BotController] %s is close to %s (Dist: %d). Attacking!" % [actor.name, victim.name, dist_manhattan]) var push_dir = victim.current_position - actor.current_position if push_dir == Vector2i.ZERO: # If overlapping, use actor's last move direction or fallback push_dir = actor.movement_manager.last_move_direction if actor.movement_manager else Vector2i(1, 0) # Trigger push logic directly var push_success = actor.movement_manager.try_push(victim.current_position, push_dir) if not push_success: # If attack failed (e.g. Safe Zone in Stop n Go), don't just loop! if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO): if victim.current_position.x in [6, 7, 8, 14, 15, 16]: # Safe Zone Columns print("[BotController] %s target is in Safe Zone. Moving to find better angle." % actor.name) await _try_unstuck_move() _is_processing_action = true _current_action = "attacking" await _wait_with_variance(action_delay) if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" return true # 2. Pathfind to victim if not adjacent var path = enhanced_gridmap.find_path( Vector2(actor.current_position), Vector2(victim.current_position), 0, false, false ) if path.size() >= 2: var next_step = Vector2i(path[1].x, path[1].y) # STOP N GO BOUNDARY PROTECTION if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and next_step.x >= 21: var main = get_tree().root.get_node_or_null("Main") var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null var time_remaining = gc_manager.get_global_time_remaining() if gc_manager else 999.0 var is_late = (gc_manager.is_match_running() and time_remaining > 0.0 and time_remaining <= 30.0) if gc_manager else false if not is_late: # print("[BotController] %s attack blocked by boundary (Not late game yet)." % actor.name) return false # Move to next step # note: simple_move_to will call try_push if next_step is occupied if actor.movement_manager.simple_move_to(next_step): _is_processing_action = true _current_action = "attacking" await _wait_with_variance(action_delay) if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" return true else: # If move failed, it might be because simple_move_to called try_push and returned false # We check if the target is occupied. If it is, and we are in attack mode, # we assume an attack attempt was made. if actor.is_position_occupied(next_step): _is_processing_action = true _current_action = "attacking" await _wait_with_variance(action_delay) if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" return true return false func _find_nearest_victim() -> Node3D: var best_dist = 9999.0 var best_victim = null var players = get_tree().get_nodes_in_group("Players") for p in players: if p == actor or p.is_in_group("Spectators"): continue # Dist check var dist = abs(p.current_position.x - actor.current_position.x) + abs(p.current_position.y - actor.current_position.y) if dist < best_dist: best_dist = dist best_victim = p return best_victim # ============================================================================= # Grab Tiles # ============================================================================= func _try_grab() -> bool: """Try to grab a tile from the grid using direct player control.""" if _is_playerboard_full(): return false # Check fullness - if >80% full, be picky var empty_slots = actor.playerboard.count(-1) if empty_slots <= 2: pass # Check if goals already achieved if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go": return false # Get tiles we need var tiles_needed = strategic_planner.get_tiles_needed() # Find tile to grab var grab_info = _find_tile_to_grab(tiles_needed) if not grab_info.position: return false # Execute grab using PLAYER method (mimic human input) var success = actor.grab_item(grab_info.position) if success: _is_processing_action = true _current_action = "grabbing" print("[BotController] %s grabbed tile via player input!" % actor.name) # Wait for animation await _wait_with_variance(action_delay) if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" return true return false 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) print("[BotController] %s checking current pos %s. Item on Layer 1: %d" % [actor.name, actor.current_position, item]) # Priority: needed tiles > holo tiles > any goal tile var norm_item = _normalize_tile(item) if norm_item in tiles_needed: print("[BotController] %s found NEEDED tile %d (normalized %d) at current pos!" % [actor.name, item, norm_item]) return {"position": actor.current_position, "type": item} if item in HOLO_TILES: print("[BotController] %s found HOLO tile %d at current pos." % [actor.name, item]) result = {"position": actor.current_position, "type": item} # Refined: Only grab at current position (per user request) # Removed neighbor check to prevent bots from "sucking" tiles from 1-tile away return result # ============================================================================= # Movement # ============================================================================= func _try_move() -> bool: """Try to move toward needed tiles taking single steps like a player.""" if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go": return false # Find optimal movement target var final_target = strategic_planner.find_optimal_move_target() if final_target == Vector2i(-1, -1) or final_target == actor.current_position: return false # Calculate path to target var path = enhanced_gridmap.find_path( Vector2(actor.current_position), Vector2(final_target), 0, false, false # Disable visualization ) var next_step = Vector2i(-1, -1) # Need at least current pos + next step if path.size() >= 2: # Extract immediate next step from path next_step = Vector2i(path[1].x, path[1].y) else: # Fallback: Check if target is adjacent and we can move directly var dist = abs(final_target.x - actor.current_position.x) + abs(final_target.y - actor.current_position.y) if dist == 1: print("[BotController] %s performing direct move to adjacent target %s" % [actor.name, final_target]) next_step = final_target else: # PATHFINDING FAILED! (Likely stuck on wall/stand) print("[BotController] %s A* PATH FAILED from %s to %s. Target likely walled off or unreachable." % [actor.name, actor.current_position, final_target]) var unstuck = await _try_unstuck_move() return unstuck # 1. Check if next step is blocked by another player/bot # If we are NOT in attack mode, we should avoid bumping into others if possible if not actor.get("is_attack_mode") and actor.is_position_occupied(next_step): # Try to find a detour? For now, just try an unstuck move to get out of the way print("[BotController] %s path blocked by %s. Detouring." % [actor.name, next_step]) var unstuck = await _try_unstuck_move() return unstuck # 2. Execute movement if actor.movement_manager.simple_move_to(next_step): _is_processing_action = true _current_action = "moving" print("[BotController] %s started move to %s. Current Pos: %s" % [actor.name, next_step, actor.current_position]) # Safety timeout to prevent infinite loop var max_wait_time = 2.0 var elapsed = 0.0 while is_instance_valid(actor) and actor.is_player_moving and is_instance_valid(self): await get_tree().process_frame elapsed += get_process_delta_time() if elapsed > max_wait_time: if is_instance_valid(actor): print("[BotController] %s movement TIMEOUT after %.1fs" % [actor.name, elapsed]) break if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" if is_instance_valid(actor): print("[BotController] %s move finished. New Pos: %s" % [actor.name, actor.current_position]) return true else: print("[BotController] %s simple_move_to BLOCKED (others). Trying unstuck move." % actor.name) return await _try_unstuck_move() return false func _should_freeze_for_stop_n_go() -> bool: """Check if the bot should intentionally skip its turn during STOP phase outside of safe zones.""" var main = get_tree().root.get_node_or_null("Main") if not main: return false var sng_manager = main.get_node_or_null("StopNGoManager") if sng_manager and sng_manager.get("is_active") and sng_manager.get("current_phase") == 1: # Phase.STOP is 1 # Check if we are outside the safe zone var tile = enhanced_gridmap.get_cell_item(Vector3i(actor.current_position.x, 0, actor.current_position.y)) if tile != sng_manager.TILE_SAFE: return true # Red Light! Freeze! return false func _try_unstuck_move() -> bool: """Move to ANY valid neighbor to escape clumping.""" var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) neighbors.shuffle() for n in neighbors: if not n.is_walkable: continue # Attempt move if actor.movement_manager.simple_move_to(n.position): _is_processing_action = true _current_action = "moving_unstuck" print("[BotController] %s Unstuck move initiated to %s" % [actor.name, n.position]) # Proper wait for movement completion var max_wait = 1.5 var elapsed = 0.0 while is_instance_valid(actor) and actor.is_player_moving and is_instance_valid(self): await get_tree().process_frame elapsed += get_process_delta_time() if elapsed > max_wait: break if not is_instance_valid(self) or not is_instance_valid(actor): return true _is_processing_action = false _current_action = "idle" if is_instance_valid(actor): print("[BotController] %s Unstuck move finished at %s" % [actor.name, actor.current_position]) return true return false # ============================================================================= # Put Tiles Back # ============================================================================= func _try_put(high_priority: bool = false) -> bool: """Try to put a tile from playerboard onto grid.""" var put_slot = -1 # Check for Panic Mode var is_panic = false 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 and goals_cycle_manager.get_time_remaining() < 5.0: is_panic = true # Also panic if board is critically full (>80%) or high priority flag set if _get_board_fullness_ratio() > 0.8 or high_priority: is_panic = true if is_panic: put_slot = strategic_planner.get_unneeded_tile_slot_panic() else: put_slot = strategic_planner.get_unneeded_tile_slot() if put_slot == -1: return false var put_position = _find_empty_adjacent_cell() if not put_position: return false _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.playerboard[put_slot] = -1 actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item) actor.rpc("sync_playerboard", actor.playerboard) print("[BotController] %s put unneeded tile %d at %s (Panic: %s)" % [actor.name, item, put_position, is_panic]) await _wait_with_variance(action_delay) if not is_instance_valid(actor) or 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 _is_goals_achieved(): return false var arrangement = _find_best_arrangement() if arrangement.is_empty(): return false _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) print("[BotController] %s arranged slot %d -> %d" % [actor.name, arrangement.source_slot, arrangement.target_slot]) await _wait_with_variance(action_delay) if not is_instance_valid(actor) or 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 _get_board_fullness_ratio() -> float: """Returns ratio of occupied slots (0.0 to 1.0).""" var occupied = 0 for item in actor.playerboard: if item != -1: occupied += 1 return float(occupied) / float(actor.playerboard.size()) func _is_goals_achieved() -> bool: """Check if goal pattern is complete (Standard) or mission complete (Stop n Go).""" if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO): var main = get_tree().root.get_node_or_null("Main") if main: var sng_manager = main.get_node_or_null("StopNGoManager") if sng_manager: return sng_manager.is_mission_complete(actor.name.to_int()) # STANDARD MODE PATTERN CHECK 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) func _wait_with_variance(base_delay: float): var variance = rng.randf_range(-0.05, 0.05) var final_delay = max(0.05, base_delay + variance) await get_tree().create_timer(final_delay).timeout