feat: Add initial player character, movement, network synchronization, bot AI, and game managers.

This commit is contained in:
Yogi Wiguna
2026-02-24 16:54:45 +08:00
parent aa6d6dcec2
commit e31973dfab
7 changed files with 367 additions and 127 deletions
+199 -39
View File
@@ -15,6 +15,13 @@ 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
# =============================================================================
@@ -40,7 +47,7 @@ func calculate_goal_progress() -> float:
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:
if board_idx < actor.playerboard.size() and _normalize_tile(actor.playerboard[board_idx]) == goal_value:
matches += 1
if total_required == 0:
@@ -53,6 +60,7 @@ func get_tiles_needed() -> Array:
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):
@@ -66,10 +74,11 @@ func get_tiles_needed() -> Array:
continue
var board_idx = (i + 1) * 5 + (j + 1)
if board_idx >= actor.playerboard.size() or actor.playerboard[board_idx] != goal_value:
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:
@@ -238,20 +247,17 @@ func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
# 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:
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):
# 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.
# In Stop n Go, prefer tiles "ahead" (higher X)
var is_stop_n_go = LobbyManager.game_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
@@ -266,6 +272,22 @@ func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
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
@@ -279,7 +301,7 @@ func _check_spiral_cell(x: int, z: int, tile_types: Array, result_array: Array):
var cell = Vector3i(x, 1, z)
var item = enhanced_gridmap.get_cell_item(cell)
if item in tile_types:
if _normalize_tile(item) in tile_types:
result_array.append(Vector2i(x, z))
# =============================================================================
@@ -288,62 +310,184 @@ func _check_spiral_cell(x: int, z: int, tile_types: Array, result_array: Array):
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.game_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()
# 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)
# Priority targets: needed tiles > holo tiles > any goal tile
var targets_to_try = []
# 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)
# 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)
# 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)
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
return _get_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 can reach the target directly, return it
if _is_within_movement_range(target):
# 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
# Otherwise, move one step closer
# 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 positions_to_try = [
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 pos in positions_to_try:
if _is_valid_move_target(pos):
return pos
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
var dist = max(abs(pos.x - current_pos.x), abs(pos.y - current_pos.y))
return dist <= actor.movement_range
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) -> bool:
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
if actor.is_position_occupied(pos):
# 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
@@ -376,6 +520,10 @@ func evaluate_sabotage_opportunity() -> Dictionary:
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.game_mode == "Stop n Go" and actor.current_position.x <= 10:
return result
# Get opponents
var opponents = _get_opponents()
if opponents.size() == 0:
@@ -391,9 +539,13 @@ func evaluate_sabotage_opportunity() -> Dictionary:
return result
# Condition 2: Opponent is close to completing their goal
var progress_threshold = 0.7
if LobbyManager.game_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 >= 0.7: # 70% complete
if opponent_progress >= progress_threshold:
result.should_sabotage = true
result.reason = "opponent_close_to_winning"
result.target = opponent
@@ -409,6 +561,14 @@ func evaluate_sabotage_opportunity() -> Dictionary:
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.game_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