extends Area3D enum State { ROAM, CHARGE, COOLDOWN } var current_state: State = State.ROAM var gridmap: Node var arena_manager: Node var move_speed: float = 3.0 var charge_speed: float = 12.0 var target_pos: Vector3 var state_timer: float = 0.0 var slow_timer: float = 0.0 func _ready(): add_to_group("MektonBulls") body_entered.connect(_on_body_entered) func initialize(manager: Node, grid: Node, start_pos: Vector3): arena_manager = manager gridmap = grid position = start_pos _pick_roam_target() func apply_slow(duration: float) -> void: slow_timer = duration func _physics_process(delta: float): if not multiplayer.is_server(): return state_timer -= delta if slow_timer > 0: slow_timer -= delta match current_state: State.ROAM: _process_roam(delta) if state_timer <= 0: _try_start_charge() State.CHARGE: _process_charge(delta) State.COOLDOWN: if state_timer <= 0: current_state = State.ROAM _pick_roam_target() func _process_roam(delta: float): var dist = position.distance_to(target_pos) if dist < 0.1: _pick_roam_target() else: var dir = (target_pos - position).normalized() dir.y = 0 if dir.length_squared() > 0: var actual_speed = move_speed if slow_timer > 0: actual_speed *= 0.5 position += dir * actual_speed * delta # Face direction implicitly var look_target = position + dir look_target.y = position.y if position.distance_squared_to(look_target) > 0.01: look_at(look_target, Vector3.UP) func _pick_roam_target(): if not arena_manager: return var bounds = arena_manager.arena_size_for_phase(arena_manager.current_phase) # Random walkable position within bounds # We know 0,0 is boundary, bounds.x-1 is boundary var min_x = 1 var max_x = bounds.x - 2 var min_z = 1 var max_z = bounds.y - 2 if min_x > max_x: max_x = min_x if min_z > max_z: max_z = min_z # Avoid exact center (where the static delivery target presumably sits) var cx = int(bounds.x / 2.0) var cz = int(bounds.y / 2.0) var rx = cx var rz = cz while rx == cx and rz == cz: rx = randi_range(min_x, max_x) rz = randi_range(min_z, max_z) var world_x = rx * gridmap.cell_size.x + gridmap.cell_size.x / 2.0 var world_z = rz * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 target_pos = Vector3(world_x, position.y, world_z) state_timer = randf_range(2.0, 4.0) func _try_start_charge(): # Find closest player var players = get_tree().get_nodes_in_group("Players") var closest_player = null var min_dist = INF for p in players: if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated(): var d = position.distance_to(p.position) if d < min_dist and d < 15.0: # Range check min_dist = d closest_player = p if closest_player: current_state = State.CHARGE target_pos = closest_player.position target_pos.y = position.y state_timer = 2.0 # Max charge duration if has_node("/root/SfxManager"): get_node("/root/SfxManager").play_rpc("bull_charge") else: _pick_roam_target() func _process_charge(delta: float): var dir = (target_pos - position).normalized() dir.y = 0 var actual_speed = charge_speed if slow_timer > 0: actual_speed *= 0.5 position += dir * actual_speed * delta var look_target = position + dir look_target.y = position.y if position.distance_squared_to(look_target) > 0.01: look_at(look_target, Vector3.UP) var dist = position.distance_to(target_pos) if dist < 0.5 or state_timer <= 0: # Hit destination or timeout current_state = State.COOLDOWN state_timer = 1.5 func _on_body_entered(body: Node3D): if body.is_in_group("Players") and multiplayer.is_server(): # Knock them out if body.has_method("eliminate"): body.eliminate() else: print("[MektonBull] Knocked out player", body.name) # Dispatch via manager if arena_manager and arena_manager.has_signal("player_eliminated"): arena_manager.player_eliminated.emit(body.name.to_int()) # Polish: SFX + Camera Shake rpc("sync_bull_impact") @rpc("authority", "call_local", "unreliable") func sync_bull_impact() -> void: if has_node("/root/SfxManager"): get_node("/root/SfxManager").play("bull_impact") var root = get_tree().root var main = root.get_node_or_null("Main") if main and main.get("screen_shake_manager"): main.screen_shake_manager.shake(0.2, 0.5)