diff --git a/addons/enhanced_gridmap/enhanced_gridmap.gd b/addons/enhanced_gridmap/enhanced_gridmap.gd index 99dbe34..544ab6c 100644 --- a/addons/enhanced_gridmap/enhanced_gridmap.gd +++ b/addons/enhanced_gridmap/enhanced_gridmap.gd @@ -580,7 +580,9 @@ func clear_path_visualization(floor_index: int = 0): # Cost calculation and updates func get_cell_cost(x: int, z: int, floor_index: int = 0) -> float: var cell_item = get_cell_item(Vector3i(x, floor_index, z)) - if cell_item in non_walkable_items: + + # Block -1 (Void) and Non-Walkable Items + if cell_item == -1 or cell_item in non_walkable_items: return INF elif cell_item == hover_item: return 0.5 diff --git a/scenes/main.gd b/scenes/main.gd index c912bac..27086f6 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -55,6 +55,18 @@ func _ready(): if em: em.cell_size = Vector3(1, 0.05, 1) + # Setup MultiplayerSpawner for Static Tekton Stands + # Create a container node for strict pathing + var stands_container = Node3D.new() + stands_container.name = "Stands" + add_child(stands_container) + + var stand_spawner = MultiplayerSpawner.new() + stand_spawner.name = "StandSpawner" + stand_spawner.spawn_path = NodePath("../Stands") # Relative to Spawner, finding sibling + stand_spawner.add_spawnable_scene("res://scenes/static_tekton_stand.tscn") + add_child(stand_spawner) + func _on_goal_count_updated(peer_id: int, count: int): # Only update for local player if peer_id == multiplayer.get_unique_id(): @@ -699,61 +711,96 @@ func spawn_static_tektons(): # ID: 99000 + i (Consistent IDs for Static Tektons) var id = 99000 + i + # Pick Shape on Server (0:Cyl, 1:Box, 2:Prism, 3:Sphere) + var shape_idx = randi() % 4 + # Spawn on Server - _create_static_setup(pos, id) + _create_static_setup(pos, id, shape_idx) # Sync to Clients - rpc("sync_spawn_static_setup", pos, id) + rpc("sync_spawn_static_setup", pos, id, shape_idx) -@rpc("call_remote", "reliable") -func sync_spawn_static_setup(pos: Vector2i, tekton_id: int): - _create_static_setup(pos, tekton_id) +@rpc("call_local", "reliable") +func sync_spawn_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int): + # Call local creation logic on all peers. + # Server: Spawns Stand + Void + Tekton + # Client: Avoids Stand (Spawner) + Void + Tekton + _create_static_setup(pos, tekton_id, shape_idx) -func _create_static_setup(pos: Vector2i, tekton_id: int): +func _create_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int): """Creates both the Stand and the Static Tekton at the position.""" var enhanced_gridmap = $EnhancedGridMap - # 1. Create Stand - var stand_name = "StaticStand_%d" % tekton_id - if not has_node(stand_name): - var stand_scene = load("res://scenes/static_tekton_stand.tscn") - if stand_scene: - var stand = stand_scene.instantiate() - stand.name = stand_name - add_child(stand) + # 1. Create Stand (Server Only - Synced via Spawner) + # IMPORTANT: Clients receive the Stand via MultiplayerSpawner. + # They MUST NOT spawn it manually here or we get duplicates. + if multiplayer.is_server(): + var stands_container = get_node_or_null("Stands") + if stands_container: + var stand_name = "StaticStand_%d" % tekton_id + if not stands_container.has_node(stand_name): + var stand_scene = load("res://scenes/static_tekton_stand.tscn") + if stand_scene: + var stand = stand_scene.instantiate() + stand.name = stand_name + + # Set Shape Index BEFORE adding to tree (so _ready picks it up/syncs) + if "shape_index" in stand: + stand.shape_index = shape_idx + + stands_container.add_child(stand) + + # Position Stand + if enhanced_gridmap: + # Convert grid to world + var world_pos = Vector3(pos.x + 0.5, 0, pos.y + 0.5) + if "cell_size" in enhanced_gridmap: + world_pos = Vector3( + pos.x * enhanced_gridmap.cell_size.x + enhanced_gridmap.cell_size.x/2, + 0, + pos.y * enhanced_gridmap.cell_size.z + enhanced_gridmap.cell_size.z/2 + ) + stand.global_position = world_pos + + # 2. Modify Base (Void) - Runs on ALL peers to update local GridMap visual/collision + if enhanced_gridmap: + var floor_count = 3 + if "floors" in enhanced_gridmap: + floor_count = enhanced_gridmap.floors - # Position Stand - if enhanced_gridmap: - # Convert grid to world - var world_pos = Vector3(pos.x + 0.5, 0, pos.y + 0.5) - if "cell_size" in enhanced_gridmap: - world_pos = Vector3( - pos.x * enhanced_gridmap.cell_size.x + enhanced_gridmap.cell_size.x/2, - 0, - pos.y * enhanced_gridmap.cell_size.z + enhanced_gridmap.cell_size.z/2 - ) - stand.global_position = world_pos + for dx in range(-1, 2): + for dy in range(-1, 2): + var tile_pos_x = pos.x + dx + var tile_pos_z = pos.y + dy - # Update GridMap to block pathfinding (Item 4 = Wall) - # Mark entire 3x3 area as immutable obstacles on FLOOR 0 (Ground Level) - # This overwrites the ground tile to ensure PlayerMovementManager sees it as blocked. - for dx in range(-1, 2): - for dy in range(-1, 2): - var tile_pos = Vector3i(pos.x + dx, 0, pos.y + dy) - enhanced_gridmap.set_cell_item(tile_pos, 4) - - # CRITICAL: Force AStar update so Bots and Pathfinding know about the new walls - if enhanced_gridmap.has_method("update_astar_costs"): - enhanced_gridmap.update_astar_costs() + # Clear ALL vertical layers (Ground, Items, etc.) + for f in range(floor_count): + var tile_pos = Vector3i(tile_pos_x, f, tile_pos_z) + enhanced_gridmap.set_cell_item(tile_pos, -1) # -1 = Empty/Void + + # CRITICAL: Force AStar update so Bots and Pathfinding know about the new walls + if enhanced_gridmap.has_method("update_astar_costs"): + enhanced_gridmap.update_astar_costs() - # 2. Create Tekton - # Reuse _create_tekton logic but force params - _create_tekton(pos, tekton_id, true) + # 3. Create Tekton Visual - Runs on ALL peers + # NOTE: Tekton NPC is currently not managed by a specialized Spawner for static setup? + # Or it is? If _create_tekton adds it to a path watched by a spawner, we should duplicate check. + # _create_tekton instantiates 'tekton.tscn' and adds to 'Main'. + # Main usually has a MultiplayerSpawner for 'Players' etc., but let's check. + # The original logic spawned it everywhere, so we keep that behavior to be safe. + # But we add a check to avoid duplicates if it already came in via sync. + if not has_node("Tekton_%d" % tekton_id): + _create_tekton(pos, tekton_id, true) - # Force Tekton height UP to sit on stand + # Force Tekton height UP to sit on stand on ALL peers var tekton = get_node_or_null("Tekton_%d" % tekton_id) if tekton: - tekton.position.y += 0.6 # Stand Height + var height_offset = 0.6 + # If Sphere (Index 3), it is taller (Dome) + if shape_idx == 3: + height_offset = 1.3 + + tekton.position.y += height_offset diff --git a/scenes/static_tekton_stand.tscn b/scenes/static_tekton_stand.tscn index 60336e0..04f2d2d 100644 --- a/scenes/static_tekton_stand.tscn +++ b/scenes/static_tekton_stand.tscn @@ -1,4 +1,6 @@ -[gd_scene load_steps=3 format=3 uid="uid://static_tekton_stand_001"] +[gd_scene load_steps=6 format=3 uid="uid://static_tekton_stand_001"] + +[ext_resource type="Script" path="res://scripts/static_tekton_stand.gd" id="1_script"] [sub_resource type="CylinderMesh" id="CylinderMesh_stand"] top_radius = 1.4 @@ -10,12 +12,23 @@ albedo_color = Color(0.15, 0.15, 0.2, 1) metallic = 0.6 roughness = 0.4 -[sub_resource type="CylinderShape3D" id="CylinderShape3D_stand"] -height = 0.6 -radius = 1.4 +[sub_resource type="BoxShape3D" id="BoxShape3D_stand"] +size = Vector3(3.2, 0.6, 3.2) + +[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_stand"] +properties/0/path = NodePath(":shape_index") +properties/0/spawn = true +properties/0/replication_mode = 2 +properties/1/path = NodePath(":global_position") +properties/1/spawn = true +properties/1/replication_mode = 2 [node name="StaticTektonStand" type="StaticBody3D"] collision_mask = 0 +script = ExtResource("1_script") + +[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."] +replication_config = SubResource("SceneReplicationConfig_stand") [node name="MeshInstance3D" type="MeshInstance3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0) @@ -24,4 +37,4 @@ surface_material_override/0 = SubResource("StandardMaterial3D_stand") [node name="CollisionShape3D" type="CollisionShape3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0) -shape = SubResource("CylinderShape3D_stand") +shape = SubResource("BoxShape3D_stand") diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index 301d4e0..abd44da 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -393,30 +393,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? + # 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) - # Removed to allow PUSHING mechanics (simple_move_to handles occupancy/pushing) - # if actor.is_position_occupied(next_step): - # return false + # PATHFINDING FAILED! (Likely stuck on wall/stand) + # Attempt UNSTUCK move to any adjacent valid tile + print("[BotController] Pathfinding failed for %s. Attempting UNSTUCK move." % actor.name) + return await _try_unstuck_move() # 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 or timeout (safety) - # Race: Signal vs Timeout - # Since Godot 4 doesn't support 'await' racing easily without helper, - # we'll just wait for the signal but ensure movement manager emits it. - # safer approach: check if is_moving goes false - # Safety timeout to prevent infinite loop var max_wait_time = 2.0 var elapsed = 0.0 @@ -435,6 +427,37 @@ func _try_move() -> bool: return false +func _try_unstuck_move() -> bool: + """Randomly move to ANY adjacent valid tile to escape sticky situations.""" + var neighbors = enhanced_gridmap.get_neighbors(actor.current_position, 0) + neighbors.shuffle() # Randomize to avoid oscillating + + 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) + + # Wait for move + await _wait_with_variance(action_delay) + if not is_instance_valid(self): return true + _is_processing_action = false + _current_action = "idle" + return true + + print("[BotController] %s is TRULY stuck! No valid neighbors." % actor.name) + return false + # ============================================================================= # Put Tiles Back # ============================================================================= diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 4629990..5e106ed 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -77,6 +77,27 @@ func simple_move_to(grid_position: Vector2i) -> bool: if (cell_item == -1 or cell_item in enhanced_gridmap.non_walkable_items) and not is_wall_passable: return false + # PHYSICS CHECK: Ensure no static obstacles (like Stands) are blocking the path + # GridMap logic handles cells, but Objects/Bodies might be placed on top (like StaticTektonStand) + var space_state = player.get_world_3d().direct_space_state + # RAYCAST HEIGHT: 0.3 (Center of the 0.6m tall stand) + # Check from CENTER using +0.5 + var from = Vector3(player.current_position.x + 0.5, 0.3, player.current_position.y + 0.5) + var to = Vector3(grid_position.x + 0.5, 0.3, grid_position.y + 0.5) + + # Check center of target tile + var query = PhysicsRayQueryParameters3D.create(from, to) + query.collide_with_areas = false + query.collide_with_bodies = true + # query.collision_mask = 1 # Default mask usually covers static bodies + + var result = space_state.intersect_ray(query) + if result: + # If we hit something static that isn't ourselves + if result.collider != player: + print("Movement Blocked by Physics Body: ", result.collider.name) + return false + if player.is_position_occupied(grid_position): var push_dir = grid_position - player.current_position if not try_push(grid_position, push_dir): diff --git a/scripts/managers/static_tekton_manager.gd b/scripts/managers/static_tekton_manager.gd index 086e97b..43b2888 100644 --- a/scripts/managers/static_tekton_manager.gd +++ b/scripts/managers/static_tekton_manager.gd @@ -20,38 +20,109 @@ func calculate_spawn_points(count: int, gridmap: Node) -> Array: """ if count <= 0 or not gridmap: return [] - print("[StaticTektonManager] Calculating %d static tekton positions..." % count) + print("[StaticTektonManager] Calculating static tekton positions (Fixed 5-Zone + Center)...") - # 1. Define Zones + # 1. Define Zones (3x3 Grid) var width = gridmap.columns var depth = gridmap.rows - - # Simple 3x3 grid partition var zone_w = width / 3 var zone_d = depth / 3 - var all_zones = [] + var zones = [] for z in range(3): for x in range(3): - # Create Rect2i for each zone (x, y, w, h) - var zone_rect = Rect2i(x * zone_w, z * zone_d, zone_w, zone_d) - all_zones.append(zone_rect) + zones.append(Rect2i(x * zone_w, z * zone_d, zone_w, zone_d)) - # 2. Select Zones (Random Distinct) - all_zones.shuffle() - var selected_zones = all_zones.slice(0, count) + # 2. Select Fixed Targets: TL(0), TR(2), Center(4), BL(6), BR(8) + # This ensures they are never adjacent (always separated by a middle zone) + var target_indices = [0, 2, 6, 8, 4] + # If count < 5, we prioritize corners then center + # If count > 5, we only return 5 because that's the max safe non-adjacent set in 3x3 var spawn_points = [] - # 3. Pick Point in each Selected Zone - for i in range(selected_zones.size()): - var zone = selected_zones[i] - var pos = _pick_spot_in_zone(zone, gridmap) + var iterations = min(count, target_indices.size()) + for i in range(iterations): + var zone_idx = target_indices[i] + var zone = zones[zone_idx] + + # Determine Position Type for Bias + # 0:TL, 1:TR, 2:BL, 3:BR, 4:Center + var pos_type = -1 + match zone_idx: + 0: pos_type = 0 # TL + 2: pos_type = 1 # TR + 6: pos_type = 2 # BL + 8: pos_type = 3 # BR + 4: pos_type = 4 # Center + + var pos = _pick_spot_in_zone_biased(zone, gridmap, pos_type) if pos != Vector2i(-1, -1): spawn_points.append(pos) return spawn_points +func _pick_spot_in_zone_biased(zone: Rect2i, gridmap: Node, type: int) -> Vector2i: + # type: 0=TL, 1=TR, 2=BL, 3=BR, 4=Center + + # ideal target relative to map + var target = Vector2i.ZERO + match type: + 0: target = Vector2i(0, 0) + 1: target = Vector2i(gridmap.columns, 0) + 2: target = Vector2i(0, gridmap.rows) + 3: target = Vector2i(gridmap.columns, gridmap.rows) + 4: target = Vector2i(gridmap.columns / 2, gridmap.rows / 2) + + # Clamp target to be inside valid area (taking 3x3 margin into account) + # Center of 3x3 must be at least 1 tile from edge + var min_x = max(1, zone.position.x + 1) + var max_x = min(gridmap.columns - 2, zone.position.x + zone.size.x - 2) + var min_y = max(1, zone.position.y + 1) + var max_y = min(gridmap.rows - 2, zone.position.y + zone.size.y - 2) + + if min_x > max_x or min_y > max_y: + return Vector2i(-1, -1) + + var clamped_target = Vector2i( + clamp(target.x, min_x, max_x), + clamp(target.y, min_y, max_y) + ) + + # BFS to find nearest valid 3x3 spot to clamped_target + var queue = [clamped_target] + var visited = {clamped_target: true} + + # Limit search to avoid hanging + var checks = 0 + while not queue.is_empty() and checks < 200: + var current = queue.pop_front() + checks += 1 + + if _is_valid_3x3(current, gridmap): + return current + + var neighbors = [ + Vector2i(0, 1), Vector2i(0, -1), Vector2i(1, 0), Vector2i(-1, 0) + ] + + for n in neighbors: + var next = current + n + if next.x >= min_x and next.x <= max_x and next.y >= min_y and next.y <= max_y: + if not visited.has(next): + visited[next] = true + queue.append(next) + + return Vector2i(-1, -1) + +func _is_valid_3x3(center: Vector2i, gridmap: Node) -> bool: + for dx in range(-1, 2): + for dy in range(-1, 2): + var check_pos = Vector3i(center.x + dx, 0, center.y + dy) + if gridmap.get_cell_item(check_pos) == -1: + return false + return true + func _pick_spot_in_zone(zone: Rect2i, gridmap: Node) -> Vector2i: # Find a valid 3x3 spot in the zone # The returned position is the CENTER of the 3x3 area diff --git a/scripts/static_tekton_stand.gd b/scripts/static_tekton_stand.gd new file mode 100644 index 0000000..8d749c4 --- /dev/null +++ b/scripts/static_tekton_stand.gd @@ -0,0 +1,76 @@ +extends StaticBody3D + +@onready var mesh_instance: MeshInstance3D = $MeshInstance3D + +# Sync the chosen shape so all clients see the same one +@export var shape_index: int = -1: + set(value): + shape_index = value + if is_inside_tree(): + _update_mesh_from_index() + +func _ready(): + if multiplayer.is_server(): + # Only randomize if not already set (Main.gd sets it now) + if shape_index == -1: + shape_index = randi() % 4 + _update_mesh_from_index() + else: + # Client side: + if shape_index != -1: + _update_mesh_from_index() + else: + # If we spawned but data hasn't arrived (unlikely with spawn=true but possible), + # we wait. The setter will trigger update when data arrives. + # But just in case, we can try to request it or use a default + pass + +func _update_mesh_from_index(): + if not mesh_instance: return + + var shapes = [ + _create_cylinder(), + _create_box(), + _create_prism(), + _create_sphere() + ] + + var idx = shape_index % shapes.size() + var selected_mesh = shapes[idx] + + # Apply Material + var mat = StandardMaterial3D.new() + mat.albedo_color = Color(0.2, 0.2, 0.25) + mat.metallic = 0.5 + mat.roughness = 0.5 + selected_mesh.material = mat + + mesh_instance.mesh = selected_mesh + +# Deprecated: _randomize_shape (Logic moved to server init and sync) +# func _randomize_shape(): ... + +func _create_cylinder() -> CylinderMesh: + var mesh = CylinderMesh.new() + mesh.top_radius = 1.4 + mesh.bottom_radius = 1.4 + mesh.height = 0.6 + return mesh + +func _create_box() -> BoxMesh: + var mesh = BoxMesh.new() + mesh.size = Vector3(3.2, 0.6, 3.2) + return mesh + +func _create_prism() -> PrismMesh: + var mesh = PrismMesh.new() + mesh.size = Vector3(3.2, 0.6, 3.2) + return mesh + +func _create_sphere() -> SphereMesh: + # A flattened sphere acting like a dome stand + var mesh = SphereMesh.new() + mesh.radius = 1.4 + mesh.height = 1.0 # Flattened + mesh.is_hemisphere = true + return mesh diff --git a/scripts/static_tekton_stand.gd.uid b/scripts/static_tekton_stand.gd.uid new file mode 100644 index 0000000..7694db6 --- /dev/null +++ b/scripts/static_tekton_stand.gd.uid @@ -0,0 +1 @@ +uid://dny2mjwk1vo0u diff --git a/scripts/tekton.gd b/scripts/tekton.gd index cb37c87..01b9117 100644 --- a/scripts/tekton.gd +++ b/scripts/tekton.gd @@ -48,8 +48,33 @@ func move_to(target_pos: Vector2i): if is_moving or is_carried or is_thrown: return # Validate + # Validate Grid if not enhanced_gridmap.is_position_valid(target_pos): return + + # Validate Physics (Block on Stands) + var space_state = get_world_3d().direct_space_state + # Tekton is slightly offset, so query center of its current and target tiles + # Just checking TARGET center is usually enough to stop entering a blocked tile + # RAY HEIGHT: 0.3 to hit the 0.6m tall stand + var from = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.UP * 5.0 + var to = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.DOWN * 5.0 + + # Raycast VERTICALLY at target to see if it's occupied by a Stand + # Actually, vertical ray from high up is fine IF it goes low enough. + # But let's check intersection at body height to be sure. + from = Vector3(target_pos.x + 0.5, 5.0, target_pos.y + 0.5) + to = Vector3(target_pos.x + 0.5, 0.1, target_pos.y + 0.5) # Go almost to floor + var query = PhysicsRayQueryParameters3D.create(from, to) + query.collide_with_areas = false + query.collide_with_bodies = true + + var result = space_state.intersect_ray(query) + if result: + # If we hit a StaticTektonStand (or any other static blocking body) + if result.collider != self: + # print("Tekton movement blocked by: ", result.collider.name) + return is_moving = true