Files
tekton/scripts/bot_controller.gd
T
2026-04-24 22:56:11 +08:00

879 lines
30 KiB
GDScript

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