Files
tekton/scripts/bot_strategic_planner.gd
T

448 lines
15 KiB
GDScript

extends RefCounted
class_name BotStrategicPlanner
# BotStrategicPlanner - Strategic decision-making for bot AI
# Evaluates tile needs, pathfinding targets, and sabotage opportunities
var actor: Node3D
var enhanced_gridmap: Node
# Tile type constants
const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
func _init(p_actor: Node3D, p_gridmap: Node):
actor = p_actor
enhanced_gridmap = p_gridmap
# =============================================================================
# Goal Analysis
# =============================================================================
func calculate_goal_progress() -> float:
"""Returns 0.0-1.0 representing how close bot is to completing their goal."""
if not actor or actor.goals.size() == 0:
return 0.0
var matches = 0
var total_required = 0
# Check center 3x3 of playerboard against goals
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
if goal_idx >= actor.goals.size():
continue
var goal_value = actor.goals[goal_idx]
if goal_value == -1:
continue # Empty goal slot
total_required += 1
var board_idx = (i + 1) * 5 + (j + 1) # Center 3x3 in 5x5 board
if board_idx < actor.playerboard.size() and actor.playerboard[board_idx] == goal_value:
matches += 1
if total_required == 0:
return 1.0 # All goals are -1 (empty)
return float(matches) / float(total_required)
func get_tiles_needed() -> Array:
"""Returns array of tile types still needed to complete goal."""
var needed = []
if not actor or actor.goals.size() == 0:
return needed
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
if goal_idx >= actor.goals.size():
continue
var goal_value = actor.goals[goal_idx]
if goal_value == -1:
continue
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx >= actor.playerboard.size() or actor.playerboard[board_idx] != goal_value:
if not goal_value in needed:
needed.append(goal_value)
return needed
func find_best_slot_for_tile(tile_type: int) -> int:
"""Find the best playerboard slot for a given tile type."""
# Check goals to find matching position
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
if goal_idx < actor.goals.size() and actor.goals[goal_idx] == tile_type:
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx < actor.playerboard.size() and actor.playerboard[board_idx] == -1:
return board_idx
# Fallback: any empty slot
return actor.playerboard.find(-1)
func get_unneeded_tile_slot() -> int:
"""Find a slot containing a tile that is not needed for the goal."""
if not actor or actor.playerboard.size() == 0:
return -1
var needed_tiles = get_tiles_needed()
# Check center 3x3 for misplaced tiles
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx >= actor.playerboard.size():
continue
var current_item = actor.playerboard[board_idx]
if current_item == -1:
continue
# If this position has a specific goal
if goal_idx < actor.goals.size() and actor.goals[goal_idx] != -1:
# If current item doesn't match the goal for this position
if current_item != actor.goals[goal_idx]:
# AND we don't need this tile type elsewhere (or we have enough)
# Simplified: if it's not in needed_tiles, dump it.
# Note: needed_tiles calculation includes checking if we already have it in correct spot.
# But if we have it in WRONG spot, it might still remain in needed list?
# current get_tiles_needed logic: if board_idx != goal_value, add to needed.
# So if we have it here (wrong spot), it is still "needed" for the right spot.
# So we should only dump it if we have duplicates or if we truly don't need it.
# For now, simplistic approach: If it's not in the goal set AT ALL, dump it.
if not current_item in actor.goals:
return board_idx
# If it IS in goals but wrong spot, only dump if we can't arrange it?
# Or if we have too many of them?
# Let's count how many we have vs how many we need
var count_have = actor.playerboard.count(current_item)
var count_need = actor.goals.count(current_item)
if count_have > count_need:
return board_idx
# If this position is supposed to be empty (-1) but has item
elif goal_idx < actor.goals.size() and actor.goals[goal_idx] == -1:
return board_idx
# Check outer ring (non-goal area) - always dump unless saving for arrangement
# 5x5 board. Center 3x3 is indices: 6,7,8, 11,12,13, 16,17,18
var center_indices = [6, 7, 8, 11, 12, 13, 16, 17, 18]
for i in range(actor.playerboard.size()):
if not i in center_indices and actor.playerboard[i] != -1:
var item = actor.playerboard[i]
# Only keep if we strictly need it and can't find it easily?
# Actually, generally dump outer ring tiles to keep board clean
# unless we are about to move it to a valid spot.
# But BotController tries to arrange.
# If we have an outer tile that is needed, Arrange should handle it.
# If Arrange failed (lower priority checks), then Put should dump it.
return i
return -1
return -1
func get_unneeded_tile_slot_panic() -> int:
"""Aggressively find ANY tile that doesn't match a goal perfectly."""
if not actor or actor.playerboard.size() == 0:
return -1
# In panic mode, dump anything not matching goals
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx >= actor.playerboard.size(): continue
var item = actor.playerboard[board_idx]
if item == -1: continue
if goal_idx < actor.goals.size():
if actor.goals[goal_idx] != -1:
if item != actor.goals[goal_idx]: return board_idx
else:
return board_idx
# Dump outer ring
var center = [6, 7, 8, 11, 12, 13, 16, 17, 18]
for i in range(actor.playerboard.size()):
if not i in center and actor.playerboard[i] != -1: return i
return -1
# =============================================================================
# Tile Finding
# =============================================================================
func find_best_tile_to_grab() -> Dictionary:
"""Find the best tile to grab, prioritizing goal tiles then holo tiles."""
var needed_tiles = get_tiles_needed()
var best_tile = {"position": null, "type": - 1, "priority": 0}
if not enhanced_gridmap:
return best_tile
# Search nearby area for tiles
var search_radius = 5
var current_pos = actor.current_position
for dx in range(-search_radius, search_radius + 1):
for dz in range(-search_radius, search_radius + 1):
var pos = Vector2i(current_pos.x + dx, current_pos.y + dz)
if not enhanced_gridmap.is_position_valid(pos):
continue
var cell = Vector3i(pos.x, 1, pos.y)
var item = enhanced_gridmap.get_cell_item(cell)
if item == -1:
continue
var priority = 0
# Priority 1: Tiles we need for goals
if item in needed_tiles:
priority = 10 - abs(dx) - abs(dz) # Closer = higher priority
# Priority 2: Holo tiles for power-ups
elif item in HOLO_TILES:
priority = 5 - abs(dx) - abs(dz)
elif item in GOAL_TILES:
priority = 1 # Low priority - might be useful later
if priority > best_tile.priority:
best_tile = {"position": pos, "type": item, "priority": priority}
return best_tile
func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
"""Find nearest tile matching any type in array using optimized spiral search."""
var current_pos = actor.current_position
if not enhanced_gridmap:
return Vector2i(-1, -1)
# Optimization: Start check at simple radius
# If we find something in the spiral, it is guaranteed to be one of the nearest (by Chebyshev distance logic broadly, or just good enough)
var max_radius = 25 # Limit search range to prevent full map scans on huge maps
if OS.has_feature("mobile"):
max_radius = 15 # Stricter limit on mobile
# Check center first
var center_cell = Vector3i(current_pos.x, 1, current_pos.y)
if enhanced_gridmap.get_cell_item(center_cell) in tile_types:
return current_pos
for r in range(1, max_radius + 1):
# Spiral perimeter:
# Top row: (x-r, y-r) to (x+r, y-r)
# Bottom row: (x-r, y+r) to (x+r, y+r)
# Left col: (x-r, y-r+1) to (x-r, y+r-1)
# Right col: (x+r, y-r+1) to (x+r, y+r-1)
var found_in_layer = []
# We'll check the ring. Note: Manhattan distance might be better metric for "nearest"
# but layer-by-layer is efficient for finding "close enough" quickly.
for x_off in range(-r, r + 1):
_check_spiral_cell(current_pos.x + x_off, current_pos.y - r, tile_types, found_in_layer) # Top
_check_spiral_cell(current_pos.x + x_off, current_pos.y + r, tile_types, found_in_layer) # Bottom
for y_off in range(-r + 1, r):
_check_spiral_cell(current_pos.x - r, current_pos.y + y_off, tile_types, found_in_layer) # Left
_check_spiral_cell(current_pos.x + r, current_pos.y + y_off, tile_types, found_in_layer) # Right
if found_in_layer.size() > 0:
# If we found candidates in this layer, pick the physically closest one (Euclidean/Manhattan refinement)
var nearest_in_layer = found_in_layer[0]
var min_dist = 999999
for pos in found_in_layer:
var dist = abs(pos.x - current_pos.x) + abs(pos.y - current_pos.y)
if dist < min_dist:
min_dist = dist
nearest_in_layer = pos
return nearest_in_layer
return Vector2i(-1, -1)
func _check_spiral_cell(x: int, z: int, tile_types: Array, result_array: Array):
if x < 0 or z < 0 or x >= enhanced_gridmap.columns or z >= enhanced_gridmap.rows:
return
var cell = Vector3i(x, 1, z)
var item = enhanced_gridmap.get_cell_item(cell)
if item in tile_types:
result_array.append(Vector2i(x, z))
# =============================================================================
# Movement Strategy
# =============================================================================
func find_optimal_move_target() -> Vector2i:
"""Calculate the best position to move towards."""
var needed_tiles = get_tiles_needed()
# First: move toward tiles we need
if needed_tiles.size() > 0:
var target = find_nearest_tile_of_type(needed_tiles)
if target != Vector2i(-1, -1):
return _get_adjacent_position(target)
# Second: move toward holo tiles if we need power-ups
var powerup_manager = actor.get_node_or_null("PowerUpManager")
if powerup_manager and powerup_manager.current_points < powerup_manager.MAX_POINTS:
var target = find_nearest_tile_of_type(HOLO_TILES)
if target != Vector2i(-1, -1):
return _get_adjacent_position(target)
# Third: move toward any goal tile that might be useful
var target = find_nearest_tile_of_type(GOAL_TILES)
if target != Vector2i(-1, -1):
return _get_adjacent_position(target)
# Fallback: random valid position
return _get_random_valid_position()
func _get_adjacent_position(target: Vector2i) -> Vector2i:
"""Get a valid position adjacent to or at the target."""
var current_pos = actor.current_position
# If we can reach the target directly, return it
if _is_within_movement_range(target):
return target
# Otherwise, move one step closer
var dx = sign(target.x - current_pos.x)
var dz = sign(target.y - current_pos.y)
var positions_to_try = [
Vector2i(current_pos.x + dx, current_pos.y + dz),
Vector2i(current_pos.x + dx, current_pos.y),
Vector2i(current_pos.x, current_pos.y + dz)
]
for pos in positions_to_try:
if _is_valid_move_target(pos):
return pos
return Vector2i(-1, -1)
func _is_within_movement_range(pos: Vector2i) -> bool:
var current_pos = actor.current_position
var dist = max(abs(pos.x - current_pos.x), abs(pos.y - current_pos.y))
return dist <= actor.movement_range
func _is_valid_move_target(pos: Vector2i) -> bool:
if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos):
return false
if actor.is_position_occupied(pos):
return false
return true
func _get_random_valid_position() -> Vector2i:
var valid_positions = []
var current_pos = actor.current_position
var range_val = actor.movement_range
for dx in range(-range_val, range_val + 1):
for dz in range(-range_val, range_val + 1):
if dx == 0 and dz == 0:
continue
var pos = Vector2i(current_pos.x + dx, current_pos.y + dz)
if _is_valid_move_target(pos):
valid_positions.append(pos)
if valid_positions.size() > 0:
return valid_positions[randi() % valid_positions.size()]
return Vector2i(-1, -1)
# =============================================================================
# Sabotage Strategy
# =============================================================================
func evaluate_sabotage_opportunity() -> Dictionary:
"""Evaluate whether to use power-up for sabotage."""
var result = {"should_sabotage": false, "reason": "", "target": null}
var powerup_manager = actor.get_node_or_null("PowerUpManager")
if not powerup_manager or not powerup_manager.can_use_special():
return result
# Get opponents
var opponents = _get_opponents()
if opponents.size() == 0:
return result
# Check conditions for sabotage (balanced strategy)
# Condition 1: Power-ups are maxed - use it or lose potential gains
if powerup_manager.current_points >= powerup_manager.MAX_POINTS:
result.should_sabotage = true
result.reason = "max_powerup"
result.target = opponents[randi() % opponents.size()]
return result
# Condition 2: Opponent is close to completing their goal
for opponent in opponents:
var opponent_progress = _estimate_opponent_progress(opponent)
if opponent_progress >= 0.7: # 70% complete
result.should_sabotage = true
result.reason = "opponent_close_to_winning"
result.target = opponent
return result
# Condition 3: Bot is behind in score - need to catch up
var goals_cycle_manager = actor.get_tree().get_root().get_node_or_null("Main/GoalsCycleManager")
if goals_cycle_manager:
var leaderboard = goals_cycle_manager.get_leaderboard()
var my_rank = _get_rank_in_leaderboard(leaderboard)
if my_rank > 1 and powerup_manager.get_bars() >= 2:
result.should_sabotage = true
result.reason = "behind_in_score"
result.target = opponents[0] if opponents.size() > 0 else null
return result
return result
func _get_opponents() -> Array:
var all_players = actor.get_tree().get_nodes_in_group("Players")
return all_players.filter(func(p): return p != actor)
func _estimate_opponent_progress(opponent: Node) -> float:
"""Estimate opponent's goal progress based on their playerboard."""
if not opponent or opponent.goals.size() == 0:
return 0.0
var matches = 0
var total = 0
for i in range(3):
for j in range(3):
var goal_idx = i * 3 + j
if goal_idx >= opponent.goals.size():
continue
var goal_value = opponent.goals[goal_idx]
if goal_value == -1:
continue
total += 1
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx < opponent.playerboard.size() and opponent.playerboard[board_idx] == goal_value:
matches += 1
return float(matches) / float(max(total, 1))
func _get_rank_in_leaderboard(leaderboard: Array) -> int:
var my_id = actor.get_multiplayer_authority()
for i in range(leaderboard.size()):
if leaderboard[i].get("peer_id", -1) == my_id:
return i + 1
return leaderboard.size() + 1