Files
tekton/scripts/managers/mekton_bulls_manager.gd
T

594 lines
18 KiB
GDScript

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()