extends Node # PortalModeManager - Handles "Tekton Doors" mode logic # Manages room partitioning, portal connections, and mode-specific timers. var main: Node var gridmap: Node # Room layout config const ROOM_COUNT = 4 const GRID_SIZE = 14 const ROOM_DIM = 7 # State var connections = {} # room_id -> {door_id -> {target_room, target_door}} var doors = [] # List of PortalDoor nodes var swap_timer: Timer var tile_refresh_timer: Timer var finish_spawned: bool = false var arena_setup_done: bool = false var player_portal_cooldowns: Dictionary = {} var hud_layer: CanvasLayer var mission_label: Label var _has_notified_mission_complete: bool = false func initialize(p_main: Node, p_gridmap: Node): main = p_main gridmap = p_gridmap if gridmap: # Ensure walls (4) are strictly treated as non-walkable for all internal checks # Use explicit type to avoid Array vs Array[int] mismatch error var non_walkable: Array[int] = [4] gridmap.non_walkable_items = non_walkable # Create Stands container if it doesn't exist print("[PortalModeManager] Initialized") # Connection Swap Timer (15s) swap_timer = Timer.new() swap_timer.name = "PortalSwapTimer" # Initial wait time; gets reset when started based on game mode settings swap_timer.wait_time = 15.0 swap_timer.timeout.connect(_on_swap_timer_timeout) add_child(swap_timer) # Tile Refresh Timer (25s) tile_refresh_timer = Timer.new() tile_refresh_timer.name = "TileRefreshTimer" # Initial wait time; gets reset when started based on game mode settings tile_refresh_timer.wait_time = 25.0 tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout) add_child(tile_refresh_timer) # Connect to mission tracking var gcm = main.get_node_or_null("GoalsCycleManager") if gcm: gcm.global_timer_updated.connect(_on_global_timer_updated) gcm.goal_count_updated.connect(_on_goal_count_updated) _setup_hud() func _on_global_timer_updated(time_remaining: float): if not multiplayer.is_server(): return # Last 30 seconds: Reveal Finish Room if time_remaining <= 30.0 and not finish_spawned: _spawn_finish_room() func start_game_mode(): if not multiplayer.is_server(): return if arena_setup_done and not doors.is_empty(): print("[PortalModeManager] Arena already setup, starting timers and refresh only.") else: print("[PortalModeManager] Starting Portal Game Mode with full setup...") setup_arena_locally() _randomize_connections() # Configure dynamic timings from LobbyManager before starting swap_timer.wait_time = float(LobbyManager.doors_swap_time) tile_refresh_timer.wait_time = float(LobbyManager.doors_refresh_time) # Start Timers if swap_timer.is_stopped(): swap_timer.start() if tile_refresh_timer.is_stopped(): tile_refresh_timer.start() # Initial Tile Spawn _refresh_tiles() # Show HUD _activate_hud() func _activate_hud(): if hud_layer: hud_layer.visible = true _update_hud_visuals() func activate_client_side(): """Called on clients to show HUD and prepare local state.""" print("[PortalModeManager] Activating client-side HUD") _activate_hud() # Initial update to catch any missed goal counts _update_hud_visuals() func setup_arena_locally(): """Sets up GridMap size and walls. Called on host and clients.""" if arena_setup_done: print("[PortalModeManager] Arena already setup locally, skipping.") return print("[PortalModeManager] Setting up arena locally...") _setup_arena_size() _setup_room_partitions() _spawn_portal_doors() # PRE-FILL TILES: Ensure all floor tiles have items before the countdown starts if multiplayer.is_server(): _refresh_tiles() arena_setup_done = true func _setup_arena_size(): if not gridmap: return gridmap.columns = GRID_SIZE gridmap.rows = GRID_SIZE gridmap.clear() # Explicitly clear Floor 1 to prevent legacy tiles from previous rounds if gridmap.has_method("clear_grid"): gridmap.clear_grid(1) # Fill Floor 0 with standard floor (Item ID 0) for x in range(GRID_SIZE): for z in range(GRID_SIZE): gridmap.set_cell_item(Vector3i(x, 0, z), 0) func get_spawn_points() -> Array[Vector2i]: # One point per quadrant return [ Vector2i(3, 3), # Room 0 Vector2i(10, 3), # Room 1 Vector2i(3, 10), # Room 2 Vector2i(10, 10) # Room 3 ] func _setup_hud(): hud_layer = CanvasLayer.new() hud_layer.layer = 5 hud_layer.visible = false add_child(hud_layer) var bottom_container = CenterContainer.new() bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN bottom_container.offset_bottom = -50 hud_layer.add_child(bottom_container) mission_label = Label.new() mission_label.text = "GOALS (0/8)" mission_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf") if custom_font: mission_label.add_theme_font_override("font", custom_font) mission_label.add_theme_font_size_override("font_size", 28) mission_label.add_theme_color_override("font_outline_color", Color.BLACK) mission_label.add_theme_constant_override("outline_size", 8) bottom_container.add_child(mission_label) # Initial update _update_hud_visuals() func _update_hud_visuals(): if not mission_label: return var my_id = multiplayer.get_unique_id() var gcm = main.get_node_or_null("GoalsCycleManager") var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0 mission_label.text = "GOALS (%d/%d)" % [completed_count, LobbyManager.doors_required_goals] if completed_count >= LobbyManager.doors_required_goals: mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!" mission_label.add_theme_color_override("font_color", Color.GOLD) if not _has_notified_mission_complete: _has_notified_mission_complete = true var player_node = main.get_node_or_null(str(my_id)) if player_node: NotificationManager.send_message(player_node, "ALL GOALS COMPLETE!", NotificationManager.MessageType.GOAL) else: mission_label.add_theme_color_override("font_color", Color.WHITE) _has_notified_mission_complete = false func is_mission_complete(peer_id: int) -> bool: var gcm = main.get_node_or_null("GoalsCycleManager") if not gcm: return false return gcm.player_goal_counts.get(peer_id, 0) >= LobbyManager.doors_required_goals func check_win_condition(player_id: int, pos: Vector2i) -> bool: # 1. Check if on finish tile var tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if tile != 3: return false # 2. Check missions return is_mission_complete(player_id) func _setup_room_partitions(): for i in range(GRID_SIZE): # Vertical wall (middle columns) gridmap.set_cell_item(Vector3i(6, 0, i), 4) # Wall item gridmap.set_cell_item(Vector3i(7, 0, i), 4) # Horizontal wall (middle rows) gridmap.set_cell_item(Vector3i(i, 0, 6), 4) gridmap.set_cell_item(Vector3i(i, 0, 7), 4) var _pending_sync_data = null func _spawn_portal_doors(): # 1. Use synced configs if they exist (passed via main.rpc("sync_portal_configs")) var door_configs = get_meta("door_configs") if has_meta("door_configs") else [] # 2. If no synced configs (e.g. Server start), generate base + extras if door_configs.is_empty(): if not multiplayer.is_server(): print("[PortalModeManager] Client waiting for portal configs sync...") return door_configs = [ # BASE DOORS (2 per room) {"room": 0, "pos": Vector2i(6, 2), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East {"room": 0, "pos": Vector2i(2, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South {"room": 1, "pos": Vector2i(7, 2), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West {"room": 1, "pos": Vector2i(11, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South {"room": 2, "pos": Vector2i(2, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North {"room": 2, "pos": Vector2i(6, 11), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East {"room": 3, "pos": Vector2i(11, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North {"room": 3, "pos": Vector2i(7, 11), "rot": PI / 2, "offset": Vector2i(1, 0)} # West ] # Server adds extras var extra_options = [ {"room": 0, "pos": Vector2i(6, 5), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,2) {"room": 1, "pos": Vector2i(7, 5), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West (Gap from 7,2) {"room": 2, "pos": Vector2i(6, 8), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,11) {"room": 3, "pos": Vector2i(7, 8), "rot": PI / 2, "offset": Vector2i(1, 0)} # West (Gap from 7,11) ] extra_options.shuffle() door_configs.append(extra_options[0]) door_configs.append(extra_options[1]) # Broadcast to clients main.rpc("sync_portal_configs", door_configs) # 3. Spawn the doors if not doors.is_empty(): return # Guard against double spawn print("[PortalModeManager] Spawning %d doors. Peer: %d" % [door_configs.size(), multiplayer.get_unique_id()]) var portal_scene = load("res://scenes/portal_door.tscn") var stands_container = main.get_node_or_null("Stands") if not stands_container: stands_container = Node3D.new() stands_container.name = "Stands" main.add_child(stands_container) for i in range(door_configs.size()): var cfg = door_configs[i] if not portal_scene: print("[PortalModeManager] Error: Failed to load portal_door.tscn") break var door = portal_scene.instantiate() door.name = "Portal_%d" % i door.room_id = cfg["room"] door.door_id = i door.set_meta("spawn_offset", cfg["offset"]) # Store offset for teleport # Position var world_pos = gridmap.map_to_local(Vector3i(cfg["pos"].x, 0, cfg["pos"].y)) door.transform.origin = world_pos door.rotation.y = cfg["rot"] stands_container.add_child(door, true) doors.append(door) # Server-only interaction logic if multiplayer.is_server(): door.player_entered_portal.connect(handle_portal_interaction) gridmap.set_cell_item(Vector3i(cfg["pos"].x, 0, cfg["pos"].y), 0) # Normal floor print("[PortalModeManager] Finished spawning %d doors" % doors.size()) # Apply pending sync if it arrived early if _pending_sync_data: print("[PortalModeManager] Applying pending sync data...") sync_portal_data(_pending_sync_data) _pending_sync_data = null const PORTAL_COLORS = [ Color(0, 1, 1), # Cyan Color(1, 0, 1), # Magenta Color(1, 0, 0), # Red Color(0, 1, 0), # Green Color(1, 0.5, 0) # Orange ] func _randomize_connections(): if not multiplayer.is_server(): return print("[PortalModeManager] Swapping portal connections...") connections.clear() var door_indices = [] for i in range(doors.size()): door_indices.append(i) # Shuffle and Validate: ensure no pairs are in the same room var valid_pairing = false var attempts = 0 while not valid_pairing and attempts < 100: attempts += 1 door_indices.shuffle() valid_pairing = true for i in range(0, door_indices.size(), 2): var a = door_indices[i] var b = door_indices[i + 1] if doors[a].room_id == doors[b].room_id: valid_pairing = false break # Prepare sync data var sync_data = [] # [[door_a_id, door_b_id, color], ...] # Pair them up and assign colors for i in range(0, door_indices.size(), 2): var a = door_indices[i] var b = door_indices[i + 1] connections[a] = b connections[b] = a var color = PORTAL_COLORS[int(i / 2.0) % PORTAL_COLORS.size()] sync_data.append([a, b, color]) doors[a].target_door_id = b doors[a].portal_color = color doors[b].target_door_id = a doors[b].portal_color = color # Sync to all clients rpc("sync_portal_data", sync_data) main.rpc("display_message", "PORTALS SWITCHED!") func sync_to_client(peer_id: int): """Syncs current portal connections to a specific client.""" var sync_data = [] # connections is id -> id # We need to rebuild the pair-based data for the RPC var handled = [] for a_id in connections: if a_id in handled: continue var b_id = connections[a_id] var color = doors[a_id].portal_color sync_data.append([a_id, b_id, color]) handled.append(a_id) handled.append(b_id) rpc_id(peer_id, "sync_portal_data", sync_data) @rpc("authority", "call_local", "reliable") func sync_portal_data(data: Array): """Syncs portal connections and colors to all clients.""" print("[PortalModeManager] Received portal sync data. Peed ID: ", multiplayer.get_unique_id()) # If doors array is empty on client, try to repopulate from Stands group if doors.is_empty(): var stands = get_tree().get_nodes_in_group("PortalDoors") # Sort by name to ensure consistent indexing stands.sort_custom(func(a, b): return a.name < b.name) doors = stands # If still empty, defer sync until doors are spawned locally if doors.is_empty(): print("[PortalModeManager] Doors not yet ready, deferring sync data...") _pending_sync_data = data return connections.clear() for pair in data: var a_id = pair[0] var b_id = pair[1] var color = pair[2] connections[a_id] = b_id connections[b_id] = a_id if a_id < doors.size() and b_id < doors.size(): if is_instance_valid(doors[a_id]): doors[a_id].target_door_id = b_id doors[a_id].portal_color = color if is_instance_valid(doors[b_id]): doors[b_id].target_door_id = a_id doors[b_id].portal_color = color else: print("[PortalModeManager] Warning: Door index %d or %d out of range during sync" % [a_id, b_id]) func _on_global_goal_count_updated(_peer_id: int, _count: int): # Mission requirement removed in favor of time-based finish reveal pass func _on_goal_count_updated(peer_id: int, _count: int): # Update HUD if relevant (always check if it's the local player whose count changed) if peer_id == multiplayer.get_unique_id(): _update_hud_visuals() func _spawn_finish_room(): print("[PortalModeManager] Time is running out! Revealing Finish Room...") finish_spawned = true # Choose a random room quadrant index (0 to 3) var room_idx = randi() % 4 # Determine center for the selected room quadrant (7x7 rooms) var x_center = 3 if (room_idx == 0 or room_idx == 2) else 10 var z_center = 3 if (room_idx == 0 or room_idx == 1) else 10 # Determine 3x3 bounds around the center var x_start = x_center - 1 var x_end = x_center + 2 # exclusive for range() var z_start = z_center - 1 var z_end = z_center + 2 # exclusive for range() print("[PortalModeManager] Converting 3x3 area in Room %d (X:%d-%d, Z:%d-%d) to Finish Tiles" % [room_idx, x_start, x_end-1, z_start, z_end-1]) # Iterate through the 3x3 area for x in range(x_start, x_end): for z in range(z_start, z_end): # Only convert walkable floor tiles (Item ID 0) on Floor 0 var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z)) if floor_0_item == 0: # Change Floor 0 tile to Finish Tile (ID 3) main.rpc("sync_grid_item", x, 0, z, 3) # Clear any item on Floor 1 above this tile main.rpc("sync_grid_item", x, 1, z, -1) # Visual update for server if gridmap.has_method("update_grid_data"): gridmap.update_grid_data() main.rpc("display_message", "[ALARM] THE FINISH ROOM HAS APPEARED!") main.rpc("broadcast_message", "SYSTEM", "A 3x3 Finish Zone has appeared in Room %d!" % room_idx, 4) # 4 = MessageType.WARNING func _get_room_index(pos: Vector2i) -> int: if pos.x < 7 and pos.y < 7: return 0 if pos.x >= 7 and pos.y < 7: return 1 if pos.x < 7 and pos.y >= 7: return 2 return 3 func _on_swap_timer_timeout(): _randomize_connections() func _on_tile_refresh_timer_timeout(): _refresh_tiles() main.rpc("display_message", "TILES REPLENISHED!") func _refresh_tiles(): # GridMap Floor 0 has the walls (ID 4) and floors (ID 0) # GridMap Floor 1 should have the items (Heart, Star, etc) # Cache door positions to avoid spawning under them var door_positions = [] for door in doors: if is_instance_valid(door): var local_pos = gridmap.local_to_map(gridmap.to_local(door.global_position)) door_positions.append(Vector2i(local_pos.x, local_pos.z)) for x in range(GRID_SIZE): for z in range(GRID_SIZE): # 1. Check if Floor 0 is a wall or void var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z)) if floor_0_item in [4, -1]: continue # 1.5. Prevent spawning directly under portal doors if door_positions.has(Vector2i(x, z)): continue # 2. Check if Floor 1 is already occupied if gridmap.get_cell_item(Vector3i(x, 1, z)) != -1: continue # 3. Spawn a tile (60% chance per valid floor cell) if randf() < 0.6: var weights = ScarcityModel.get_tile_weights() var tile_id = _pick_weighted_tile(weights) # Update GridMap Floor 1 via RPC for sync main.rpc("sync_grid_item", x, 1, z, tile_id) func _pick_weighted_tile(weights: Dictionary) -> int: var total_weight = 0 for w in weights.values(): total_weight += w var r = randi() % total_weight var cumulative = 0 for tile in weights: cumulative += weights[tile] if r < cumulative: return tile return 7 # Default Heart func handle_portal_interaction(player, door): if not multiplayer.is_server(): return var current_time = Time.get_ticks_msec() if player_portal_cooldowns.has(player.name): # Reduce cooldown to 200ms (more responsive than 1s, but enough to avoid jitter) if current_time - player_portal_cooldowns[player.name] < 200: return player_portal_cooldowns[player.name] = current_time var source_id = door.door_id if not connections.has(source_id): return var target_id = connections[source_id] var target_door = doors[target_id] # Use stored offset to avoid infinite loop (spawn inside the target room) var offset = target_door.get_meta("spawn_offset") if target_door.has_meta("spawn_offset") else Vector2i(0, 0) var target_world = target_door.global_position var target_grid_3d = gridmap.local_to_map(target_world) var target_grid = Vector2i(target_grid_3d.x, target_grid_3d.z) + offset # Check for overlaps at the target_grid var final_target = target_grid var all_players = get_tree().get_nodes_in_group("Players") var is_occupied = true var search_radius = 0 var max_search_radius = 2 while is_occupied and search_radius <= max_search_radius: is_occupied = false for p in all_players: if p != player and p.current_position == final_target: is_occupied = true break if is_occupied: # Try to find an adjacent cell search_radius += 1 var found_empty = false # Check immediate neighbors first var offsets = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1), Vector2i(1, 1), Vector2i(-1, 1), Vector2i(1, -1), Vector2i(-1, -1)] for offset_vec in offsets: var test_pos = final_target + offset_vec # Check if it's strictly a floor tile (ID 0) on Floor 0, not a wall if gridmap.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) == 0: # Verify no player is on this test_pos var test_occupied = false for p in all_players: if p != player and p.current_position == test_pos: test_occupied = true break if not test_occupied: final_target = test_pos found_empty = true break if found_empty: is_occupied = false print("[Portal] Teleporting %s to Room %d, Pos %s (via Door %d)" % [player.name, target_door.room_id, final_target, target_id]) # Snap player if player.has_method("set_spawn_position"): player.rpc("set_spawn_position", final_target)