Персонаж: модель киберниндзя со скелетом вместо капсул
- player/character/character.glb: 752 треугольника, 17 костей, 14 анимаций (idle, walk, run, crouch, slide, jump, fall, air_jump, dash, wallrun L/R, mantle, death); ноги на IK, исходник и скрипт сборки в Blender models - Ghost: выбор анимации по флагам и скорости, подгонка speed_scale, голова повторяет взгляд, свечение красится в цвет игрока от его id - Net: флаги FLAG_WALL_RIGHT и FLAG_AIR (RPC не менялись, сервер перевыкладывать не нужно) - Игрок: капсула в player.tscn заменена моделью, спрятанной от своей камеры - Модель в ghost.gd грузится через load, чтобы не ломать сервер без неё Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
| ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает |
|
||||
| ❌ | Сохранения прогресса, аккаунтов |
|
||||
| ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») |
|
||||
| ❌ | Анимаций персонажа: чужой игрок — просто капсула (`net/ghost.gd`) |
|
||||
| ✅ | Чужой игрок — модель киберниндзя со скелетом (`player/character/character.glb`, 752 треугольника, 17 костей, 14 анимаций); свечение красится в цвет игрока от его id. Своё тело — та же модель, спрятанная от своей камеры |
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -68,7 +68,8 @@ godot --path . # можно запустить несколько ок
|
||||
project.godot, export_presets.cfg, icon.svg
|
||||
net/ сеть. Net — автолоад: и клиент, и сервер (один скрипт)
|
||||
net.gd состояние комнат, преследователей, RPC (см. «Сеть»)
|
||||
ghost.gd чужой игрок: капсула с цветом от id и ником
|
||||
ghost.gd чужой игрок: модель, выбор анимации по флагам и скорости, цвет свечения от id, ник
|
||||
player/character/ character.glb — модель и анимации (исходник и скрипт сборки: `../Blender models/character_01/`)
|
||||
server.tscn пустая сцена для запуска выделенного сервера
|
||||
core/
|
||||
map_loader.gd автолоад MapLoader: скачивает пакет карты и подключает его (см. «Загрузка карт»)
|
||||
@@ -161,7 +162,7 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
|
||||
| `submit_life(dead, pos)` / `recv_life(id, dead, pos)` | клиент → сервер → остальные | смерть/возрождение, `reliable` |
|
||||
| `recv_left(id)` | сервер → комната | игрок вышел |
|
||||
|
||||
Флаги состояния (`Net.FLAG_*`): подкат, бег по стене, рывок, подтягивание. Присед передаётся как подкат (у призрака сплюснутая капсула).
|
||||
Флаги состояния (`Net.FLAG_*`): подкат, бег по стене (+ сторона стены), рывок, подтягивание, «в воздухе». Присед передаётся как подкат; призрак отличает их по скорости. Скорость и двойной прыжок призрак считает сам по смещению позиции — в протокол они не входят.
|
||||
|
||||
**RPC требуют одинаковые путь узла и скрипт на сервере и клиенте**, поэтому `net.gd` один. Любое изменение RPC — **клиент и сервер выкладываются вместе**
|
||||
(старый клиент с новым сервером не работает). Имена вроде `rock_owner`/`recv_rock_owner` остались от времени, когда преследователь был один.
|
||||
@@ -291,11 +292,24 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
|
||||
|
||||
### Другое
|
||||
|
||||
- Ники по выбору игрока, текстовый чат, анимации персонажа вместо капсулы, настройки (чувствительность, громкость), меню паузы.
|
||||
- Ники по выбору игрока, текстовый чат, анимация приземления, свои руки/тело от первого лица, настройки (чувствительность, громкость), меню паузы.
|
||||
- Серверные проверки (антиспуфинг позиций), если игроков станет много.
|
||||
- Новые карты (добавляются по инструкции выше) и новые преследователи.
|
||||
- Автоматизировать прогон ботов в CI и проверку «пакет не пустой» после экспорта.
|
||||
|
||||
## Персонаж
|
||||
|
||||
Модель и анимации **генерирует скрипт** `Blender models/character_01/build_character.py` (Blender 5.x: `exec` в окне или `blender -b -P`).
|
||||
Он строит меш из гранёных сегментов (жёсткие веса: одна кость на сегмент), арматуру из 17 костей, считает позы и пишет 14 анимаций
|
||||
(`idle walk run crouch_idle crouch_walk slide jump fall air_jump dash wallrun_R wallrun_L mantle death`), ноги ставятся через IK.
|
||||
Экспорт: GLB, `export_animation_mode=NLA_TRACKS`, в `player/character/character.glb`. Материалы: `Suit` (тёмный) и `Glow` (свечение; в игре заменяется цветом игрока).
|
||||
|
||||
- **Выбор анимации** — `Ghost._choose()`: смерть > подтягивание > рывок > бег по стене > воздух > присед/подкат > бег/ходьба/стоит.
|
||||
Скорость шага подгоняется `speed_scale` (`REF_SPEED`), между анимациями смешивание 0,15 с. Голова повторяет взгляд вверх-вниз.
|
||||
- **Своё тело:** та же модель стоит в `player/player.tscn` (видна в редакторе, отбрасывает тень), но спрятана от своей камеры слоем 2 (`player.gd → _ready`). Не анимируется.
|
||||
- **Выкладка сервера:** `ghost.gd` лежит и на сервере, где модели нет, поэтому модель грузится `load()` внутри `setup()`, а не `preload`.
|
||||
- Изменить позы: правь функции анимаций в скрипте, перезапусти его в Blender, заново экспортируй GLB и импортируй в Godot.
|
||||
|
||||
## Лицензии и авторство
|
||||
|
||||
- Модель карты Backrooms — «Backrooms VR», автор VTX (https://sketchfab.com/VTX_car), **CC-BY 4.0** (`maps/backrooms/model/CREDITS.md`).
|
||||
|
||||
+109
-36
@@ -1,66 +1,139 @@
|
||||
class_name Ghost
|
||||
extends Node3D
|
||||
# Чужой игрок: капсула, которая плавно догоняет последнее полученное состояние.
|
||||
# Чужой игрок: киберниндзя со скелетом. Позиция и поворот плавно догоняют последнее полученное состояние,
|
||||
# анимация выбирается по флагам и скорости, которые прислал игрок (скорость считаем сами по смещению).
|
||||
# Свечение модели красится в цвет игрока (от его id) — так игроков отличают друг от друга.
|
||||
|
||||
# load, не preload: скрипт лежит и на выделенном сервере, а модели там нет (сервер Ghost не создаёт)
|
||||
const MODEL_PATH = "res://player/character/character.glb"
|
||||
const SMOOTHING = 14.0
|
||||
const FEET_OFFSET = -1.16 # Ноги относительно центра капсулы игрока
|
||||
const BLEND = 0.15 # Сглаживание между анимациями, сек
|
||||
const GLOW_ENERGY = 1.0
|
||||
const LOOPED = ["idle", "walk", "run", "crouch_idle", "crouch_walk", "slide", "jump", "fall", "dash", "wallrun_R", "wallrun_L"]
|
||||
# Скорость, на которой анимация шагает без скольжения ног; реальную скорость приводим к ней через speed_scale
|
||||
const REF_SPEED = {"walk": 2.0, "run": 6.1, "crouch_walk": 1.4, "wallrun_R": 5.9, "wallrun_L": 5.9}
|
||||
const STAND_SPEED = 0.8 # Медленнее — стоит
|
||||
const RUN_SPEED = 4.5 # Быстрее — бежит, медленнее — идёт
|
||||
const SLIDE_SPEED = 6.5 # В приседе быстрее — подкат
|
||||
const AIR_JUMP_KICK = 5.0 # Рывок вертикальной скорости в воздухе — двойной прыжок
|
||||
const AIR_JUMP_TIME = 0.6 # Длительность анимации двойного прыжка (сальто)
|
||||
const HEAD_PITCH_SHARE = 0.7 # Какую долю наклона головы игрока повторяет модель
|
||||
|
||||
var id := 0
|
||||
var color := Color.WHITE
|
||||
var target_pos := Vector3.ZERO
|
||||
var target_yaw := 0.0
|
||||
var pitch := 0.0
|
||||
var flags := 0
|
||||
var body: MeshInstance3D
|
||||
var visor: MeshInstance3D
|
||||
var velocity := Vector3.ZERO # Сглаженная скорость по сетевым пакетам, м/с
|
||||
|
||||
func setup(id: int, pos: Vector3) -> void:
|
||||
var anim: AnimationPlayer
|
||||
var skeleton: Skeleton3D
|
||||
var head_bone := -1
|
||||
var _last_msec := 0
|
||||
var _raw_vy := 0.0
|
||||
var _air_time := 0.0
|
||||
var _air_jump_left := 0.0
|
||||
var _current := ""
|
||||
|
||||
func setup(peer_id: int, pos: Vector3) -> void:
|
||||
id = peer_id
|
||||
name = "Ghost_%d" % id
|
||||
position = pos
|
||||
target_pos = pos
|
||||
var color := Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0)
|
||||
color = Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0)
|
||||
_last_msec = Time.get_ticks_msec()
|
||||
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = 0.6
|
||||
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = 0.34
|
||||
capsule.height = 2.3
|
||||
body = MeshInstance3D.new()
|
||||
body.mesh = capsule
|
||||
body.material_override = mat
|
||||
add_child(body)
|
||||
|
||||
# «Визор» показывает, куда смотрит игрок (-Z — вперёд)
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(0.5, 0.15, 0.3)
|
||||
visor = MeshInstance3D.new()
|
||||
visor.mesh = box
|
||||
visor.position = Vector3(0, 0.9, -0.25)
|
||||
add_child(visor)
|
||||
var model := (load(MODEL_PATH) as PackedScene).instantiate() as Node3D
|
||||
model.position.y = FEET_OFFSET
|
||||
add_child(model)
|
||||
skeleton = model.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||
anim = model.find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||||
head_bone = skeleton.find_bone("Head")
|
||||
for n in LOOPED:
|
||||
anim.get_animation(n).loop_mode = Animation.LOOP_LINEAR
|
||||
anim.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL # кадры двигаем сами (см. _process)
|
||||
_paint_glow(model)
|
||||
|
||||
var label := Label3D.new()
|
||||
label.text = "Игрок %d" % (id % 1000)
|
||||
label.modulate = color
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.no_depth_test = true
|
||||
label.position = Vector3(0, 1.6, 0)
|
||||
label.position = Vector3(0, 1.5, 0)
|
||||
label.pixel_size = 0.005
|
||||
add_child(label)
|
||||
|
||||
func set_target(pos: Vector3, yaw: float, pitch: float, new_flags: int) -> void:
|
||||
# Материал «Glow» модели заменяем своим — с цветом игрока (общий материал модели остался бы одним на всех)
|
||||
func _paint_glow(model: Node) -> void:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color.BLACK
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = GLOW_ENERGY
|
||||
for mi: MeshInstance3D in model.find_children("*", "MeshInstance3D", true, false):
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var m := mi.mesh.surface_get_material(s)
|
||||
if m != null and m.resource_name == "Glow":
|
||||
mi.set_surface_override_material(s, mat)
|
||||
|
||||
func set_target(pos: Vector3, yaw: float, new_pitch: float, new_flags: int) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
var dt := (now - _last_msec) / 1000.0
|
||||
_last_msec = now
|
||||
if dt > 0.005:
|
||||
var raw := (pos - target_pos) / dt
|
||||
velocity = velocity.lerp(raw, 0.5)
|
||||
# Двойной прыжок: уже летим какое-то время и вертикальная скорость резко выросла
|
||||
if (new_flags & Net.FLAG_AIR) != 0 and _air_time > 0.15 and raw.y - _raw_vy > AIR_JUMP_KICK:
|
||||
_air_jump_left = AIR_JUMP_TIME
|
||||
_raw_vy = raw.y
|
||||
target_pos = pos
|
||||
target_yaw = yaw
|
||||
pitch = new_pitch
|
||||
flags = new_flags
|
||||
visor.rotation.x = pitch
|
||||
|
||||
# Какая анимация подходит текущему состоянию
|
||||
func _choose(speed: float) -> String:
|
||||
if Net.dead_peers.has(id):
|
||||
return "death"
|
||||
if (flags & Net.FLAG_MANTLE) != 0:
|
||||
return "mantle"
|
||||
if (flags & Net.FLAG_DASH) != 0:
|
||||
return "dash"
|
||||
if (flags & Net.FLAG_WALLRUN) != 0:
|
||||
return "wallrun_R" if (flags & Net.FLAG_WALL_RIGHT) != 0 else "wallrun_L"
|
||||
if (flags & Net.FLAG_AIR) != 0:
|
||||
if _air_jump_left > 0.0:
|
||||
return "air_jump"
|
||||
return "jump" if velocity.y > 0.5 else "fall"
|
||||
if (flags & Net.FLAG_SLIDING) != 0: # присед и подкат приходят одним флагом
|
||||
if speed > SLIDE_SPEED:
|
||||
return "slide"
|
||||
return "crouch_walk" if speed > STAND_SPEED else "crouch_idle"
|
||||
if speed > RUN_SPEED:
|
||||
return "run"
|
||||
return "walk" if speed > STAND_SPEED else "idle"
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta)
|
||||
position = position.lerp(target_pos, t)
|
||||
rotation.y = lerp_angle(rotation.y, target_yaw, t)
|
||||
|
||||
var sliding := (flags & Net.FLAG_SLIDING) != 0
|
||||
var squash := 0.55 if sliding else 1.0
|
||||
body.scale = body.scale.lerp(Vector3(1, squash, 1), t)
|
||||
var roll := 0.0
|
||||
if (flags & Net.FLAG_WALLRUN) != 0:
|
||||
roll = 0.35
|
||||
body.rotation.z = lerp(body.rotation.z, roll, t)
|
||||
_air_time = _air_time + delta if (flags & Net.FLAG_AIR) != 0 else 0.0
|
||||
_air_jump_left = maxf(_air_jump_left - delta, 0.0)
|
||||
|
||||
var speed := Vector2(velocity.x, velocity.z).length()
|
||||
var wanted := _choose(speed)
|
||||
if wanted != _current:
|
||||
_current = wanted
|
||||
anim.play(wanted, BLEND)
|
||||
anim.speed_scale = clampf(speed / REF_SPEED[wanted], 0.5, 2.0) if REF_SPEED.has(wanted) else 1.0
|
||||
anim.advance(delta)
|
||||
|
||||
# Голова повторяет взгляд игрока вверх-вниз (только пока анимация идёт: иначе наклон копился бы с кадра на кадр)
|
||||
if head_bone >= 0 and anim.is_playing():
|
||||
var q := skeleton.get_bone_pose_rotation(head_bone)
|
||||
var tilt := clampf(pitch * HEAD_PITCH_SHARE, -0.9, 0.9)
|
||||
skeleton.set_bone_pose_rotation(head_bone, q * Quaternion(Vector3.RIGHT, tilt))
|
||||
|
||||
@@ -15,6 +15,8 @@ const FLAG_SLIDING = 1
|
||||
const FLAG_WALLRUN = 2
|
||||
const FLAG_DASH = 4
|
||||
const FLAG_MANTLE = 8
|
||||
const FLAG_WALL_RIGHT = 16 # Бег по стене: стена справа (иначе слева)
|
||||
const FLAG_AIR = 32 # В воздухе (не на полу, не на стене, не на уступе)
|
||||
|
||||
signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://d1vpt63d58k7j"
|
||||
path="res://.godot/imported/character.glb-1a3f83084714c6a7099112be703c2a9c.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://player/character/character.glb"
|
||||
dest_files=["res://.godot/imported/character.glb-1a3f83084714c6a7099112be703c2a9c.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
|
||||
+9
-1
@@ -196,6 +196,11 @@ func _ready() -> void:
|
||||
head_base_pos = head.position
|
||||
base_fov = head.fov
|
||||
|
||||
# Тело видно в редакторе и отбрасывает тень, но своя камера его не рисует (слой 2)
|
||||
for mi: MeshInstance3D in $Model.find_children("*", "MeshInstance3D", true, false):
|
||||
mi.layers = 2
|
||||
head.cull_mask = 0xFFFFF & ~2
|
||||
|
||||
# Чтобы при спуске по ступенькам персонаж «прилипал» к полу, а не летел по воздуху
|
||||
floor_snap_length = STEP_HEIGHT
|
||||
|
||||
@@ -251,9 +256,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
func get_net_flags() -> int:
|
||||
var flags := 0
|
||||
if is_sliding or is_crouching: flags |= Net.FLAG_SLIDING # У призрака присед выглядит как сплюснутая капсула
|
||||
if is_wallrunning: flags |= Net.FLAG_WALLRUN
|
||||
if is_wallrunning:
|
||||
flags |= Net.FLAG_WALLRUN
|
||||
if wall_side > 0.0: flags |= Net.FLAG_WALL_RIGHT
|
||||
if is_dashing: flags |= Net.FLAG_DASH
|
||||
if is_mantling: flags |= Net.FLAG_MANTLE
|
||||
if not is_on_floor() and not is_wallrunning and not is_mantling: flags |= Net.FLAG_AIR
|
||||
return flags
|
||||
|
||||
func _get_input_dir() -> Vector2:
|
||||
|
||||
+3
-6
@@ -1,15 +1,12 @@
|
||||
[gd_scene format=3 uid="uid://c3efk6k8xnf57"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c22hclit1affa" path="res://player/player.gd" id="1_iwpo4"]
|
||||
[ext_resource type="PackedScene" uid="uid://d1vpt63d58k7j" path="res://player/character/character.glb" id="2_model"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_h8axm"]
|
||||
radius = 0.35205078
|
||||
height = 2.3198242
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_bcw3a"]
|
||||
radius = 0.34472656
|
||||
height = 2.3242188
|
||||
|
||||
[node name="CharacterBody3D" type="CharacterBody3D" unique_id=365865796]
|
||||
script = ExtResource("1_iwpo4")
|
||||
|
||||
@@ -21,5 +18,5 @@ debug_color = Color(0, 0.6, 0.69803923, 1)
|
||||
transform = Transform3D(1, 0, -1.7484555e-07, 0, 1, 0, 1.7484555e-07, 0, 1, 0, 0.9, 0)
|
||||
fov = 59.9
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=81801455]
|
||||
mesh = SubResource("CapsuleMesh_bcw3a")
|
||||
[node name="Model" parent="." unique_id=81801455 instance=ExtResource("2_model")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.16, 0)
|
||||
|
||||
Reference in New Issue
Block a user