diff --git a/systems/map/map_runtime.gd b/systems/map/map_runtime.gd index 5fedfdd..7b8098b 100644 --- a/systems/map/map_runtime.gd +++ b/systems/map/map_runtime.gd @@ -9,6 +9,9 @@ const DOOR_WIDTH := 42.0 const DOOR_HEIGHT := 132.0 const DOOR_GLOW_COLOR := Color(0.66, 1.0, 0.84, 0.92) const DOOR_FRAME_COLOR := Color(0.18, 0.22, 0.28, 0.98) +const FUNCTION_ROOM_INDICES := [4, 5, 6] +const FUNCTION_CHOICE_SOURCE_ROOM_INDEX := 3 +const FUNCTION_CHOICE_Y_RATIOS := [0.28, 0.50, 0.72] var world_root: Node2D = null var spawn_marker: Marker2D = null @@ -19,6 +22,7 @@ var map_camera: Camera2D = null var map_cover_root: Node2D = null var map_room_rects: Array[Rect2] = [] var map_walkable_rects: Array[Rect2] = [] +var selected_function_room_index: int = -1 func setup(p_world_root: Node2D, p_spawn_marker: Marker2D, p_encounter_marker: Marker2D, p_rng: RandomNumberGenerator) -> void: @@ -106,28 +110,86 @@ func _build_runtime_map_rooms() -> void: map_room_rects.clear() map_walkable_rects.clear() var x_cursor := 0.0 + var function_room_x := -1.0 for index in range(MapBrowserDemo.ROOM_PATHS.size()): var texture := RuntimeTextureLoader.load_texture(String(MapBrowserDemo.ROOM_PATHS[index])) if texture == null: push_warning("Missing map texture: %s" % MapBrowserDemo.ROOM_PATHS[index]) continue - var sprite := Sprite2D.new() - sprite.name = "MapRoom%02d" % [index + 1] - sprite.texture = texture - sprite.centered = false - sprite.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR - sprite.position = Vector2(x_cursor, 0.0) - map_root.add_child(sprite) - var room_rect := Rect2(sprite.position, Vector2(float(texture.get_width()), float(texture.get_height()))) + if index == FUNCTION_ROOM_INDICES[0]: + function_room_x = x_cursor + var room_x := function_room_x if index in FUNCTION_ROOM_INDICES else x_cursor + var room_rect := Rect2(Vector2(room_x, 0.0), Vector2(float(texture.get_width()), float(texture.get_height()))) var walk_rect := _map_walkable_rect(index, room_rect) map_room_rects.append(room_rect) map_walkable_rects.append(walk_rect) - _add_runtime_room_walls(index, room_rect) - _add_runtime_room_doors(index, room_rect, walk_rect) - x_cursor += room_rect.size.x + if index not in FUNCTION_ROOM_INDICES: + _add_runtime_room_visual(index, texture, room_rect) + _add_runtime_room_walls(index, room_rect) + _add_runtime_room_doors(index, room_rect, walk_rect) + if index == FUNCTION_ROOM_INDICES[FUNCTION_ROOM_INDICES.size() - 1]: + x_cursor = function_room_x + room_rect.size.x + elif index not in FUNCTION_ROOM_INDICES: + x_cursor += room_rect.size.x _add_runtime_room_props() +func _add_runtime_room_visual(index: int, texture: Texture2D, room_rect: Rect2) -> void: + var sprite := Sprite2D.new() + sprite.name = "MapRoom%02d" % [index + 1] + sprite.texture = texture + sprite.centered = false + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + sprite.position = room_rect.position + sprite.set_meta("room_index", index) + map_root.add_child(sprite) + + +func select_function_room(room_index: int) -> bool: + if selected_function_room_index >= 0 or room_index not in FUNCTION_ROOM_INDICES: + return false + var texture := RuntimeTextureLoader.load_texture(String(MapBrowserDemo.ROOM_PATHS[room_index])) + if texture == null: + return false + selected_function_room_index = room_index + _add_runtime_room_visual(room_index, texture, map_room_rects[room_index]) + _add_runtime_room_walls(room_index, map_room_rects[room_index]) + _add_runtime_room_doors(room_index, map_room_rects[room_index], map_walkable_rects[room_index]) + _add_random_props_for_room(room_index) + return true + + +func reset_function_room_selection() -> void: + selected_function_room_index = -1 + if map_root != null: + for child in map_root.get_children(): + var child_name := String(child.name) + if child_name.begins_with("MapRoom05") or child_name.begins_with("MapRoom06") or child_name.begins_with("MapRoom07") or child_name.begins_with("Room05") or child_name.begins_with("Room06") or child_name.begins_with("Room07"): + child.queue_free() + if map_cover_root != null: + for child in map_cover_root.get_children(): + if int(child.get_meta("room_index", -1)) in FUNCTION_ROOM_INDICES: + child.queue_free() + + +func function_room_choice_for_position(world_position: Vector2) -> int: + if selected_function_room_index >= 0 or map_room_rects.size() <= FUNCTION_CHOICE_SOURCE_ROOM_INDEX: + return selected_function_room_index + var source_rect := map_room_rects[FUNCTION_CHOICE_SOURCE_ROOM_INDEX] + if world_position.x < source_rect.end.x - 46.0: + return -1 + var walk_rect := map_walkable_rects[FUNCTION_CHOICE_SOURCE_ROOM_INDEX] + var best_index := 0 + var best_distance := INF + for choice_index in range(FUNCTION_CHOICE_Y_RATIOS.size()): + var choice_y := walk_rect.position.y + walk_rect.size.y * float(FUNCTION_CHOICE_Y_RATIOS[choice_index]) + var distance := absf(world_position.y - choice_y) + if distance < best_distance: + best_distance = distance + best_index = choice_index + return int(FUNCTION_ROOM_INDICES[best_index]) + + func _map_walkable_rect(index: int, room_rect: Rect2) -> Rect2: var ratio: Rect2 = MapBrowserDemo.WALKABLE_AREAS[min(index, MapBrowserDemo.WALKABLE_AREAS.size() - 1)] return Rect2( @@ -140,6 +202,9 @@ func _add_runtime_room_walls(index: int, room_rect: Rect2) -> void: var wall_rects := MapBrowserDemo.get_room_wall_rects(index, room_rect) for wall_index in range(wall_rects.size()): _add_runtime_wall("Room%02dWall%02d" % [index + 1, wall_index + 1], wall_rects[wall_index]) + var circle_collisions := MapBrowserDemo.get_room_circle_collisions(index, room_rect) + for circle_index in range(circle_collisions.size()): + _add_runtime_circle_wall("Room%02dCircle%02d" % [index + 1, circle_index + 1], circle_collisions[circle_index]) func _add_runtime_wall(wall_name: String, rect: Rect2) -> void: @@ -158,6 +223,25 @@ func _add_runtime_wall(wall_name: String, rect: Rect2) -> void: shape.shape = rectangle body.add_child(shape) +func _add_runtime_circle_wall(wall_name: String, data: Dictionary) -> void: + if map_root == null: + return + var radius := float(data.get("radius", 0.0)) + if radius <= 0.0: + return + var body := StaticBody2D.new() + body.name = wall_name + body.collision_layer = 1 + body.collision_mask = 2 + body.add_to_group("projectile_blocker") + body.position = data.get("center", Vector2.ZERO) as Vector2 + map_root.add_child(body) + var shape := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = radius + shape.shape = circle + body.add_child(shape) + func _add_runtime_room_doors(room_index: int, room_rect: Rect2, walk_rect: Rect2) -> void: if map_root == null: @@ -166,7 +250,11 @@ func _add_runtime_room_doors(room_index: int, room_rect: Rect2, walk_rect: Rect2 var exit_center := Vector2(room_rect.end.x - 10.0, walk_rect.get_center().y) if room_index > 0: _add_door_marker("Room%02dEntrance" % [room_index + 1], entrance_center, true) - if room_index < MapBrowserDemo.ROOM_PATHS.size() - 1: + if room_index == FUNCTION_CHOICE_SOURCE_ROOM_INDEX: + for choice_index in range(FUNCTION_CHOICE_Y_RATIOS.size()): + var choice_center := Vector2(exit_center.x, walk_rect.position.y + walk_rect.size.y * float(FUNCTION_CHOICE_Y_RATIOS[choice_index])) + _add_door_marker("FunctionChoice%02d" % [choice_index + 1], choice_center, false) + elif room_index < MapBrowserDemo.ROOM_PATHS.size() - 1: _add_door_marker("Room%02dExit" % [room_index + 1], exit_center, false) @@ -224,20 +312,26 @@ func _add_runtime_room_props() -> void: map_cover_root.name = "RuntimeRoomProps" map_cover_root.z_index = 6 map_root.add_child(map_cover_root) - var manifest := MapBrowserDemo.load_generated_prop_manifest() for room_index in range(map_room_rects.size()): - var candidates := MapBrowserDemo.get_generated_prop_candidates(manifest, room_index) - if candidates.is_empty(): + if room_index in FUNCTION_ROOM_INDICES: continue - candidates.shuffle() - var count: int = min(reward_rng.randi_range(MapBrowserDemo.RANDOM_PROP_MIN_PER_ROOM, MapBrowserDemo.RANDOM_PROP_MAX_PER_ROOM), candidates.size()) - var placed_rects: Array[Rect2] = [] - var placed := 0 - for candidate in candidates: - if placed >= count: - break - if _try_add_generated_room_prop(room_index, candidate, placed_rects): - placed += 1 + _add_random_props_for_room(room_index) + + +func _add_random_props_for_room(room_index: int) -> void: + var manifest := MapBrowserDemo.load_generated_prop_manifest() + var candidates := MapBrowserDemo.get_generated_prop_candidates(manifest, room_index) + if candidates.is_empty(): + return + candidates.shuffle() + var count: int = min(reward_rng.randi_range(MapBrowserDemo.RANDOM_PROP_MIN_PER_ROOM, MapBrowserDemo.RANDOM_PROP_MAX_PER_ROOM), candidates.size()) + var placed_rects: Array[Rect2] = [] + var placed := 0 + for candidate in candidates: + if placed >= count: + break + if _try_add_generated_room_prop(room_index, candidate, placed_rects): + placed += 1 func _room_prop_candidates(room_index: int) -> Array[Dictionary]: @@ -263,7 +357,7 @@ func _try_add_generated_room_prop(room_index: int, candidate: Dictionary, placed var source_width := maxf(1.0, float(source_size[0])) var source_height := maxf(1.0, float(source_size[1])) var texture_to_room_scale := Vector2(room_rect.size.x / source_width, room_rect.size.y / source_height) - texture_to_room_scale *= MapBrowserDemo.RANDOM_PROP_WORLD_SCALE + texture_to_room_scale *= MapBrowserDemo.RANDOM_PROP_WORLD_SCALE * MapBrowserDemo.generated_prop_scale_multiplier(candidate) var prop_size := Vector2(float(texture.get_width()) * texture_to_room_scale.x, float(texture.get_height()) * texture_to_room_scale.y) if not MapBrowserDemo.is_generated_prop_size_usable(prop_size, room_rect.size): return false @@ -284,10 +378,17 @@ func _add_generated_room_prop(room_index: int, candidate: Dictionary, texture: T body.name = "Room%02d%sProp" % [room_index + 1, String(candidate.get("name", "Generated")).replace(" ", "")] body.collision_layer = 1 body.collision_mask = 2 - body.add_to_group("projectile_blocker") + var walkable_prop := MapBrowserDemo.generated_prop_is_walkable(candidate) + if walkable_prop: + body.collision_layer = 0 + body.collision_mask = 0 + else: + body.add_to_group("projectile_blocker") body.position = world_position body.set_meta("room_index", room_index) + body.set_meta("prop_key", MapBrowserDemo.generated_prop_key(candidate)) body.set_meta("prop_size", Vector2(float(texture.get_width()) * texture_to_room_scale.x, float(texture.get_height()) * texture_to_room_scale.y)) + body.set_meta("walkable_prop", walkable_prop) map_cover_root.add_child(body) var sprite := Sprite2D.new() @@ -298,7 +399,7 @@ func _add_generated_room_prop(room_index: int, candidate: Dictionary, texture: T sprite.centered = true body.add_child(sprite) - var polygons := MapBrowserDemo.build_alpha_collision_polygons(texture, texture_to_room_scale) + var polygons := MapBrowserDemo.build_generated_prop_collision_polygons(candidate, texture, texture_to_room_scale) for index in range(polygons.size()): var shape := CollisionPolygon2D.new() shape.name = "AlphaCollision%02d" % [index + 1] diff --git a/tests/smoke_map_random_props.gd b/tests/smoke_map_random_props.gd index 4673501..9e30d1b 100644 --- a/tests/smoke_map_random_props.gd +++ b/tests/smoke_map_random_props.gd @@ -36,16 +36,25 @@ func _run() -> void: quit(1) return - var alpha_collision_count := 0 + var collidable_prop_count := 0 var room_rects: Array = map_runtime.get("map_room_rects") var walkable_rects: Array = map_runtime.get("map_walkable_rects") var props_by_room := {} for body in prop_bodies: - if body is StaticBody2D and not (body as StaticBody2D).is_in_group("projectile_blocker"): - push_error("Random prop is not a projectile blocker: %s" % body.name) - quit(1) - return - alpha_collision_count += body.find_children("AlphaCollision*", "CollisionPolygon2D", true, false).size() + var static_body := body as StaticBody2D + var walkable_prop := bool(static_body.get_meta("walkable_prop", false)) + var collision_count := body.find_children("AlphaCollision*", "CollisionPolygon2D", true, false).size() + if walkable_prop: + if static_body.is_in_group("projectile_blocker") or collision_count != 0: + push_error("Walkable ground prop unexpectedly blocks movement: %s" % body.name) + quit(1) + return + else: + if not static_body.is_in_group("projectile_blocker") or collision_count == 0: + push_error("Random cover prop is missing collision: %s" % body.name) + quit(1) + return + collidable_prop_count += 1 var sprite := body.get_node_or_null("Sprite") as Sprite2D if sprite == null or sprite.texture == null: push_error("Random prop is missing sprite texture: %s" % body.name) @@ -71,8 +80,79 @@ func _run() -> void: quit(1) return - if alpha_collision_count < prop_bodies.size(): - push_error("Random props did not create alpha collision polygons") + if collidable_prop_count == 0: + push_error("Runtime map did not create any collidable cover props") + quit(1) + return + + var manifest := MapBrowserDemo.load_generated_prop_manifest() + var room_four_candidates := MapBrowserDemo.get_generated_prop_candidates(manifest, 3) + var hut_candidate := _find_candidate(room_four_candidates, "Room04Prop01") + var ground_seal_candidate := _find_candidate(room_four_candidates, "Room04Prop35") + if hut_candidate.is_empty() or ground_seal_candidate.is_empty(): + push_error("Room 4 prop manifest is missing hut or ground seal assets") + quit(1) + return + if not is_equal_approx(MapBrowserDemo.generated_prop_scale_multiplier(hut_candidate), 1.20): + push_error("Room 4 hut scale multiplier is incorrect") + quit(1) + return + for prop_key in ["0:Room01Prop01", "0:Room01Prop03", "0:Room01Prop07", "1:Room02Prop13", "1:Room02Prop15"]: + if not is_equal_approx(float(MapBrowserDemo.GENERATED_PROP_SCALE_MULTIPLIERS.get(prop_key, 1.0)), 0.50): + push_error("Oversized prop was not reduced by 50%%: %s" % prop_key) + quit(1) + return + var hut_texture := load(String(hut_candidate.get("path", ""))) as Texture2D + if hut_texture == null or MapBrowserDemo.build_generated_prop_collision_polygons(hut_candidate, hut_texture, Vector2.ONE).size() != 1: + push_error("Room 4 hut did not create its reduced footprint collision") + quit(1) + return + var ground_seal_texture := load(String(ground_seal_candidate.get("path", ""))) as Texture2D + if not MapBrowserDemo.generated_prop_is_walkable(ground_seal_candidate) or not MapBrowserDemo.build_generated_prop_collision_polygons(ground_seal_candidate, ground_seal_texture, Vector2.ONE).is_empty(): + push_error("Room 4 ground seal should be fully walkable") + quit(1) + return + + var expected_bottom_ratios := [0.75, 0.76, 0.76, 0.76] + for room_index in range(expected_bottom_ratios.size()): + var room_rect := room_rects[room_index] as Rect2 + var expected_top := room_rect.position.y + room_rect.size.y * float(expected_bottom_ratios[room_index]) + var found_bottom_blocker := false + for wall_rect in MapBrowserDemo.get_room_wall_rects(room_index, room_rect): + if wall_rect.position.y <= expected_top + 0.5 and wall_rect.end.y >= room_rect.end.y - 0.5 and wall_rect.size.x >= room_rect.size.x * 0.70: + found_bottom_blocker = true + break + if not found_bottom_blocker: + push_error("Room %d bottom buildings are missing roof collision" % [room_index + 1]) + quit(1) + return + + var plaza_statue := world_root.find_child("Room04Circle01", true, false) as StaticBody2D + if plaza_statue == null or not plaza_statue.is_in_group("projectile_blocker"): + push_error("Central plaza statue collision is missing") + quit(1) + return + var statue_shape := plaza_statue.get_child(0) as CollisionShape2D if plaza_statue.get_child_count() > 0 else null + if statue_shape == null or not (statue_shape.shape is CircleShape2D): + push_error("Central plaza statue does not use a circular collision") + quit(1) + return + + var function_rect_a := room_rects[4] as Rect2 + var function_rect_b := room_rects[5] as Rect2 + var function_rect_c := room_rects[6] as Rect2 + if function_rect_a.position != function_rect_b.position or function_rect_b.position != function_rect_c.position or function_rect_a.size.distance_to(function_rect_b.size) > 2.0 or function_rect_b.size.distance_to(function_rect_c.size) > 2.0: + push_error("The three function rooms do not share one branch slot") + quit(1) + return + var branch_source := room_rects[3] as Rect2 + var chosen_room := map_runtime.function_room_choice_for_position(Vector2(branch_source.end.x, (walkable_rects[3] as Rect2).get_center().y)) + if chosen_room != 5 or not map_runtime.select_function_room(chosen_room): + push_error("Middle function-room door did not select the armory") + quit(1) + return + if world_root.find_child("MapRoom06", true, false) == null or world_root.find_child("MapRoom05", true, false) != null or world_root.find_child("MapRoom07", true, false) != null: + push_error("Function-room branch loaded more than the chosen room") quit(1) return @@ -84,3 +164,10 @@ func _run() -> void: return quit(0) + + +func _find_candidate(candidates: Array[Dictionary], candidate_name: String) -> Dictionary: + for candidate in candidates: + if String(candidate.get("name", "")) == candidate_name: + return candidate + return {} diff --git a/tests/smoke_run_flow.gd b/tests/smoke_run_flow.gd index a7192fc..ee66cbe 100644 --- a/tests/smoke_run_flow.gd +++ b/tests/smoke_run_flow.gd @@ -251,6 +251,18 @@ func _run() -> void: push_error("Second map encounter did not begin after first map reward") quit(1) return + if world._next_encounter_index(4) != 7 or world._next_encounter_index(5) != 7 or world._next_encounter_index(6) != 7: + push_error("Function-room route did not converge on the inner gate") + quit(1) + return + var visible_recovery_position: Vector2 = (world.player_character as Node2D).global_position + Vector2(8.0, 0.0) + world.gate_walk_target = visible_recovery_position + world.player_character.visible = false + world._finish_gate_walk() + if not world.player_character.visible or world.player_character.global_position != visible_recovery_position: + push_error("Door transition did not recover the player position and visibility") + quit(1) + return # The final chamber is an extra encounter slot backed by FINAL_BOSS_SCENES. world.encounter_index = world._encounter_count() - 1 diff --git a/tools/map_browser_demo.gd b/tools/map_browser_demo.gd index a2f4944..1c25c63 100644 --- a/tools/map_browser_demo.gd +++ b/tools/map_browser_demo.gd @@ -102,6 +102,24 @@ const RANDOM_PROP_MIN_PER_ROOM := 2 const RANDOM_PROP_MAX_PER_ROOM := 4 const RANDOM_PROP_PLACEMENT_ATTEMPTS := 56 const RANDOM_PROP_WORLD_SCALE := 0.70 +const GENERATED_PROP_SCALE_MULTIPLIERS := { + "0:Room01Prop01": 0.50, + "0:Room01Prop03": 0.50, + "0:Room01Prop07": 0.50, + "1:Room02Prop13": 0.50, + "1:Room02Prop15": 0.50, + "3:Room04Prop01": 1.20, + "3:Room04Prop02": 1.20 +} +const GENERATED_PROP_WALKABLE := { + "3:Room04Prop35": true, + "3:Room04Prop36": true, + "3:Room04Prop37": true +} +const GENERATED_PROP_FOOTPRINTS := { + "3:Room04Prop01": Rect2(0.08, 0.70, 0.84, 0.24), + "3:Room04Prop02": Rect2(0.08, 0.70, 0.84, 0.24) +} const RANDOM_PROP_MIN_WIDTH_RATIO := 0.045 const RANDOM_PROP_MIN_HEIGHT_RATIO := 0.050 const RANDOM_PROP_MIN_AREA_RATIO := 0.0040 @@ -114,10 +132,10 @@ const RANDOM_PROP_DOOR_LANE_WIDTH_RATIO := 0.22 const RANDOM_PROP_SAFE_RADIUS := 92.0 const WALKABLE_AREAS := [ - Rect2(0.06, 0.16, 0.88, 0.68), - Rect2(0.06, 0.16, 0.88, 0.68), - Rect2(0.06, 0.15, 0.88, 0.70), - Rect2(0.06, 0.16, 0.84, 0.70), + Rect2(0.06, 0.16, 0.88, 0.59), + Rect2(0.06, 0.16, 0.88, 0.60), + Rect2(0.06, 0.15, 0.88, 0.61), + Rect2(0.06, 0.16, 0.84, 0.60), Rect2(0.06, 0.16, 0.88, 0.68), Rect2(0.06, 0.16, 0.88, 0.68), Rect2(0.06, 0.16, 0.88, 0.68), @@ -132,7 +150,7 @@ const WALKABLE_AREAS := [ const ROOM_WALL_COLLISIONS := [ [ Rect2(0.00, 0.00, 1.00, 0.16), - Rect2(0.00, 0.84, 1.00, 0.16), + Rect2(0.00, 0.75, 1.00, 0.25), Rect2(0.00, 0.00, 0.055, 0.42), Rect2(0.00, 0.58, 0.055, 0.42), Rect2(0.94, 0.00, 0.06, 0.42), @@ -140,7 +158,7 @@ const ROOM_WALL_COLLISIONS := [ ], [ Rect2(0.00, 0.00, 1.00, 0.16), - Rect2(0.00, 0.84, 1.00, 0.16), + Rect2(0.00, 0.76, 1.00, 0.24), Rect2(0.00, 0.00, 0.055, 0.42), Rect2(0.00, 0.58, 0.055, 0.42), Rect2(0.94, 0.00, 0.06, 0.42), @@ -148,7 +166,7 @@ const ROOM_WALL_COLLISIONS := [ ], [ Rect2(0.00, 0.00, 1.00, 0.16), - Rect2(0.00, 0.84, 1.00, 0.16), + Rect2(0.00, 0.76, 1.00, 0.24), Rect2(0.00, 0.00, 0.055, 0.42), Rect2(0.00, 0.58, 0.055, 0.42), Rect2(0.94, 0.00, 0.06, 0.42), @@ -158,7 +176,7 @@ const ROOM_WALL_COLLISIONS := [ Rect2(0.00, 0.00, 0.08, 0.42), Rect2(0.00, 0.58, 0.08, 0.42), Rect2(0.08, 0.00, 0.76, 0.16), - Rect2(0.08, 0.84, 0.76, 0.16), + Rect2(0.08, 0.76, 0.76, 0.24), Rect2(0.88, 0.00, 0.06, 0.32), Rect2(0.88, 0.68, 0.06, 0.32) ], @@ -277,6 +295,12 @@ const ROOM_WALL_COLLISIONS := [ ] ] +const ROOM_CIRCLE_COLLISIONS := { + 3: [ + {"center": Vector2(0.455, 0.49), "radius": 0.105} + ] +} + const PROP_CANDIDATES := [] const LEGACY_PROP_CANDIDATES := [ @@ -598,6 +622,9 @@ func _add_collision_boxes(parent: Node) -> void: var wall_rects := get_room_wall_rects(index, room_rect) for wall_index in range(wall_rects.size()): _add_blocker(collision_root, "Room%02dWall%02d" % [index + 1, wall_index + 1], wall_rects[wall_index]) + var circle_collisions := get_room_circle_collisions(index, room_rect) + for circle_index in range(circle_collisions.size()): + _add_circle_blocker(collision_root, "Room%02dCircle%02d" % [index + 1, circle_index + 1], circle_collisions[circle_index]) func _add_blocker(parent: Node, blocker_name: String, rect: Rect2) -> void: if rect.size.x <= 0.0 or rect.size.y <= 0.0: @@ -629,6 +656,22 @@ func _add_blocker(parent: Node, blocker_name: String, rect: Rect2) -> void: ]) body.add_child(visual) +func _add_circle_blocker(parent: Node, blocker_name: String, data: Dictionary) -> void: + var radius := float(data.get("radius", 0.0)) + if radius <= 0.0: + return + var body := StaticBody2D.new() + body.name = blocker_name + body.collision_layer = 1 + body.collision_mask = 2 + body.position = data.get("center", Vector2.ZERO) as Vector2 + parent.add_child(body) + var shape := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = radius + shape.shape = circle + body.add_child(shape) + func _add_random_cover_props(parent: Node) -> void: var prop_root := Node2D.new() prop_root.name = "RandomCoverProps" @@ -668,7 +711,7 @@ func _try_add_random_cover_prop(parent: Node, room_index: int, candidate: Dictio var source_width := maxf(1.0, float(source_size[0])) var source_height := maxf(1.0, float(source_size[1])) var texture_to_room_scale := Vector2(room_rect.size.x / source_width, room_rect.size.y / source_height) - texture_to_room_scale *= RANDOM_PROP_WORLD_SCALE + texture_to_room_scale *= RANDOM_PROP_WORLD_SCALE * generated_prop_scale_multiplier(candidate) var prop_size := Vector2(float(texture.get_width()) * texture_to_room_scale.x, float(texture.get_height()) * texture_to_room_scale.y) if not is_generated_prop_size_usable(prop_size, room_rect.size): return false @@ -700,10 +743,17 @@ func _add_generated_cover_prop(parent: Node, candidate: Dictionary, texture: Tex body.name = "%sCover" % String(candidate.get("name", "GeneratedProp")) body.collision_layer = 1 body.collision_mask = 2 - body.add_to_group("projectile_blocker") + var walkable_prop := generated_prop_is_walkable(candidate) + if walkable_prop: + body.collision_layer = 0 + body.collision_mask = 0 + else: + body.add_to_group("projectile_blocker") body.position = world_position body.set_meta("room_index", int(candidate.get("room", -1))) + body.set_meta("prop_key", generated_prop_key(candidate)) body.set_meta("prop_size", Vector2(float(texture.get_width()) * texture_to_room_scale.x, float(texture.get_height()) * texture_to_room_scale.y)) + body.set_meta("walkable_prop", walkable_prop) parent.add_child(body) var sprite := Sprite2D.new() @@ -714,7 +764,7 @@ func _add_generated_cover_prop(parent: Node, candidate: Dictionary, texture: Tex sprite.z_index = 1 body.add_child(sprite) - var polygons := build_alpha_collision_polygons(texture, texture_to_room_scale) + var polygons := build_generated_prop_collision_polygons(candidate, texture, texture_to_room_scale) for index in range(polygons.size()): var collision := CollisionPolygon2D.new() collision.name = "AlphaCollision%02d" % [index + 1] @@ -805,6 +855,44 @@ static func get_room_wall_rects(room_index: int, room_rect: Rect2) -> Array[Rect )) return result +static func get_room_circle_collisions(room_index: int, room_rect: Rect2) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var definitions := ROOM_CIRCLE_COLLISIONS.get(room_index, []) as Array + for raw_definition in definitions: + var definition := raw_definition as Dictionary + var center_ratio := definition.get("center", Vector2.ZERO) as Vector2 + result.append({ + "center": room_rect.position + Vector2(room_rect.size.x * center_ratio.x, room_rect.size.y * center_ratio.y), + "radius": minf(room_rect.size.x, room_rect.size.y) * float(definition.get("radius", 0.0)) + }) + return result + +static func generated_prop_key(candidate: Dictionary) -> String: + return "%d:%s" % [int(candidate.get("room", -1)), String(candidate.get("name", ""))] + +static func generated_prop_scale_multiplier(candidate: Dictionary) -> float: + return float(GENERATED_PROP_SCALE_MULTIPLIERS.get(generated_prop_key(candidate), 1.0)) + +static func generated_prop_is_walkable(candidate: Dictionary) -> bool: + return bool(GENERATED_PROP_WALKABLE.get(generated_prop_key(candidate), false)) + +static func build_generated_prop_collision_polygons(candidate: Dictionary, texture: Texture2D, texture_to_room_scale: Vector2) -> Array[PackedVector2Array]: + if generated_prop_is_walkable(candidate): + return [] + var footprint = GENERATED_PROP_FOOTPRINTS.get(generated_prop_key(candidate), null) + if footprint is Rect2 and texture != null: + var ratio := footprint as Rect2 + var texture_size := Vector2(float(texture.get_width()), float(texture.get_height())) + var top_left := (texture_size * ratio.position - texture_size * 0.5) * texture_to_room_scale + var bottom_right := (texture_size * ratio.end - texture_size * 0.5) * texture_to_room_scale + return [PackedVector2Array([ + top_left, + Vector2(bottom_right.x, top_left.y), + bottom_right, + Vector2(top_left.x, bottom_right.y) + ])] + return build_alpha_collision_polygons(texture, texture_to_room_scale) + static func load_generated_prop_manifest() -> Dictionary: if not FileAccess.file_exists(GENERATED_PROP_MANIFEST_PATH): return {} diff --git a/world.gd b/world.gd index 9bd514d..ac4c8d7 100644 --- a/world.gd +++ b/world.gd @@ -87,6 +87,8 @@ var last_attack_feedback_msec: int = 0 var reward_rng := RandomNumberGenerator.new() var map_runtime: Node = null var active_map_room_index: int = -1 +var function_room_choice_pending: bool = false +var selected_function_room_index: int = -1 var gate_walk_active: bool = false var gate_walk_target: Vector2 = Vector2.ZERO var gate_walk_callback: Callable = Callable() @@ -169,6 +171,7 @@ func _ready() -> void: func _process(_delta: float) -> void: _process_gate_walk() + _process_function_room_choice() _apply_cheat_safety() _sync_audio_hint_state() _update_map_camera() @@ -213,6 +216,9 @@ func _process_gate_walk() -> void: func _finish_gate_walk() -> void: var callback := gate_walk_callback + if player_character is Node2D and is_instance_valid(player_character): + (player_character as Node2D).global_position = gate_walk_target + (player_character as CanvasItem).visible = true _clear_player_auto_walk() if callback.is_valid(): callback.call() @@ -269,6 +275,20 @@ func _clear_player_auto_walk() -> void: if player_character != null and is_instance_valid(player_character) and player_character.has_method("set_auto_walk_direction"): player_character.set_auto_walk_direction(Vector2.ZERO) + +func _process_function_room_choice() -> void: + if not function_room_choice_pending or gate_walk_active: + return + if map_runtime == null or player_character == null or not is_instance_valid(player_character) or not (player_character is Node2D): + return + var room_index := int(map_runtime.function_room_choice_for_position((player_character as Node2D).global_position)) + if room_index < 0 or not map_runtime.select_function_room(room_index): + return + function_room_choice_pending = false + selected_function_room_index = room_index + encounter_index = room_index - 1 + _start_next_encounter() + func _prepare_map_runtime() -> void: _hide_legacy_arena() map_runtime = MapRuntime.new() @@ -472,6 +492,10 @@ func _on_character_selected(character_id: StringName) -> void: if inventory_panel.has_method("reset_run"): inventory_panel.reset_run() active_encounter_prep.clear() + function_room_choice_pending = false + selected_function_room_index = -1 + if map_runtime != null and map_runtime.has_method("reset_function_room_selection"): + map_runtime.reset_function_room_selection() if player_character != null and is_instance_valid(player_character): player_character.queue_free() if current_encounter != null and is_instance_valid(current_encounter): @@ -521,7 +545,7 @@ func _start_next_encounter() -> void: return_pause_after_audio_panel = false return_pause_after_settings_panel = false active_encounter_prep = RunDirector.consume_pending_encounter_prep() - encounter_index += 1 + encounter_index = _next_encounter_index(encounter_index) if encounter_index >= _encounter_count(): active_encounter_prep.clear() _complete_run_victory() @@ -534,6 +558,12 @@ func _start_next_encounter() -> void: return _begin_current_encounter() + +func _next_encounter_index(current_index: int) -> int: + if current_index in [4, 5, 6]: + return 7 + return current_index + 1 + func _begin_current_encounter() -> void: if LineageDirector != null: LineageDirector.record_checkpoint(encounter_index) @@ -636,7 +666,10 @@ func _on_encounter_defeated() -> void: if defeated_final_encounter: _complete_run_victory() else: - var next_encounter_index := encounter_index + 1 + var next_encounter_index := _next_encounter_index(encounter_index) + if next_encounter_index in [4, 5, 6] and selected_function_room_index < 0: + _offer_stage_clear_reward() + return _walk_player_to_next_room_entry(next_encounter_index, func() -> void: if not is_instance_valid(self) or player_character == null or not is_instance_valid(player_character) or float(player_character.hp) <= 0.0: return @@ -819,6 +852,15 @@ func _on_stage_reward_chosen(choice: Dictionary) -> void: if stage_reward_panel != null and stage_reward_panel.has_method("close") and stage_reward_panel.visible: stage_reward_panel.close() _apply_stage_reward_choice(choice) + var next_encounter_index := _next_encounter_index(encounter_index) + if next_encounter_index in [4, 5, 6] and selected_function_room_index < 0: + function_room_choice_pending = true + _refresh_battle_status( + _ui_text("Three-Way Gate", "三岔门", "三岔門"), + _ui_text("Church / Armory / Shop", "教堂 / 军械库 / 商店", "教堂 / 軍械庫 / 商店"), + _localized_detail_text("") + ) + return _start_next_encounter() func _on_stage_reward_pause_requested() -> void: @@ -978,6 +1020,8 @@ func _on_run_event_choice_made(choice_id: String) -> void: if EndingDirector != null and applied and _is_church_baptism_choice(choice_id): EndingDirector.record_church_baptism() active_run_event_kind = "" + if player_character is CanvasItem and is_instance_valid(player_character): + (player_character as CanvasItem).visible = true if choice_id == "shop_relic" and applied: _offer_accessory(_ui_text("Purchased Relic", "购买饰品", "購買飾品"), "shop") else: