From e31973dfab97f3d2d5bc6047a60d2416add985ec Mon Sep 17 00:00:00 2001 From: Yogi Wiguna Date: Tue, 24 Feb 2026 16:54:45 +0800 Subject: [PATCH] feat: Add initial player character, movement, network synchronization, bot AI, and game managers. --- scenes/player.gd | 4 + scripts/bot_controller.gd | 157 ++++++++----- scripts/bot_strategic_planner.gd | 238 ++++++++++++++++---- scripts/managers/player_movement_manager.gd | 32 +-- scripts/managers/playerboard_manager.gd | 14 +- scripts/managers/special_tiles_manager.gd | 20 +- scripts/managers/stop_n_go_manager.gd | 29 +-- 7 files changed, 367 insertions(+), 127 deletions(-) diff --git a/scenes/player.gd b/scenes/player.gd index 68eedba..15a1230 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -1283,6 +1283,7 @@ func move_player_to_clicked_position(grid_position: Vector2i): @rpc("any_peer", "call_local") func start_movement_along_path(path: Array, clear_visual: bool = true): + print("[Player] %s starting move along path: %s" % [name, path]) # SERVER-SIDE VIOLATION CHECK (for Stop n Go) if multiplayer.is_server() and LobbyManager.game_mode == "Stop n Go": var main = get_tree().root.get_node_or_null("Main") @@ -1317,10 +1318,13 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): tween.tween_property(self , "global_position", grid_to_world(Vector2i(point.x, point.y)), step_duration) tween.tween_callback(func(): + var old_pos = current_position current_position = Vector2i(path[-1].x, path[-1].y) is_player_moving = false target_position = Vector2i(-1, -1) + print("[Player] %s finished move. %s -> %s" % [name, old_pos, current_position]) + if is_carrying_tekton and is_instance_valid(carried_tekton): carried_tekton.current_position = current_position diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index e55a397..d180b05 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -24,6 +24,13 @@ var rng = RandomNumberGenerator.new() 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(): @@ -98,9 +105,13 @@ func _physics_process(delta): else: _stuck_timer = 0.0 - # Rate limiting + # Rate limiting (with difficulty scaling for Stop n Go) + var current_tick_rate = tick_rate + if LobbyManager.game_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 < tick_rate: + if _tick_counter < current_tick_rate: return _tick_counter = 0 @@ -121,9 +132,13 @@ func _run_ai_tick(): if actor.is_player_moving: return + # STOP N GO: Don't process if already at finish line + if LobbyManager.game_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(): - # print("[BotController] %s freezes for STOP phase!" % actor.name) + 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...") @@ -132,6 +147,12 @@ func _run_ai_tick(): 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.game_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) @@ -140,41 +161,51 @@ func _run_ai_tick(): return # Priority 0: Attack Mode (Aggressive Chase) - if actor.get("is_attack_mode"): + 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: Use power-up sabotage if conditions are met + # Priority 1: Use power-up sabotage if conditions are met (Aggression threshold handled in planner) if await _try_use_powerup(): print("[BotController] Action Taken: PowerUp") return - # Priority 2: Grab tiles (goal tiles or holo tiles) + # Priority 2: Arrange playerboard (Finish what we have FIRST) + if await _try_arrange(): + print("[BotController] Action Taken: Arrange") + return + + # Priority 3: 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 + + # Priority 4: Move toward needed tiles if await _try_move(): print("[BotController] Action Taken: Move") return - - # Priority 4: Put tiles back on grid (Standard priority) + + # Priority 5: 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()]) + var goals_achv = _is_goals_achieved() + + if actor.action_points > 1: # Only print if they have multi-AP + print("[BotController] %s AI Tick. AP: %d, GoalsAchieved: %s, Board: %s" % [actor.name, actor.action_points, str(goals_achv), str(board_fullness)]) + + # Only stop completely if objectives are met AND we are at the finish line (if applicable) + if goals_achv: + if is_sng and actor.current_position.x >= 21: + return + elif not is_sng: + return # In standard mode, goal achievement = game over usually # STALL PREVENTION: If we have AP but couldn't do anything, we are stuck. # Skip turn to prevent game freeze in turn-based mode. @@ -254,6 +285,17 @@ func _try_attack_chase() -> bool: if path.size() >= 2: var next_step = Vector2i(path[1].x, path[1].y) + # STOP N GO BOUNDARY PROTECTION + if LobbyManager.game_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 (If occupied by victim, movement_manager will trigger PUSH) if actor.movement_manager.simple_move_to(next_step): _is_processing_action = true @@ -293,13 +335,6 @@ func _try_grab() -> bool: 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 @@ -309,7 +344,7 @@ func _try_grab() -> bool: pass # Check if goals already achieved - if _is_goals_achieved(): + if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go": return false # Get tiles we need @@ -339,28 +374,36 @@ func _try_grab() -> bool: 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} + 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 - if item in tiles_needed: + 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} # Check adjacent cells var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) + # print("[BotController] %s has %d walkable neighbors" % [actor.name, neighbors.size()]) 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) + norm_item = _normalize_tile(item) - if item in tiles_needed: + # print("[BotController] %s checking neighbor %s. Item: %d" % [actor.name, neighbor.position, item]) + + if norm_item in tiles_needed: + print("[BotController] %s found NEEDED tile %d (normalized %d) at neighbor %s!" % [actor.name, item, norm_item, neighbor.position]) return {"position": neighbor.position, "type": item} if item in HOLO_TILES and not result.position: @@ -377,7 +420,7 @@ func _try_move() -> bool: if TurnManager.turn_based_mode and actor.action_points <= 0: return false - if _is_goals_achieved(): + if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go": return false # Find optimal movement target @@ -402,21 +445,22 @@ func _try_move() -> bool: # 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 + # 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) - # Attempt UNSTUCK move to any adjacent valid tile - return await _try_unstuck_move() + 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 # Execute SINGLE STEP movement using player manager 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 @@ -426,13 +470,17 @@ func _try_move() -> bool: await get_tree().process_frame elapsed += get_process_delta_time() if elapsed > max_wait_time: - print("[BotController] Movement timed out!") + print("[BotController] %s movement TIMEOUT after %.1fs" % [actor.name, elapsed]) break if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" + 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 @@ -451,34 +499,33 @@ func _should_freeze_for_stop_n_go() -> bool: return false func _try_unstuck_move() -> bool: - """Randomly move to ANY adjacent valid tile to escape sticky situations.""" + """Move to ANY valid neighbor to escape clumping.""" var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) - neighbors.shuffle() # Randomize to avoid oscillating + neighbors.shuffle() for n in neighbors: if not n.is_walkable: continue - var cell = Vector3i(n.position.x, 0, n.position.y) # Check Floor 0 - var item = enhanced_gridmap.get_cell_item(cell) - - # Ensure we don't walk into a wall (Item 4) or Void (-1) - # Obstacles should be checked by is_walkable but let's be sure - if item == 4 or item == -1: continue - # Attempt move if actor.movement_manager.simple_move_to(n.position): _is_processing_action = true _current_action = "moving_unstuck" - print("[BotController] Unstuck move to %s" % n.position) + print("[BotController] %s Unstuck move initiated to %s" % [actor.name, n.position]) - # Wait for move - await _wait_with_variance(action_delay) + # Proper wait for movement completion + var max_wait = 1.5 + var elapsed = 0.0 + while 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): return true _is_processing_action = false _current_action = "idle" + print("[BotController] %s Unstuck move finished at %s" % [actor.name, actor.current_position]) return true - print("[BotController] %s is TRULY stuck! No valid neighbors." % actor.name) return false # ============================================================================= @@ -629,7 +676,15 @@ func _get_board_fullness_ratio() -> float: return float(occupied) / float(actor.playerboard.size()) func _is_goals_achieved() -> bool: - """Check if goal pattern is complete in any 3x3 region of playerboard.""" + """Check if goal pattern is complete (Standard) or mission complete (Stop n Go).""" + if LobbyManager.game_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 = [] diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index aab5ec2..bc5db12 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -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 diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 9eb74f5..6eb7cc5 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -55,7 +55,7 @@ func simple_move_to(grid_position: Vector2i) -> bool: return false if not player.is_multiplayer_authority(): - # print("[Move] Failed: Not authority for ", player.name) + print("[Move] Failed: Not authority for %s (Authority: %d, My Peer: %d)" % [player.name, player.get_multiplayer_authority(), player.multiplayer.get_unique_id()]) return false if player.get("is_frozen") or player.get("is_stop_frozen"): @@ -144,7 +144,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: # === NEW LOGIC: Only allow push if in ATTACK MODE === if not player.get("is_attack_mode"): # Standard bumping effect or nothing? - # User said "Remove standard push", so we just do nothing or small shake + print("[Move] Push blocked: Not in attack mode (%s trying to push)" % player.name) return false # === SUPER PUSH (Attack Mode) === @@ -196,19 +196,23 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: # Consume all available boost to force a full recharge cycle player.powerup_manager.consume_boost(100.0) - # SCORING: 200 Points for successful attack + # SCORING: 200 Points for successful attack (ONLY in Free Mode) if player.is_multiplayer_authority(): - var main = player.get_tree().get_root().get_node_or_null("Main") - if main: - var gcm = main.get_node_or_null("GoalsCycleManager") - if gcm: - if multiplayer.is_server(): - # Server/Bot: Directly add score to specific player ID - gcm.add_score(player.name.to_int(), 200) - else: - # Client: Request score add (sender ID used) - gcm.rpc("request_add_score", 200) - NotificationManager.send_message(player, "Successful Attack! +200 Pts", NotificationManager.MessageType.GOAL) + var is_sng = LobbyManager.game_mode == "Stop n Go" + if not is_sng: + var main = player.get_tree().get_root().get_node_or_null("Main") + if main: + var gcm = main.get_node_or_null("GoalsCycleManager") + if gcm: + if multiplayer.is_server(): + # Server/Bot: Directly add score to specific player ID + gcm.add_score(player.name.to_int(), 200) + else: + # Client: Request score add (sender ID used) + gcm.rpc("request_add_score", 200) + NotificationManager.send_message(player, "Successful Attack! +200 Pts", NotificationManager.MessageType.GOAL) + else: + NotificationManager.send_message(player, "Successful Attack!", NotificationManager.MessageType.GOAL) # 5. Attack Mode Persistence # logic moved to consume_boost: checks if <= 0 then disables. diff --git a/scripts/managers/playerboard_manager.gd b/scripts/managers/playerboard_manager.gd index ae6451d..93f7bf9 100644 --- a/scripts/managers/playerboard_manager.gd +++ b/scripts/managers/playerboard_manager.gd @@ -31,10 +31,15 @@ func _normalize_tile(tile: int) -> int: func grab_item(grid_position: Vector2i) -> bool: var has_ap = player.action_points > 0 if TurnManager.turn_based_mode else true - if not enhanced_gridmap or not has_ap: + if not enhanced_gridmap: + print("[Grab] Failed for %s: enhanced_gridmap is null" % player.name) + return false + if not has_ap: + print("[Grab] Failed for %s: no AP (%d)" % [player.name, player.action_points]) return false if player.get("is_frozen"): + print("[Grab] Failed for %s: player is frozen" % player.name) return false var cell = Vector3i(grid_position.x, 1, grid_position.y) @@ -49,9 +54,11 @@ func grab_item(grid_position: Vector2i) -> bool: is_adjacent = true break if not is_adjacent: + print("[Grab] Failed for %s: %s is not adjacent to current %s" % [player.name, grid_position, player.current_position]) return false if item == -1: + print("[Grab] Failed for %s: no item at %s Layer 1" % [player.name, grid_position]) return false # === AUTO-ARRANGE LOGIC (Client-side pre-check) === @@ -62,12 +69,15 @@ func grab_item(grid_position: Vector2i) -> bool: if not is_powerup: target_slot = find_best_goal_slot_for_item(item) if target_slot == -1: - print("Player: No valid slot found for item.") + print("[Grab] Failed for %s: No valid slot found for item %d." % [player.name, item]) return false # no space if not player.is_multiplayer_authority(): + print("[Grab] Failed for %s: not authority" % player.name) return false + print("[Grab] %s SUCCESS! Grabbing item %d at %s into slot %d" % [player.name, item, grid_position, target_slot]) + # Play pickup animation (synced across network) if player.is_multiplayer_authority() and player.has_method("sync_pickup_animation"): player.rpc("sync_pickup_animation") diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index 2526870..2f3b1bc 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -315,14 +315,18 @@ func _execute_area_freeze(center_pos: Vector2i = Vector2i.ZERO): hit_count += 1 if hit_count > 0 and player.is_multiplayer_authority(): - var points = hit_count * 50 - var main = player.get_tree().get_root().get_node_or_null("Main") - if main: - var gcm = main.get_node_or_null("GoalsCycleManager") - if gcm: - gcm.rpc("request_add_score", points) - - NotificationManager.send_message(player, "Hit %d Players! +%d Pts" % [hit_count, points], NotificationManager.MessageType.GOAL) + var is_sng = LobbyManager.game_mode == "Stop n Go" + if not is_sng: + var points = hit_count * 50 + var main = player.get_tree().get_root().get_node_or_null("Main") + if main: + var gcm = main.get_node_or_null("GoalsCycleManager") + if gcm: + gcm.rpc("request_add_score", points) + + NotificationManager.send_message(player, "Hit %d Players! +%d Pts" % [hit_count, points], NotificationManager.MessageType.GOAL) + else: + NotificationManager.send_message(player, "Hit %d Players!" % hit_count, NotificationManager.MessageType.GOAL) # Visual Feedback (Turn Floor Blue - Item 12 on Layer 0) if player.is_multiplayer_authority(): diff --git a/scripts/managers/stop_n_go_manager.gd b/scripts/managers/stop_n_go_manager.gd index 89a86f0..691a315 100644 --- a/scripts/managers/stop_n_go_manager.gd +++ b/scripts/managers/stop_n_go_manager.gd @@ -11,7 +11,7 @@ enum Phase {GO, STOP} const GO_DURATION: float = 8.0 const STOP_DURATION: float = 4.0 -const REQUIRED_GOALS: int = 2 +const REQUIRED_GOALS: int = 5 var current_phase: Phase = Phase.GO var phase_timer: float = GO_DURATION @@ -416,12 +416,7 @@ func sync_mission_progress(_player_id: int, _mission_index: int, _current: int): # Deprecated pass -func check_win_condition(player_id: int, position: Vector2i) -> bool: - # 1. Must reach the finish line (Column 21) - if position.x < finish_line_x: - return false - - # 2. Must have enough Goal Completions (tracked by GoalsCycleManager) +func is_mission_complete(player_id: int) -> bool: var main = get_node_or_null("/root/Main") if not main: return false @@ -429,15 +424,23 @@ func check_win_condition(player_id: int, position: Vector2i) -> bool: if not goals_cycle_manager: return false var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0) - - if completed_count >= REQUIRED_GOALS: - print("[StopNGo] Player %d REACHED FINISH with %d goals complete!" % [player_id, completed_count]) + return completed_count >= REQUIRED_GOALS + +func check_win_condition(player_id: int, position: Vector2i) -> bool: + # 1. Must reach the finish line (Column 21) + if position.x < finish_line_x: + return false + + # 2. Must have enough Goal Completions + if is_mission_complete(player_id): + print("[StopNGo] Player %d REACHED FINISH with goals complete!" % player_id) return true else: # Inform the player locally if they reach the end without goals - var player_node = main.get_node_or_null(str(player_id)) + var main = get_node_or_null("/root/Main") + var player_node = main.get_node_or_null(str(player_id)) if main else null if player_node: - NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals (x%d) to win!" % [REQUIRED_GOALS, REQUIRED_GOALS], NotificationManager.MessageType.WARNING) + NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % REQUIRED_GOALS, NotificationManager.MessageType.WARNING) - print("[StopNGo] Player %d reached finish but goal count too low: %d/%d" % [player_id, completed_count, REQUIRED_GOALS]) + print("[StopNGo] Player %d reached finish but goals incomplete." % player_id) return false