550 lines
17 KiB
GDScript
550 lines
17 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 rng = RandomNumberGenerator.new()
|
|
|
|
# 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
|
|
|
|
# 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
|
|
await get_tree().create_timer(1.0).timeout
|
|
|
|
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)
|
|
if not multiplayer.is_server():
|
|
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
|
|
else:
|
|
_stuck_timer = 0.0
|
|
|
|
# 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
|
|
|
|
# Don't make new decisions while moving
|
|
if actor.is_player_moving:
|
|
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
|
|
|
|
# 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 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)
|
|
# ONLY if not in critical full mode (unless it's a critical needed tile?)
|
|
# Refined: If > 80% full, disable grabbing completely unless strict conditions met inner function
|
|
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 (Standard priority)
|
|
if not full_board_priority_mode:
|
|
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] %s - No action taken (Idle). AP: %d, GoalsAchieved: %s" % [actor.name, actor.action_points, _is_goals_achieved()])
|
|
|
|
# STALL PREVENTION: If we have AP but couldn't do anything, we are stuck.
|
|
# Skip turn to prevent game freeze in turn-based mode.
|
|
if TurnManager.turn_based_mode and actor.action_points > 0:
|
|
print("[BotController] %s is STUCK with AP %d! Skipping turn to proceed flow." % [actor.name, actor.action_points])
|
|
actor.consume_action_points(actor.action_points)
|
|
|
|
# =============================================================================
|
|
# 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 _wait_with_variance(action_delay)
|
|
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 using direct player control."""
|
|
# Check AP only if turn-based
|
|
if TurnManager.turn_based_mode and actor.action_points <= 0:
|
|
return false
|
|
|
|
# PANIC MODE CHECK
|
|
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:
|
|
return false
|
|
|
|
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():
|
|
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): 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)
|
|
|
|
# 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}
|
|
|
|
return result
|
|
|
|
# =============================================================================
|
|
# Movement
|
|
# =============================================================================
|
|
|
|
func _try_move() -> bool:
|
|
"""Try to move toward needed tiles taking single steps like a player."""
|
|
if TurnManager.turn_based_mode and actor.action_points <= 0:
|
|
return false
|
|
|
|
if _is_goals_achieved():
|
|
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
|
|
)
|
|
|
|
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: Pathfinding failed or target is too close?
|
|
# 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:
|
|
next_step = final_target
|
|
else:
|
|
return false
|
|
|
|
# Redundant safety check (simple_move_to also checks this)
|
|
if actor.is_position_occupied(next_step):
|
|
return false
|
|
|
|
# Execute SINGLE STEP movement using player manager
|
|
if actor.movement_manager.simple_move_to(next_step):
|
|
_is_processing_action = true
|
|
_current_action = "moving"
|
|
|
|
# Wait for movement to finish (signal from movement manager)
|
|
await actor.movement_manager.movement_finished
|
|
|
|
|
|
if not is_instance_valid(self): return true
|
|
_is_processing_action = false
|
|
_current_action = "idle"
|
|
return true
|
|
|
|
return false
|
|
|
|
# =============================================================================
|
|
# Put Tiles Back
|
|
# =============================================================================
|
|
|
|
func _try_put(high_priority: bool = false) -> bool:
|
|
"""Try to put a tile from playerboard onto grid."""
|
|
if actor.action_points <= 0:
|
|
return false
|
|
|
|
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:
|
|
# Aggressive dumping
|
|
put_slot = strategic_planner.get_unneeded_tile_slot_panic()
|
|
else:
|
|
# Normal smart dumping
|
|
put_slot = strategic_planner.get_unneeded_tile_slot()
|
|
|
|
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.playerboard[put_slot] = -1
|
|
actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item)
|
|
actor.rpc("sync_playerboard", actor.playerboard)
|
|
actor.action_points -= 1
|
|
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(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 _wait_with_variance(action_delay)
|
|
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 _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 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)
|
|
|
|
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
|