extends Node class_name MektonBullsManager class Blueprint3x3 extends RefCounted: var anchor: Vector2i var color: int var cells: Array[Vector2i] = [] var progress: int = 0 signal phase_changed(phase_index: int) signal player_eliminated(player_id: int) # Nodes var main_scene: Node var gridmap: Node # Phase State var current_phase: int = 1 var arena_size: Vector2i = Vector2i(20, 20) var round_duration: float = 120.0 var phase_interval: float = 30.0 var round_timer: float = 120.0 var phase_timer: float = 30.0 signal time_remaining_changed(remaining: float) var bull_node: Node3D = null const BULL_SCENE = preload("res://scenes/npcs/mekton_bull.tscn") enum CellState { SAFE, WATER, BLOCKED } var arena_cells: Dictionary = {} var is_active: bool = false var flood_cooldown: float = 0.0 var player_blueprints: Dictionary = {} # { player_id: Blueprint3x3 } var player_powers: Dictionary = {} # { player_id: { "FREEZE": 0, "KNOCK": 0 } } var player_cooldowns: Dictionary = {} # { player_id: float } enum PowerType { FREEZE, KNOCK } # Placement Tracking var player_placement: Dictionary = {} # { pid: placement_rank } 1=first out var elimination_order: Array = [] # List of pids var candy_tick_timer: float = 0.0 const GOAL_COLORS = [7, 8, 9, 10] const TILE_WALKABLE: int = 0 const TILE_WATER: int = 24 # Water tile const TILE_OBSTACLE: int = 4 # Wall/obstacle func initialize(main: Node, grid: Node) -> void: main_scene = main gridmap = grid func start_game_mode() -> void: print("[MektonBulls] Starting Mekton Bulls game mode...") round_duration = float(LobbyManager.mekton_bulls_round_duration) phase_interval = float(LobbyManager.mekton_bulls_phase_interval) round_timer = round_duration phase_timer = phase_interval is_active = true _setup_arena() if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): _spawn_bull() _assign_initial_blueprints() func _ready(): player_eliminated.connect(_on_player_eliminated) func _setup_arena() -> void: if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: push_error("[MektonBulls] No EnhancedGridMap found!") return print("[MektonBulls] Setting up Phase 1 Arena...") if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): rpc("sync_arena_setup") _apply_arena_setup() @rpc("authority", "call_remote", "reliable") func sync_arena_setup() -> void: _apply_arena_setup() func _apply_arena_setup() -> void: if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: return current_phase = 1 arena_size = arena_size_for_phase(current_phase) gridmap.set("columns", 20) gridmap.set("rows", 20) gridmap.clear() # Initial build 20x20 for x in range(20): for z in range(20): var pos = Vector2i(x, z) if _is_boundary(pos): # Perimeter gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) arena_cells[pos] = CellState.SAFE else: # Walkable floor gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) arena_cells[pos] = CellState.SAFE gridmap.set_cell_item(Vector3i(x, 2, z), -1) gridmap.diagonal_movement = true gridmap.update_grid_data() gridmap.initialize_astar() _reposition_npc() _validate_blueprint_after_shrink() func arena_size_for_phase(phase: int) -> Vector2i: match phase: 1: return Vector2i(20, 20) 2: return Vector2i(19, 19) 3: return Vector2i(18, 18) 4: return Vector2i(17, 17) _: return Vector2i(17, 17) # Final phase func _is_boundary(pos: Vector2i) -> bool: var bounds = arena_size_for_phase(current_phase) # Grid shrinks symmetrically towards (0,0) by bounds. return pos.x == 0 or pos.x == bounds.x - 1 or pos.y == 0 or pos.y == bounds.y - 1 func _shrink_arena() -> void: if current_phase >= 4: return current_phase += 1 var new_bounds = arena_size_for_phase(current_phase) var old_bounds = arena_size_for_phase(current_phase - 1) print("[MektonBulls] Shrinking arena to Phase %d (%dx%d)" % [current_phase, new_bounds.x, new_bounds.y]) # Apply locally first _apply_ring_shrink(old_bounds, new_bounds) if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): rpc("sync_shrink_arena", current_phase) emit_signal("phase_changed", current_phase) @rpc("authority", "call_remote", "reliable") func sync_shrink_arena(new_phase: int) -> void: var old_bounds = arena_size_for_phase(current_phase) current_phase = new_phase var new_bounds = arena_size_for_phase(current_phase) _apply_ring_shrink(old_bounds, new_bounds) emit_signal("phase_changed", current_phase) func _apply_ring_shrink(old_bounds: Vector2i, new_bounds: Vector2i) -> void: for x in range(20): for z in range(20): var pos = Vector2i(x, z) if x >= new_bounds.x or z >= new_bounds.y: # It is now outside the new bounds -> WATER gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER) gridmap.set_cell_item(Vector3i(x, 1, z), -1) arena_cells[pos] = CellState.WATER elif _is_boundary(pos): # New boundary -> No wall, just the edge of SAFE gridmap.set_cell_item(Vector3i(x, 1, z), -1) arena_cells[pos] = CellState.SAFE gridmap.update_grid_data() gridmap.initialize_astar() _reposition_npc() _validate_blueprint_after_shrink() func _reposition_npc() -> void: # Reposition Mekton Bull to the center of the current bounds if not bull_node: return var bounds = arena_size_for_phase(current_phase) # Center in world units var cx = (bounds.x / 2.0) * gridmap.cell_size.x var cz = (bounds.y / 2.0) * gridmap.cell_size.z bull_node.position = Vector3(cx, 0, cz) func get_spawn_points(player_count: int) -> Array[Vector2i]: var spawns: Array[Vector2i] = [] var bounds = arena_size_for_phase(current_phase) # 4 players: inner corners spawns.append(Vector2i(1, 1)) if bounds.x > 2: spawns.append(Vector2i(bounds.x - 2, 1)) if bounds.y > 2: spawns.append(Vector2i(1, bounds.y - 2)) if bounds.x > 2 and bounds.y > 2: spawns.append(Vector2i(bounds.x - 2, bounds.y - 2)) if player_count > 4: if bounds.x > 4: spawns.append(Vector2i(bounds.x / 2, 1)) if bounds.x > 4 and bounds.y > 2: spawns.append(Vector2i(bounds.x / 2, bounds.y - 2)) if player_count > 6: if bounds.y > 4: spawns.append(Vector2i(1, bounds.y / 2)) if bounds.x > 2 and bounds.y > 4: spawns.append(Vector2i(bounds.x - 2, bounds.y / 2)) return spawns.slice(0, player_count) func _spawn_bull() -> void: if bull_node == null: bull_node = BULL_SCENE.instantiate() bull_node.name = "MektonBull" # Use multiplayer spawner if appropriate, else just add child main_scene.add_child(bull_node) var bounds = arena_size_for_phase(current_phase) var cx = (bounds.x / 2.0) * gridmap.cell_size.x var cz = (bounds.y / 2.0) * gridmap.cell_size.z var start_pos = Vector3(cx, 0, cz) if bull_node.has_method("initialize"): bull_node.initialize(self, gridmap, start_pos) func _process(delta: float) -> void: if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return if not is_active: return if multiplayer.is_server(): round_timer -= delta time_remaining_changed.emit(round_timer) rpc("sync_time_remaining", round_timer) if round_timer <= 0: _on_round_time_expired() phase_timer -= delta if phase_timer <= 0: phase_timer = phase_interval _shrink_arena() if flood_cooldown > 0: flood_cooldown -= delta if multiplayer.is_server(): if flood_cooldown <= 0 and bull_node: var bull_pos_3d = gridmap.local_to_map(bull_node.position) var bull_pos_2d = Vector2i(bull_pos_3d.x, bull_pos_3d.z) if _is_boundary(bull_pos_2d): _trigger_water_flood() candy_tick_timer -= delta if candy_tick_timer <= 0: candy_tick_timer = 0.1 _process_candy_tick() for pid in player_cooldowns.keys(): if player_cooldowns[pid] > 0: player_cooldowns[pid] -= delta @rpc("authority", "call_local", "reliable") func sync_time_remaining(time: float) -> void: round_timer = time time_remaining_changed.emit(round_timer) func _on_round_time_expired() -> void: is_active = false var players = get_tree().get_nodes_in_group("Players") for p in players: if p.is_in_group("Players"): var pid = p.name.to_int() if not elimination_order.has(pid): elimination_order.append(pid) player_placement[pid] = elimination_order.size() _end_round() func _trigger_water_flood() -> void: flood_cooldown = 3.0 print("[MektonBulls] Bull is on boundary! Flooding outer ring!") # Network sync rpc("sync_water_flood") _apply_water_flood() @rpc("authority", "call_local", "reliable") func sync_water_flood() -> void: _apply_water_flood() func _apply_water_flood() -> void: # 1. Eliminate any player whose cell is currently _is_boundary # (which is the outermost ring of the current arena_size) var players = get_tree().get_nodes_in_group("Players") var bounds = arena_size_for_phase(current_phase) for p in players: if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated(): var p_cell_3d = gridmap.local_to_map(p.position) var p_cell_2d = Vector2i(p_cell_3d.x, p_cell_3d.z) if _is_boundary(p_cell_2d): if multiplayer.is_server(): if p.has_method("eliminate"): p.eliminate() else: player_eliminated.emit(p.name.to_int()) # 2. Play VFX / SFX (placeholder print if no actual scene yet) if main_scene and main_scene.get("vfx_manager") and main_scene.vfx_manager.has_method("play_splash"): var cx = (bounds.x / 2.0) * gridmap.cell_size.x var cz = (bounds.y / 2.0) * gridmap.cell_size.z main_scene.vfx_manager.play_splash(Vector3(cx, 0, cz)) if has_node("/root/SfxManager"): get_node("/root/SfxManager").play_rpc("water_flood") else: print("[MektonBulls] WHOOSH! Water flood VFX played.") # 3. Set those cells to TILE_WATER for x in range(20): for z in range(20): var pos = Vector2i(x, z) if _is_boundary(pos): gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER) gridmap.set_cell_item(Vector3i(x, 1, z), -1) arena_cells[pos] = CellState.WATER gridmap.update_grid_data() func _assign_initial_blueprints() -> void: var players = get_tree().get_nodes_in_group("Players") for p in players: if p.is_in_group("Players"): _reroll_blueprint(p.name.to_int()) func _reroll_blueprint(player_id: int) -> void: var bp = Blueprint3x3.new() bp.color = GOAL_COLORS[randi() % GOAL_COLORS.size()] bp.anchor = _get_valid_3x3_anchor() # Generate 3x3 cells for dx in range(3): for dz in range(3): var cpos = Vector2i(bp.anchor.x + dx, bp.anchor.y + dz) bp.cells.append(cpos) # Paint it gridmap.set_cell_item(Vector3i(cpos.x, 0, cpos.y), bp.color) player_blueprints[player_id] = bp gridmap.update_grid_data() rpc("sync_painted_cells", bp.cells, bp.color) @rpc("authority", "call_local", "reliable") func sync_painted_cells(cells: Array, color: int) -> void: for c in cells: gridmap.set_cell_item(Vector3i(c.x, 0, c.y), color) func _get_valid_3x3_anchor() -> Vector2i: var bounds = arena_size_for_phase(current_phase) # boundary is 0 and bounds-1 # inner is 1 to bounds-2 # 3x3 needs x to x+2 -> x+2 <= bounds-2 -> x <= bounds-4 var max_x = bounds.x - 4 var max_y = bounds.y - 4 if max_x < 1: max_x = 1 if max_y < 1: max_y = 1 return Vector2i(randi_range(1, max_x), randi_range(1, max_y)) func _process_candy_tick() -> void: var players = get_tree().get_nodes_in_group("Players") for p in players: if not p.is_in_group("Players") or (p.has_method("is_eliminated") and p.is_eliminated()): continue var pid = p.name.to_int() if not player_blueprints.has(pid): continue var bp: Blueprint3x3 = player_blueprints[pid] var pos_3d = gridmap.local_to_map(p.position) var pos = Vector2i(pos_3d.x, pos_3d.z) if pos in bp.cells: var item = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if item == bp.color: # Pickup! bp.progress += 1 # Remove color local gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_WALKABLE) rpc("sync_painted_cells", [pos], TILE_WALKABLE) if bp.progress >= 9: _grant_power(pid) _reroll_blueprint(pid) func _grant_power(player_id: int) -> void: print("[MektonBulls] Blueprint complete! Prompting power picker for ", player_id) # Safely call rpc_id if multiplayer.has_multiplayer_peer() and multiplayer.get_peers().has(player_id) or player_id == multiplayer.get_unique_id(): rpc_id(player_id, "prompt_power_picker") @rpc("authority", "call_remote", "reliable") func prompt_power_picker() -> void: if hud_node and hud_node.has_method("show_power_picker"): hud_node.show_power_picker() var hud_node: Node = null @rpc("authority", "call_local", "reliable") func sync_score_completed(placements: Dictionary) -> void: if hud_node and hud_node.has_method("show_placement"): hud_node.show_placement(placements) @rpc("any_peer", "call_local", "reliable") func try_pick_power(power_type_str: String) -> void: var sender = multiplayer.get_remote_sender_id() if not multiplayer.is_server(): return if not player_powers.has(sender): player_powers[sender] = { "FREEZE": 0, "KNOCK": 0 } if power_type_str == "FREEZE" or power_type_str == "KNOCK": player_powers[sender][power_type_str] += 1 rpc("sync_player_powers", sender, player_powers[sender]) @rpc("authority", "call_local", "reliable") func sync_player_powers(pid: int, powers: Dictionary) -> void: player_powers[pid] = powers @rpc("any_peer", "call_local", "reliable") func try_use_freeze() -> void: var sender = multiplayer.get_remote_sender_id() if not multiplayer.is_server(): return if player_cooldowns.get(sender, 0.0) > 0: return if not player_powers.has(sender) or player_powers[sender]["FREEZE"] <= 0: return player_powers[sender]["FREEZE"] -= 1 player_cooldowns[sender] = 1.0 rpc("sync_player_powers", sender, player_powers[sender]) if bull_node and bull_node.has_method("apply_slow"): bull_node.apply_slow(3.0) if has_node("/root/SfxManager"): get_node("/root/SfxManager").play_rpc("freeze_burst") @rpc("any_peer", "call_local", "reliable") func try_use_knock(target_id: int, dir: Vector3) -> void: var sender = multiplayer.get_remote_sender_id() if not multiplayer.is_server(): return if player_cooldowns.get(sender, 0.0) > 0: return if not player_powers.has(sender) or player_powers[sender]["KNOCK"] <= 0: return player_powers[sender]["KNOCK"] -= 1 player_cooldowns[sender] = 1.0 rpc("sync_player_powers", sender, player_powers[sender]) rpc("sync_apply_knock", target_id, dir) @rpc("authority", "call_local", "reliable") func sync_apply_knock(target_id: int, dir: Vector3) -> void: var players = get_tree().get_nodes_in_group("Players") for p in players: if p.name == str(target_id): # Translate position 1 cell in dir var move_dist = gridmap.cell_size.x # Normalize to 4 directions var dx = 0 var dz = 0 if abs(dir.x) > abs(dir.z): dx = sign(dir.x) else: dz = sign(dir.z) p.position += Vector3(dx * move_dist, 0, dz * move_dist) # Play a smack sound if available if has_node("/root/SfxManager"): get_node("/root/SfxManager").play_rpc("knock_burst") elif main_scene and main_scene.get("audio_manager") and main_scene.audio_manager.has_method("play_sfx"): main_scene.audio_manager.play_sfx("smack") func _validate_blueprint_after_shrink() -> void: if not multiplayer.is_server(): return var bounds = arena_size_for_phase(current_phase) for pid in player_blueprints.keys(): var bp: Blueprint3x3 = player_blueprints[pid] var outside = false for c in bp.cells: if c.x <= 0 or c.x >= bounds.x - 1 or c.y <= 0 or c.y >= bounds.y - 1: outside = true break if outside: # Clear old ones rpc("sync_painted_cells", bp.cells, TILE_WALKABLE) _reroll_blueprint(pid) func _on_player_eliminated(player_id: int) -> void: if not elimination_order.has(player_id): elimination_order.append(player_id) print("[MektonBulls] Player %d eliminated. Rank: %d" % [player_id, elimination_order.size()]) # Placement rank: 1 is first out player_placement[player_id] = elimination_order.size() if multiplayer.is_server(): # Check if only 1 player remains var players = get_tree().get_nodes_in_group("Players") var alive_count = 0 var last_alive: int = -1 for p in players: if p.is_in_group("Players"): var pid = p.name.to_int() if not elimination_order.has(pid): alive_count += 1 last_alive = pid if alive_count <= 1: if last_alive != -1 and not elimination_order.has(last_alive): elimination_order.append(last_alive) player_placement[last_alive] = elimination_order.size() is_active = false _end_round() func _end_round() -> void: if not multiplayer.is_server(): return print("[MektonBulls] Round ended. Computing placement scores...") var total_players = elimination_order.size() if total_players == 0: return var min_pts = LobbyManager.mekton_bulls_min_points var max_pts = LobbyManager.mekton_bulls_max_points var scores = {} for i in range(total_players): var pid = elimination_order[i] var rank = i + 1 # 1 = first out var pts = min_pts if total_players > 1: var t = float(rank - 1) / float(total_players - 1) pts = int(lerp(float(min_pts), float(max_pts), t)) scores[pid] = pts print("[MektonBulls] Player %d finished rank %d -> %d pts" % [pid, rank, pts]) # In the real game, we'd sync this to the scores manager # main_scene.rpc("sync_score_updated", scores) - wait, is there a direct scoreboard in Mekton Bulls? # Typically GoalsCycleManager tracks scores, or main.gd if main_scene and main_scene.get("goals_cycle_manager"): for pid in scores.keys(): main_scene.goals_cycle_manager.add_score(pid, scores[pid]) rpc("sync_score_completed", player_placement) # End the goal cycle match if it hasn't already if main_scene and main_scene.get("goals_cycle_manager") and main_scene.goals_cycle_manager.is_active: main_scene.goals_cycle_manager.end_match()