626 lines
22 KiB
GDScript
626 lines
22 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
|
|
|
|
func _normalize_tile(tile: int) -> int:
|
|
"""Normal tiles 7-10 are goals. 11-14 are powerups and not goals."""
|
|
# If it's a holo tile, treat it as its normal counterpart for goal matching
|
|
if tile >= 11 and tile <= 14:
|
|
return tile - 4 # 11->7, 12->8, etc.
|
|
return tile
|
|
|
|
# =============================================================================
|
|
# 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 _normalize_tile(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:
|
|
# print("[BotStrategicPlanner] %s - No goals assigned yet." % actor.name)
|
|
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 _normalize_tile(actor.playerboard[board_idx]) != goal_value:
|
|
if not goal_value in needed:
|
|
needed.append(goal_value)
|
|
|
|
# print("[BotStrategicPlanner] %s goals: %s. Needed: %s" % [actor.name, actor.goals, needed])
|
|
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)
|
|
var center_item = enhanced_gridmap.get_cell_item(center_cell)
|
|
if _normalize_tile(center_item) in tile_types:
|
|
return current_pos
|
|
|
|
for r in range(1, max_radius + 1):
|
|
var found_in_layer = []
|
|
|
|
# In Stop n Go, prefer tiles "ahead" (higher X)
|
|
var is_stop_n_go = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
|
|
|
# Check the ring
|
|
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)
|
|
|
|
# DIRECTIONAL BIAS: In Stop n Go, penalize tiles that are "behind" us
|
|
if is_stop_n_go:
|
|
if current_pos.x <= 10:
|
|
# EARLY GAME: Extremely focused on moving right
|
|
if pos.x < current_pos.x:
|
|
dist += 10 # Heavier penalty for backtracking
|
|
elif pos.x > current_pos.x:
|
|
dist -= 4 # Heavier bonus for moving forward
|
|
else:
|
|
# LATE GAME: Normal bias
|
|
if pos.x < current_pos.x:
|
|
dist += 5
|
|
elif pos.x > current_pos.x:
|
|
dist -= 2
|
|
|
|
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 _normalize_tile(item) in tile_types:
|
|
result_array.append(Vector2i(x, z))
|
|
|
|
func find_nearest_roaming_tekton() -> Node3D:
|
|
"""Find the nearest Tekton that isn't carried and is on the grid."""
|
|
var tektons = actor.get_tree().get_nodes_in_group("Tektons")
|
|
var nearest_tekton = null
|
|
var min_dist = 999999.0
|
|
|
|
for tekton in tektons:
|
|
if not is_instance_valid(tekton): continue
|
|
if tekton.get("is_carried") or tekton.get("is_static_turret"): continue
|
|
if tekton.get("is_recovering"): continue # Cannot target shrinking/recovering Tektons
|
|
|
|
var dist = actor.global_position.distance_to(tekton.global_position)
|
|
if dist < min_dist:
|
|
min_dist = dist
|
|
nearest_tekton = tekton
|
|
|
|
return nearest_tekton
|
|
|
|
# =============================================================================
|
|
# Movement Strategy
|
|
# =============================================================================
|
|
|
|
func find_optimal_move_target() -> Vector2i:
|
|
"""Calculate the best position to move towards."""
|
|
var main = actor.get_tree().get_root().get_node_or_null("Main")
|
|
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
|
var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
|
var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0
|
|
var is_match_running = gc_manager.is_match_running() if gc_manager else false
|
|
var is_late_game = is_sng and is_match_running and time_left > 0.0 and time_left <= 30.0
|
|
|
|
# 1. STOP N GO: Reach the finish line if goals are complete
|
|
if is_sng and main:
|
|
var sng_manager = main.get_node_or_null("StopNGoManager")
|
|
if sng_manager and sng_manager.is_mission_complete(actor.name.to_int()):
|
|
# MISSION COMPLETE: We CAN finish, but should we?
|
|
|
|
if not is_late_game:
|
|
# CHAOS MODE: Allow falling through to target holo tiles, but we'll limit the target X later
|
|
# print("[BotStrategicPlanner] %s mission complete (Chaos Phase %.1fs). Roaming field." % [actor.name, time_left])
|
|
pass
|
|
else:
|
|
# Late game: go to finish
|
|
var finish_target = Vector2i(21, actor.current_position.y)
|
|
|
|
# Ensure finish_target is walkable
|
|
if not _is_valid_move_target(finish_target):
|
|
for dy in [1, -1, 2, -2]:
|
|
var alt = Vector2i(21, actor.current_position.y + dy)
|
|
if _is_valid_move_target(alt):
|
|
finish_target = alt
|
|
break
|
|
|
|
print("[BotStrategicPlanner] %s mission complete (Late Game %.1fs)! Heading to finish: %s" % [actor.name, time_left, finish_target])
|
|
return finish_target
|
|
|
|
var sng_manager = main.get_node_or_null("StopNGoManager") if main else null
|
|
var is_mission_complete = sng_manager.is_mission_complete(actor.name.to_int()) if sng_manager else false
|
|
|
|
var needed_tiles = get_tiles_needed()
|
|
|
|
# Priority targets: needed tiles > holo tiles > any goal tile
|
|
var targets_to_try = []
|
|
|
|
# If mission is complete, we don't need goal tiles or specific board tiles
|
|
if not is_mission_complete:
|
|
if needed_tiles.size() > 0:
|
|
targets_to_try.append(needed_tiles)
|
|
|
|
var pu_manager = actor.get_node_or_null("PowerUpManager")
|
|
if pu_manager and pu_manager.current_points < pu_manager.MAX_POINTS:
|
|
targets_to_try.append(HOLO_TILES)
|
|
|
|
if not is_mission_complete:
|
|
targets_to_try.append(GOAL_TILES)
|
|
|
|
# CONSTRAINT: In Stop n Go, NEVER target X >= 21 unless it's late game (last 30s)
|
|
var max_x = 22 # No limit by default
|
|
if is_sng and not is_late_game:
|
|
max_x = 20
|
|
|
|
for tile_set in targets_to_try:
|
|
var target = find_nearest_tile_of_type(tile_set)
|
|
if target != Vector2i(-1, -1) and target.x <= max_x:
|
|
# Just return the target directly if it's a valid tile position.
|
|
# The BotController will use A* to find the path.
|
|
# We only need _get_adjacent_position if the target itself is an obstacle (e.g. Tekton Stand).
|
|
if _is_valid_move_target(target, true):
|
|
return target
|
|
else:
|
|
# If we can't stand ON it (e.g. it's on a stand), find a spot NEXT to it.
|
|
var final = _get_adjacent_position(target)
|
|
if final != actor.current_position and final.x <= max_x:
|
|
return final
|
|
|
|
# Fallback: move Right in Stop n Go mode even if idle
|
|
if is_sng:
|
|
# Only force forward if we haven't finished our mission OR time is almost up
|
|
# DRIFT PREVENTION: Only step right if incomplete AND not already late in the track
|
|
# If they reach column 16 without goals, they should stay there and wait for items.
|
|
if (not is_mission_complete and actor.current_position.x < 16) or is_late_game:
|
|
var right_step = actor.current_position + Vector2i(1, 0)
|
|
if _is_valid_move_target(right_step):
|
|
return right_step
|
|
|
|
# Fallback: random valid position
|
|
var rnd = _get_random_valid_position()
|
|
# Apply X constraint to random move
|
|
if is_sng and rnd.x > max_x:
|
|
rnd.x = max_x # Clamp to safe zone
|
|
if not _is_valid_move_target(rnd):
|
|
# Try to find any other valid y at this x
|
|
for dy in [1, -1, 2, -2]:
|
|
var alt = Vector2i(rnd.x, rnd.y + dy)
|
|
if _is_valid_move_target(alt):
|
|
rnd = alt
|
|
break
|
|
if rnd.x > max_x: return actor.current_position # Last resort
|
|
|
|
return rnd
|
|
|
|
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 are already at the target, stay there
|
|
if current_pos == target:
|
|
return target
|
|
|
|
# If the target is walkable and within range, return it
|
|
if _is_valid_move_target(target) and _is_within_movement_range(target):
|
|
return target
|
|
|
|
# 1. ORTHOGONAL NEIGHBORS (Normal priority)
|
|
var neighbors = [
|
|
target + Vector2i(1, 0), target + Vector2i(-1, 0),
|
|
target + Vector2i(0, 1), target + Vector2i(0, -1)
|
|
]
|
|
|
|
# Priority: Pick neighbors that are NOT our current position first
|
|
var candidates = []
|
|
for n_pos in neighbors:
|
|
# Use ignore_players=true here because we want to see ALL potentially walkable paths
|
|
# The movement manager will handle actual collisions/pushes
|
|
if _is_valid_move_target(n_pos, true) and _is_within_movement_range(n_pos):
|
|
candidates.append(n_pos)
|
|
|
|
if candidates.size() > 0:
|
|
# If we have candidates that aren't where we are, pick the closest one to target
|
|
var non_current = candidates.filter(func(p): return p != current_pos)
|
|
if non_current.size() > 0:
|
|
non_current.sort_custom(func(a, b):
|
|
return (a - current_pos).length_squared() < (b - current_pos).length_squared()
|
|
)
|
|
return non_current[0]
|
|
else:
|
|
# If only option is current pos, we are "at" the target neighbor
|
|
return current_pos
|
|
|
|
# 2. STEP CLOSER FALLBACK
|
|
var dx = sign(target.x - current_pos.x)
|
|
var dz = sign(target.y - current_pos.y)
|
|
|
|
var steps = [
|
|
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 step in steps:
|
|
if _is_valid_move_target(step, true): # Ignore players for step planning
|
|
return step
|
|
|
|
return Vector2i(-1, -1)
|
|
|
|
func _is_within_movement_range(pos: Vector2i) -> bool:
|
|
var current_pos = actor.current_position
|
|
if actor.get("use_diagonal_movement"):
|
|
return max(abs(pos.x - current_pos.x), abs(pos.y - current_pos.y)) <= actor.movement_range
|
|
else:
|
|
return (abs(pos.x - current_pos.x) + abs(pos.y - current_pos.y)) <= actor.movement_range
|
|
|
|
func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
|
|
if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos):
|
|
return false
|
|
|
|
# Check Floor 0 (Ground/Walls)
|
|
var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
|
if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items:
|
|
return false
|
|
|
|
# Check Floor 1 (Items/Obstacles)
|
|
var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 1, pos.y))
|
|
if item != -1 and item in enhanced_gridmap.non_walkable_items:
|
|
return false
|
|
|
|
# Check Physics (Stands/Static Objects)
|
|
if actor.movement_manager and actor.movement_manager.has_method("_is_position_blocked_by_physics"):
|
|
if actor.movement_manager._is_position_blocked_by_physics(pos):
|
|
return false
|
|
|
|
if not ignore_players and 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
|
|
|
|
# 0. STOP N GO THRESHOLD: No sabotage until passing column 10
|
|
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x <= 10:
|
|
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
|
|
var progress_threshold = 0.7
|
|
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 10:
|
|
progress_threshold = 0.4 # More aggressive in late game!
|
|
|
|
for opponent in opponents:
|
|
var opponent_progress = _estimate_opponent_progress(opponent)
|
|
if opponent_progress >= progress_threshold:
|
|
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
|
|
|
|
# Condition 4: Random Aggression (Stop n Go Late Game)
|
|
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 12:
|
|
if randf() < 0.3: # 30% chance each tick to just be mean
|
|
result.should_sabotage = true
|
|
result.reason = "random_aggression"
|
|
result.target = opponents[randi() % opponents.size()]
|
|
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
|