extends Node3D # Преследователь (камень, Верити…): гонится за ближайшим живым игроком, обходя препятствия по навмешу, и ловит его. # С sight_limited замечает игрока лишь вблизи или в поле зрения без стен между ними; # потеряв из виду, идёт к последнему месту, где видел, и дальше бродит по карте. # В сети всех преследователей комнаты считает один клиент (владелец, его назначает сервер, см. net/net.gd), # остальные плавно повторяют их позиции — поэтому у всех игроков преследователи одни и те же. @export var chaser_id := "rock" # Имя в сети: по нему владелец и остальные клиенты сопоставляют преследователей @export var model_scale := 1.0 # Масштаб узла Model (если он есть): уменьшаем в тесных картах @export var emission_energy := -1.0 # Модель светится сама (чёрная основа, цвет = свечение × это число); меньше 0 — как в модели @export var speed := 15.0 # Скорость движения @export var turn_speed := 6.0 # Как быстро разворачивается к игроку @export var catch_distance := 3.0 # С какого расстояния (по горизонтали) ловит игрока @export var catch_height := 4.0 # Игрока выше этого над камнем не достать @export var repath_distance := 1.0 # Пересчитываем путь, когда игрок сместился на столько @export var respawn_safe_radius := 25.0 # Игрок возродился ближе этого — преследователь уходит на свой старт @export var sync_smoothing := 12.0 # Плавность повтора чужого камня @export var snap_distance := 20.0 # Дальше этого чужой камень не едет, а прыгает (сброс на старт) @export_group("Зрение") @export var sight_limited := false # false — знает, где игрок, из любой точки карты @export var sight_range := 30.0 # Дальность зрения, м @export var sight_fov := 120.0 # Угол обзора, градусы @export var hearing_radius := 6.0 # Ближе этого замечает игрока, даже за стеной @export var lose_time := 4.0 # Сколько ещё идёт за игроком после потери из виду, сек @export var patrol_speed := 0.4 # Доля speed, когда бродит @export var wander_radius := 30.0 # Куда выбирает точки для прогулки, м @export var eye_height := 0.9 # Высота «глаз» над полом, м @onready var agent: NavigationAgent3D = $NavigationAgent3D @onready var start_transform := global_transform enum State { WANDER, CHASE } var player: Node3D = null var _state := State.WANDER var _last_seen := Vector3.ZERO var _since_seen := 0.0 var _wander_target := Vector3.ZERO var _wander_active := false var _wander_wait := 0.0 var _wander_time := 0.0 var _own_rids: Array[RID] = [] func _ready() -> void: agent.target_desired_distance = 1.0 agent.path_desired_distance = 1.5 agent.path_height_offset = 0.25 # Навмеш лежит чуть выше пола var model := get_node_or_null("Model") if model != null and model_scale != 1.0: model.scale = Vector3.ONE * model_scale if emission_energy >= 0.0: _set_emission(emission_energy) for body in find_children("*", "CollisionObject3D", true, false): _own_rids.append(body.get_rid()) # Луч зрения не должен упираться в сам камень Net.chasers[chaser_id] = self Net.peer_respawned.connect(_on_respawned) # Копируем материалы модели (они общие для всех её экземпляров) и делаем их чисто светящимися: # без белой основы лицо не выгорает на ярком свету и не пропадает при слабом свечении func _set_emission(energy: float) -> void: for mi in find_children("*", "MeshInstance3D", true, false): for s in mi.mesh.get_surface_count(): var mat := mi.get_active_material(s) as BaseMaterial3D if mat != null and mat.emission_enabled: var copy := mat.duplicate() as BaseMaterial3D copy.albedo_color = Color.BLACK copy.emission_energy_multiplier = energy mi.set_surface_override_material(s, copy) func _exit_tree() -> void: if Net.chasers.get(chaser_id) == self: Net.chasers.erase(chaser_id) func _physics_process(delta: float) -> void: if player == null: player = get_tree().get_first_node_in_group("local_player") as Node3D if player == null: return player.respawned.connect(func(): _on_respawned(player.global_position)) if Net.rock_is_local(): _chase(delta) else: _follow_owner(delta) _try_catch() # Своего игрока ловит его собственный клиент — по тому камню, который он видит func _try_catch() -> void: if player.get("is_dead"): return var flat := player.global_position - global_position var dy := flat.y flat.y = 0.0 if flat.length() <= catch_distance and absf(dy) <= catch_height: player.die() # Живые игроки: свой и чужие func _candidates() -> Array[Node3D]: var out: Array[Node3D] = [] if not player.get("is_dead"): out.append(player) for id in Net.ghosts: var g: Node3D = Net.ghosts[id] if is_instance_valid(g) and not Net.dead_peers.has(id): out.append(g) return out func _horizontal_dist_sq(c: Node3D) -> float: return Vector2(c.global_position.x - global_position.x, c.global_position.z - global_position.z).length_squared() # Ближайший по горизонтали живой игрок func _pick_target() -> Node3D: var best: Node3D = null var best_d := INF for c in _candidates(): var d := _horizontal_dist_sq(c) if d < best_d: best_d = d best = c return best # Ближайший из тех, кого камень сейчас замечает func _pick_visible_target() -> Node3D: var best: Node3D = null var best_d := INF for c in _candidates(): var d := _horizontal_dist_sq(c) if d < best_d and _can_see(c): best_d = d best = c return best # Рядом — слышит (даже за стеной); дальше — только в конусе обзора и если стены не мешают func _can_see(c: Node3D) -> bool: var to := c.global_position - global_position var flat := Vector3(to.x, 0.0, to.z) var dist := flat.length() if dist <= hearing_radius: return true if dist > sight_range: return false var fwd := -global_transform.basis.z fwd.y = 0.0 if fwd.normalized().dot(flat / dist) < cos(deg_to_rad(sight_fov * 0.5)): return false var query := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, eye_height, 0), c.global_position, 1) var exclude := _own_rids.duplicate() if c is CollisionObject3D: exclude.append(c.get_rid()) query.exclude = exclude return get_world_3d().direct_space_state.intersect_ray(query).is_empty() func _chase(delta: float) -> void: if not sight_limited: var target := _pick_target() if target != null: # Все лежат — стоим _move_to(target.global_position, speed, catch_distance, delta) return var seen := _pick_visible_target() if seen != null: _state = State.CHASE _last_seen = seen.global_position _since_seen = 0.0 elif _state == State.CHASE: _since_seen += delta if _since_seen > lose_time: _state = State.WANDER _wander_active = false _wander_wait = 1.0 if _state == State.CHASE: # Видим — стоим над жертвой у catch_distance; потеряли — идём к последнему месту _move_to(_last_seen, speed, catch_distance if seen != null else 1.5, delta) else: _wander(delta) func _wander(delta: float) -> void: if _wander_wait > 0.0: _wander_wait -= delta return if not _wander_active: if not _pick_wander_target(): _wander_wait = 1.0 return _wander_active = true _wander_time = 0.0 _wander_time += delta if _move_to(_wander_target, speed * patrol_speed, 1.5, delta) or _wander_time > 20.0: _wander_active = false _wander_wait = randf_range(1.0, 3.0) # Случайная точка навмеша на расстоянии 8..wander_radius м; нет такой — ближайшая из дальних func _pick_wander_target() -> bool: var map := agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(map) == 0: return false var best := Vector3.INF var best_d := INF for i in 8: var p := NavigationServer3D.map_get_random_point(map, 1, true) var d := p.distance_to(global_position) if d < 8.0: continue if d <= wander_radius: best = p break if d < best_d: best = p best_d = d if best == Vector3.INF: return false _wander_target = best return true # Шаг к точке по навмешу. true — дошли (ближе stop_dist по горизонтали) func _move_to(dest: Vector3, spd: float, stop_dist: float, delta: float) -> bool: var flat := dest - global_position flat.y = 0.0 if flat.length() <= stop_dist: _face(flat, delta) # Стоим над жертвой, пока её клиент не засчитает поимку return true var step: Vector3 var map := agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(map) > 0: if agent.target_position.distance_to(dest) > repath_distance: agent.target_position = dest if not agent.is_navigation_finished(): step = agent.get_next_path_position() - global_position # Навмеш ещё не готов или точка вне его — идём напрямую if step == Vector3.ZERO: step = flat var d := step.length() if d < 0.001: return false global_position += step / d * min(spd * delta, d) _face(Vector3(step.x, 0.0, step.z), delta) return false # Камень считает другой клиент — плавно идём к тому, что он прислал func _follow_owner(delta: float) -> void: var state = Net.chaser_states.get(chaser_id) if state == null: return if global_position.distance_to(state.pos) > snap_distance: global_position = state.pos else: global_position = global_position.lerp(state.pos, 1.0 - exp(-sync_smoothing * delta)) rotation.y = lerp_angle(rotation.y, state.yaw, 1.0 - exp(-sync_smoothing * delta)) # Кто-то (мы или другой игрок) возродился. Если камень рядом с точкой возрождения — # уводим его на старт, чтобы не поймал снова сразу. Решает только владелец камня. func _on_respawned(pos: Vector3) -> void: if not Net.rock_is_local(): return if global_position.distance_to(pos) < respawn_safe_radius: global_transform = start_transform agent.target_position = global_position # Плавный разворот: модель смотрит вперёд по -Z func _face(dir: Vector3, delta: float) -> void: if dir.length_squared() < 0.0001: return var target_yaw := atan2(-dir.x, -dir.z) rotation.y = lerp_angle(rotation.y, target_yaw, 1.0 - exp(-turn_speed * delta))