Персонаж: модель киберниндзя со скелетом вместо капсул

- 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:
2026-10-28 13:11:23 +05:00
co-authored by Claude Sonnet 5.5
parent ace7ed5c84
commit b8282b1438
7 changed files with 186 additions and 47 deletions
+109 -36
View File
@@ -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))