Структура проекта: папки по назначению, карты самодостаточны, инструменты, README

- Перенос: Net→net, Player→player, World/*→maps/{hub,sandbox,backrooms}, core, objects,
  models/*→chasers/{rock,verity} и maps/*/models. Имена внутри папок с .glb не менялись
  (рядом лежат извлечённые текстуры). Все пути, пресеты экспорта и автолоады обновлены.
- Преследователи: rock-follow.gd → chasers/chaser.gd; камень в песочнице теперь инстанс
  chasers/rock/rock.tscn (как Верити).
- Удалён мусор: неиспользуемое небо sunflowers, Tree_01.glb, rock-path.gd, старая
  модель Верити, unwrap_cache (теперь в .gitignore).
- Основной пакет игры 70 КБ (камень и Верити уехали в пакеты карт).
- tools/: export_all.sh, serve_web.py (nginx-подобный dev-сервер), deploy_server.sh,
  test/ (боты, временный проект и сервер). Net: флаг --client для headless-клиента.
- deploy/: боевые nginx.conf и my-world.service, project.godot выделенного сервера.
- README переписан: что есть и чего нет, структура, карты, преследователи, сеть, пакеты,
  деплой, грабли, дорожная карта (телефон).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 17:47:47 +05:00
co-authored by Claude Sonnet 5.5
parent 5ba6466113
commit fc0139dc05
158 changed files with 724 additions and 565 deletions
+269
View File
@@ -0,0 +1,269 @@
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))
+1
View File
@@ -0,0 +1 @@
uid://o2kqd0cy4knr
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://280pvcvxt2n2"
path="res://.godot/imported/Guitar.ogg-a9c649256b934e43412c17ea4e1fd302.oggvorbisstr"
[deps]
source_file="res://chasers/rock/Guitar.ogg"
dest_files=["res://.godot/imported/Guitar.ogg-a9c649256b934e43412c17ea4e1fd302.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cowp33ejxcfcl"
path="res://.godot/imported/Rock.glb-d2a8f3a84a2ecdbbc2d73c9b22190137.scn"
[deps]
source_file="res://chasers/rock/Rock.glb"
dest_files=["res://.godot/imported/Rock.glb-d2a8f3a84a2ecdbbc2d73c9b22190137.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx6t5621iqw7f"
path.s3tc="res://.godot/imported/Rock_22ee8cdbd4ccc631cd7c804ab4ebca15.jpg-4f14ab3f29d2ef2273e9da4765cef118.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "d87e4757e8f4306abfb2713939bf67fa"
}
[deps]
source_file="res://chasers/rock/Rock_22ee8cdbd4ccc631cd7c804ab4ebca15.jpg"
dest_files=["res://.godot/imported/Rock_22ee8cdbd4ccc631cd7c804ab4ebca15.jpg-4f14ab3f29d2ef2273e9da4765cef118.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
+19
View File
@@ -0,0 +1,19 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://chasers/chaser.gd" id="1_script"]
[ext_resource type="PackedScene" uid="uid://cowp33ejxcfcl" path="res://chasers/rock/Rock.glb" id="2_model"]
[ext_resource type="AudioStream" uid="uid://280pvcvxt2n2" path="res://chasers/rock/Guitar.ogg" id="3_audio"]
[node name="Rock" type="Node3D"]
script = ExtResource("1_script")
[node name="Model" parent="." instance=ExtResource("2_model")]
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
height = 5.0
radius = 2.0
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Model"]
stream = ExtResource("3_audio")
autoplay = true
max_distance = 30.0
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b7qmenkjh1wdk"
path="res://.godot/imported/verity.ogg-e0f71c02d742979c6aefd117ffe3f1b5.oggvorbisstr"
[deps]
source_file="res://chasers/verity/verity.ogg"
dest_files=["res://.godot/imported/verity.ogg-e0f71c02d742979c6aefd117ffe3f1b5.oggvorbisstr"]
[params]
loop=true
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+25
View File
@@ -0,0 +1,25 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://chasers/chaser.gd" id="1_script"]
[ext_resource type="PackedScene" path="res://chasers/verity/verity2.glb" id="2_model"]
[ext_resource type="AudioStream" path="res://chasers/verity/verity.ogg" id="3_audio"]
[node name="Verity" type="Node3D"]
script = ExtResource("1_script")
chaser_id = "verity"
emission_energy = 1.0
[node name="Model" type="Node3D" parent="."]
[node name="Mesh" parent="Model" instance=ExtResource("2_model")]
transform = Transform3D(0, 0, 1, 0, 1, 0, -1, 0, 0, 0, 1.59, 0)
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
height = 5.0
radius = 2.0
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
stream = ExtResource("3_audio")
autoplay = true
max_distance = 35.0
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://wup5p3waumce"
path="res://.godot/imported/verity2.glb-60af6cc5d69d9f8af83072155580b49b.scn"
[deps]
source_file="res://chasers/verity/verity2.glb"
dest_files=["res://.godot/imported/verity2.glb-60af6cc5d69d9f8af83072155580b49b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4fe3eo5c2w6g"
path.s3tc="res://.godot/imported/verity2_unnamed.webp-1aa0cf701c3ad8dae1575d20b6b42222.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "5d3e31c7af9c06dc059d9fa5104b94dd"
}
[deps]
source_file="res://chasers/verity/verity2_unnamed.webp"
dest_files=["res://.godot/imported/verity2_unnamed.webp-1aa0cf701c3ad8dae1575d20b6b42222.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0