Files
my-world/models/Bochi-Rock/rock-follow.gd
T
aibrashandClaude Sonnet 5.5 ed4ee86c8e Верити: второй преследователь, общая сеть преследователей
- models/verity: модель verity2, звук из видео (verity.ogg), сцена с тем же
  мозгом, что у камня. В Backrooms вместо камня — Верити; в песочнице — Верити и камень.
- Сеть: синхронизация любого числа преследователей по имени (Net.chasers,
  submit_chasers/recv_chasers) вместо одного камня.
- rock-follow.gd: chaser_id, model_scale, emission_energy (чисто светящийся материал).
- Экспорт: models/verity только в пакетах карт.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:20:27 +05:00

270 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node3D
# Преследователь (камень, Верити…): гонится за ближайшим живым игроком, обходя препятствия по навмешу, и ловит его.
# С sight_limited он замечает игрока лишь вблизи или в поле зрения без стен между ними;
# потеряв из виду, идёт к последнему месту, где видел, и дальше бродит по карте.
# В сети камень считает один клиент (владелец, его назначает сервер), остальные
# плавно повторяют его позицию — поэтому у всех игроков камень один и тот же.
@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))