extends CharacterBody3D # --- Скорость и инерция (паркур-ощущение) --- const WALK_SPEED = 6.0 const SPRINT_SPEED = 13.0 const GROUND_ACCEL = 55.0 # Разгон на земле const GROUND_FRICTION = 45.0 # Торможение на земле без ввода const OVERSPEED_DECEL = 14.0 # Как быстро гасится "лишняя" скорость (после слайда/прыжка) при беге const AIR_ACCEL = 18.0 # Управление в воздухе const MAX_SPEED = 22.0 # Потолок скорости # --- Прыжок --- const JUMP_VELOCITY = 6.0 const FALL_GRAVITY_MULT = 1.4 # Быстрее падаем — прыжок «злее» const JUMP_CUT_GRAVITY_MULT = 2.5 # Отпустил пробел — прыжок ниже const COYOTE_TIME = 0.12 # Можно прыгнуть сразу после схода с края const JUMP_BUFFER_TIME = 0.12 # Нажал пробел чуть раньше приземления — прыжок сработает const SLIDE_JUMP_BOOST = 1.15 # Бонус скорости при прыжке из слайда # --- Двойной прыжок --- const AIR_JUMPS = 1 # Сколько прыжков в воздухе (0 — отключить) const AIR_JUMP_VELOCITY = 5.5 # --- Рывок (клавиша Q) --- const DASH_SPEED = 20.0 # Не больше MAX_SPEED const DASH_TIME = 0.18 # Длительность рывка const DASH_EXIT_SPEED = 13.0 # Скорость, с которой выходим из рывка const DASH_COOLDOWN = 0.6 const MAX_DASHES = 1 # Зарядов рывка в воздухе (восстанавливаются на земле и на стенах) const DASH_FOV_BONUS = 16.0 const DASH_VIGNETTE = 0.5 # --- Бег по стенам --- const WALLRUN_MIN_SPEED = 6.0 # Минимальная скорость, чтобы «зацепиться» за стену const WALLRUN_SPEED = 11.0 # Скорость бега по стене const WALLRUN_MAX_TIME = 1.8 # Сколько можно бежать по стене const WALLRUN_MAX_FALL_SPEED = 10.0 # Если падаем быстрее — зацепиться нельзя const WALLRUN_GRAVITY = 25.0 # Как быстро гасится вертикальная скорость на стене const WALLRUN_SINK = 0.8 # Медленное сползание вниз const WALLRUN_STICK = 3.0 # Прижимание к стене const WALLRUN_COOLDOWN = 0.35 # Пауза после схода со стены const WALLRUN_ROLL = 12.0 # Наклон камеры на стене (градусы) const WALLRUN_FOV_BONUS = 8.0 const WALLRUN_VIGNETTE = 0.3 # --- Прыжок от стены --- const WALL_JUMP_PUSH = 8.0 # Отталкивание от стены const WALL_JUMP_UP = 7.0 const MAX_WALL_JUMPS = 2 # Прыжков от стены без бега по ней (сбрасывается на земле) # --- Подтягивание на уступ (держи пробел в воздухе рядом с краем) --- const MANTLE_WALL_Y = 0.5 # Высота (от ног), на которой ищем стену перед собой const MANTLE_MIN_HEIGHT = 0.5 # Минимальная высота уступа относительно ног const MANTLE_MAX_HEIGHT = 1.5 # Максимальная высота уступа относительно ног const MANTLE_REACH = 0.9 # Дальность поиска стены const MANTLE_FORWARD = 0.5 # Насколько вглубь уступа ставим персонажа const MANTLE_CLEARANCE = 1.8 # Свободное место над уступом (высота капсулы) const MANTLE_TIME = 0.4 const MANTLE_EXIT_SPEED = 5.0 const MANTLE_MAX_RISE_SPEED = 3.0 # Хватаемся, когда уже не летим резко вверх # --- Приземление --- const LAND_MIN_SPEED = 7.0 # С какой скорости падения камера «проседает» const HARD_LAND_SPEED = 16.0 # Жёсткое приземление — теряем часть скорости const HARD_LAND_SLOWDOWN = 0.6 # --- Подкат --- const SLIDE_START_SPEED = 15.0 # Минимальная стартовая скорость слайда const SLIDE_START_MULT = 1.25 # Множитель от скорости бега const SLIDE_FRICTION = 6.0 # Чем меньше — тем дальше слайд const SLIDE_END_SPEED = 6.5 # При какой скорости слайд заканчивается const SLIDE_STEER = 1.8 # Насколько можно рулить во время слайда const SLIDE_COOLDOWN = 0.3 const SLIDE_CAMERA_DROP = 0.6 const SLIDE_HEIGHT_FACTOR = 0.5 const CROUCH_SPEED = 2.5 # Ползём, если над головой низкий потолок const CROUCH_WALK_SPEED = 3.5 # Скорость в приседе (держим C; на бегу Shift+C — подкат) const CROUCH_CAMERA_DROP = 0.6 const MIN_SLIDE_SPEED = 7.0 # Минимальная скорость для начала слайда # --- Ступеньки --- const STEP_HEIGHT = 0.45 # Максимальная высота ступеньки, на которую поднимаемся сами const STEP_PROBE = 0.12 # Минимальная дальность проверки препятствия перед собой const STEP_CAMERA_SMOOTH = 14.0 # Плавность камеры при подъёме (больше — резче) # --- Тело (для рейкастов; считаем, что начало координат персонажа у ног) --- const BODY_MID_HEIGHT = 0.9 # Высота «пояса» для поиска стен const WALL_CHECK_DIST = 0.9 # Дальность поиска стен сбоку # --- Камера --- const ZOOM_FOV = 35.0 const ZOOM_SPEED = 10.0 const SPRINT_FOV_BONUS = 7.0 # Расширение FOV на скорости const SLIDE_FOV_BONUS = 13.0 const STRAFE_ROLL = 3.0 # Наклон камеры при стрейфе (градусы) # --- Виньетка (затемнение по краям) --- const ZOOM_VIGNETTE = 0.85 const SLIDE_VIGNETTE = 0.45 const SPRINT_VIGNETTE = 0.2 const VIGNETTE_SPEED = 8.0 # --- Смерть (камень поймал) --- const DEATH_TIME = 2.5 # Сколько лежим перед возрождением const DEATH_CAMERA_HEIGHT = 0.3 # Камера падает почти на землю const DEATH_ROLL = 75.0 # И заваливается набок (градусы) const VIGNETTE_SHADER = """ shader_type canvas_item; uniform float intensity : hint_range(0.0, 1.0) = 0.0; void fragment() { float d = distance(UV, vec2(0.5)); float v = smoothstep(0.25, 0.75, d); COLOR = vec4(0.0, 0.0, 0.0, v * intensity); } """ # Настройки чувствительности мыши # --- Внешние эффекты карт (objects/vent.gd, objects/boost_strip.gd): локально, в сеть не уходят --- const VENT_ACCEL = 80.0 # Подъёмное ускорение в вентиляционном потоке const VENT_MAX_RISE = 16.0 # Поток разгоняет вертикальную скорость не выше этой const BOOST_ACCEL = 60.0 # Разгон на ускоряющей полосе (потолок — MAX_SPEED) const EFFECT_HOLD = 0.12 # Сколько секунд эффект держится после последнего вызова (область зовёт каждый кадр) var vent_timer: float = 0.0 var boost_timer: float = 0.0 var boost_dir: Vector3 = Vector3.ZERO var boost_speed: float = MAX_SPEED @export var MOUSE_SENSITIVITY: float = 0.003 # --- Настройки тряски камеры (Head Bobbing) --- const BOB_FREQ = 2.4 const BOB_AMP = 0.06 var bob_time: float = 0.0 # Состояние подката var is_sliding: bool = false var is_crouching: bool = false var slide_cooldown_timer: float = 0.0 var slide_dir: Vector3 = Vector3.ZERO var slide_speed: float = 0.0 # Прыжок var coyote_timer: float = 0.0 var jump_buffer_timer: float = 0.0 var space_was_down: bool = false var air_jumps_left: int = AIR_JUMPS var wall_jumps_left: int = MAX_WALL_JUMPS # Рывок var is_dashing: bool = false var dash_timer: float = 0.0 var dash_cooldown_timer: float = 0.0 var dash_dir: Vector3 = Vector3.ZERO var dashes_left: int = MAX_DASHES # Бег по стене var is_wallrunning: bool = false var wallrun_timer: float = 0.0 var wallrun_cooldown_timer: float = 0.0 var wall_normal: Vector3 = Vector3.ZERO var wall_side: float = 0.0 # +1 стена справа, -1 стена слева # Подтягивание на уступ var is_mantling: bool = false var mantle_t: float = 0.0 var mantle_start: Vector3 = Vector3.ZERO var mantle_target: Vector3 = Vector3.ZERO var mantle_dir: Vector3 = Vector3.ZERO # Камера var head_base_pos: Vector3 = Vector3.ZERO var head_offset_y: float = 0.0 var land_offset: float = 0.0 var step_offset: float = 0.0 var current_roll: float = 0.0 var shake_strength: float = 0.0 var base_fov: float = 75.0 var is_zooming: bool = false # Виньетка var vignette_material: ShaderMaterial = null var vignette_rect: ColorRect = null var vignette_value: float = 0.0 # Смерть signal died signal respawned var is_dead: bool = false var death_timer: float = 0.0 var spawn_transform: Transform3D var death_overlay: ColorRect = null var death_label: Label = null # Заморозка ввода (режим «Гонка»: отсчёт до старта). Обзор мышью работает, движение и способности — нет. var input_frozen: bool = false # Коллайдер (опционально, ищется по имени CollisionShape3D) var collision_shape: CollisionShape3D = null var capsule: CapsuleShape3D = null var default_capsule_height: float = 0.0 var default_collision_y: float = 0.0 @onready var head: Camera3D = $Camera3D func _ready() -> void: add_to_group("local_player") Input.mouse_mode = Input.MOUSE_MODE_CAPTURED head_base_pos = head.position base_fov = Settings.fov head.fov = base_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 collision_shape = get_node_or_null("CollisionShape3D") if collision_shape and collision_shape.shape is CapsuleShape3D: capsule = collision_shape.shape default_capsule_height = capsule.height default_collision_y = collision_shape.position.y _setup_vignette() _setup_death_screen() spawn_transform = global_transform func _setup_vignette() -> void: var layer := CanvasLayer.new() layer.layer = 10 add_child(layer) var rect := ColorRect.new() rect.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(rect) rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) vignette_rect = rect var shader := Shader.new() shader.code = VIGNETTE_SHADER vignette_material = ShaderMaterial.new() vignette_material.shader = shader rect.material = vignette_material func _unhandled_input(event: InputEvent) -> void: # Esc / Start теперь открывают меню паузы (core/pause_menu.gd), оно само освобождает мышь if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not Net.input_blocked: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # (панель ника открыта — мышь нужна на экране) if is_dead: return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _mouse_look(event.relative) if input_frozen or Net.input_blocked: return if event.is_action_pressed("crouch"): _try_start_slide() # Подкат: бег (sprint) + crouch elif event.is_action_pressed("dash"): _try_dash() # Обзор мышью: смещение в пикселях -> поворот с учётом чувствительности из настроек и зума func _mouse_look(relative: Vector2) -> void: var k := MOUSE_SENSITIVITY * Settings.mouse_sens * head.fov / base_fov _look(relative.x * k, relative.y * k) # Поворот обзора (радианы за событие/кадр); вертикаль можно инвертировать в настройках func _look(dx: float, dy: float) -> void: if Settings.invert_y: dy = -dy rotate_y(-dx) head.rotate_x(-dy) head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80)) # Обзор правым стиком геймпада (плавно, каждый кадр); кривая 1.6 даёт точность у центра и быстрый разворот на краю func _process(delta: float) -> void: base_fov = Settings.fov if is_dead or Net.input_blocked: # открыто меню/панель ника (на отсчёте гонки обзор разрешён, как мышью) return var v := Input.get_vector("look_left", "look_right", "look_up", "look_down") if v == Vector2.ZERO: return var strength := pow(minf(v.length(), 1.0), 1.6) v = v.normalized() * strength var k := Settings.STICK_LOOK_SPEED * Settings.stick_sens * delta * head.fov / base_fov _look(v.x * k, v.y * k) # Битовые флаги состояния для сетевой синхронизации (см. Net.FLAG_*) 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 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: # Действия привязаны к физическим клавишам (работает в любой раскладке); у них же — сенсорные кнопки return Input.get_vector("move_left", "move_right", "move_forward", "move_back") func _physics_process(delta: float) -> void: if is_dead: _process_death(delta) return if input_frozen or Net.input_blocked: # input_blocked — открыта панель ника (core/overlay.gd), не зависит от гонки # Стоим на месте (только падаем, если под ногами пусто) velocity.x = 0.0 velocity.z = 0.0 if not is_on_floor(): velocity += get_gravity() * delta else: velocity.y = 0.0 move_and_slide() return slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0) dash_cooldown_timer = maxf(dash_cooldown_timer - delta, 0.0) wallrun_cooldown_timer = maxf(wallrun_cooldown_timer - delta, 0.0) var space_down := Input.is_action_pressed("jump") _update_crouch() # --- Таймеры прыжка (coyote time + jump buffer) --- if is_on_floor(): coyote_timer = COYOTE_TIME # На земле восстанавливаем все воздушные способности air_jumps_left = AIR_JUMPS dashes_left = MAX_DASHES wall_jumps_left = MAX_WALL_JUMPS if is_wallrunning: _end_wallrun() else: coyote_timer = maxf(coyote_timer - delta, 0.0) if space_down and not space_was_down: jump_buffer_timer = JUMP_BUFFER_TIME else: jump_buffer_timer = maxf(jump_buffer_timer - delta, 0.0) space_was_down = space_down # Ввод var input_dir := _get_input_dir() var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() var has_input := direction != Vector3.ZERO # --- Подтягивание на уступ: полностью забирает управление --- if is_mantling: _process_mantle(delta) _update_camera(delta, input_dir) return # --- Рывок: тоже забирает управление, пока длится --- if is_dashing: dash_timer -= delta var cancel_by_jump := jump_buffer_timer > 0.0 and coyote_timer > 0.0 if dash_timer <= 0.0 or cancel_by_jump: _end_dash() else: velocity = dash_dir * DASH_SPEED _update_camera(delta, input_dir) _move_and_track(delta) return # 1. Гравитация (падение быстрее, короткий прыжок при отпускании пробела) if not is_on_floor() and not is_wallrunning: var g := get_gravity() if velocity.y < 0.0: g *= FALL_GRAVITY_MULT elif not space_down: g *= JUMP_CUT_GRAVITY_MULT velocity += g * delta # 2a. Автоподтягивание: держим пробел в воздухе рядом с уступом if not is_on_floor() and space_down and not is_wallrunning \ and velocity.y < MANTLE_MAX_RISE_SPEED: if _try_mantle(): jump_buffer_timer = 0.0 # 2b. Прыжок if not is_mantling and jump_buffer_timer > 0.0: if is_wallrunning: _wall_jump(wall_normal) elif coyote_timer > 0.0: if (not is_sliding and not is_crouching) or _can_stand(): if is_crouching: _end_crouch() velocity.y = JUMP_VELOCITY jump_buffer_timer = 0.0 coyote_timer = 0.0 if is_sliding: _end_slide() # Прыжок из слайда сохраняет и усиливает скорость velocity.x *= SLIDE_JUMP_BOOST velocity.z *= SLIDE_JUMP_BOOST else: # Мы в воздухе: сначала пробуем оттолкнуться от стены, потом двойной прыжок if wall_jumps_left > 0 and _try_wall_jump(): wall_jumps_left -= 1 elif air_jumps_left > 0 and not (velocity.y < 0.0 and _near_ground()): _air_jump(direction) if is_mantling: _update_camera(delta, input_dir) return # 3. Скорость (Спринт) var current_speed := WALK_SPEED if is_crouching: current_speed = CROUCH_WALK_SPEED elif Input.is_action_pressed("sprint"): current_speed = SPRINT_SPEED # 4. Бег по стене if not is_wallrunning and not is_on_floor() and not is_sliding \ and wallrun_cooldown_timer <= 0.0 and Input.is_action_pressed("move_forward"): _try_start_wallrun() if is_wallrunning: _process_wallrun(delta) # 5. Движение с инерцией var horizontal := Vector3(velocity.x, 0, velocity.z) if is_wallrunning: pass # скорость уже задана в _process_wallrun elif is_sliding: horizontal = _process_slide(delta, direction, horizontal) elif is_on_floor(): var target := direction * current_speed var rate := GROUND_ACCEL if not has_input: rate = GROUND_FRICTION elif horizontal.length() > current_speed: rate = OVERSPEED_DECEL # плавно гасим лишнюю скорость, не рубим резко horizontal = horizontal.move_toward(target, rate * delta) else: # В воздухе импульс сохраняется, можно только подруливать if has_input: var speed_cap: float = maxf(horizontal.length(), current_speed) horizontal += direction * AIR_ACCEL * delta if horizontal.length() > speed_cap: horizontal = horizontal.normalized() * speed_cap # Полоса: разгоняет вдоль своего направления, но не выше потолка MAX_SPEED if boost_timer > 0.0: boost_timer -= delta var hs := horizontal.length() if hs > 3.0 and horizontal.normalized().dot(boost_dir) > 0.5: horizontal = horizontal.normalized() * maxf(hs, minf(hs + BOOST_ACCEL * delta, boost_speed)) # только добирает: быстрее полосы не тормозит if horizontal.length() > MAX_SPEED: horizontal = horizontal.normalized() * MAX_SPEED velocity.x = horizontal.x velocity.z = horizontal.z # Вентиляционный поток: тянет вверх (горизонтальная скорость не трогается), возвращает двойной прыжок if vent_timer > 0.0: vent_timer -= delta velocity.y = minf(velocity.y + VENT_ACCEL * delta, maxf(velocity.y, VENT_MAX_RISE)) air_jumps_left = AIR_JUMPS # 6. Камера _update_camera(delta, input_dir) _move_and_track(delta) # Вызывает область-вентилятор каждый физический кадр, пока игрок в потоке func apply_vent() -> void: vent_timer = EFFECT_HOLD # Вызывает ускоряющая полоса каждый кадр: dir — куда она разгоняет (горизонтально), speed — до какой скорости func apply_boost(dir: Vector3, speed: float) -> void: boost_timer = EFFECT_HOLD boost_dir = Vector3(dir.x, 0.0, dir.z).normalized() boost_speed = minf(speed, MAX_SPEED) # Двигаемся и отслеживаем приземление func _move_and_track(delta: float) -> void: var pre_vy := velocity.y var was_on_floor := is_on_floor() if was_on_floor and velocity.y <= 0.1 and not is_wallrunning: _try_step_up(delta) move_and_slide() if is_on_floor() and not was_on_floor and pre_vy < -LAND_MIN_SPEED: _on_land(-pre_vy) # Автоматический подъём на ступеньки: если перед нами невысокое препятствие, # приподнимаем персонажа ровно настолько, чтобы он встал на его верх. func _try_step_up(delta: float) -> void: var horiz := Vector3(velocity.x, 0, velocity.z) if horiz.length() < 0.5: return var motion := horiz.normalized() * maxf(horiz.length() * delta, STEP_PROBE) var from := global_transform # 1. Перед нами вообще есть препятствие? if not test_move(from, motion): return # 2. Над нами достаточно места, чтобы приподняться? var up := Vector3.UP * STEP_HEIGHT if test_move(from, up): return var raised := from.translated(up) # 3. Если и на высоте ступеньки путь закрыт — это стена, а не ступенька if test_move(raised, motion): return # 4. Опускаемся вниз за препятствием и ищем, на какой высоте там пол var down_hit := KinematicCollision3D.new() var forward_pos := raised.translated(motion) if not test_move(forward_pos, Vector3.DOWN * STEP_HEIGHT, down_hit): return # пола там нет — это обрыв, а не ступенька if down_hit.get_normal().y < cos(floor_max_angle): return # слишком крутая поверхность var rise := STEP_HEIGHT - down_hit.get_travel().length() if rise > 0.01: global_position.y += rise + 0.005 step_offset -= rise # камера плавно догоняет, без рывка func _on_land(fall_speed: float) -> void: # Камера «проседает» сильнее при высоком падении land_offset = -clampf((fall_speed - LAND_MIN_SPEED) * 0.03, 0.05, 0.45) shake_strength = maxf(shake_strength, clampf(fall_speed * 0.004, 0.02, 0.1)) # Жёсткое приземление гасит скорость (если не перешли в подкат) if fall_speed > HARD_LAND_SPEED and not is_sliding: velocity.x *= HARD_LAND_SLOWDOWN velocity.z *= HARD_LAND_SLOWDOWN # --- РАЙКАСТЫ --- func _ray(from: Vector3, to: Vector3) -> Dictionary: var query := PhysicsRayQueryParameters3D.create(from, to, collision_mask, [get_rid()]) return get_world_3d().direct_space_state.intersect_ray(query) func _near_ground() -> bool: var from := global_position + Vector3.UP * 0.1 return not _ray(from, global_position + Vector3.DOWN * 0.8).is_empty() # --- ДВОЙНОЙ ПРЫЖОК --- func _air_jump(direction: Vector3) -> void: velocity.y = AIR_JUMP_VELOCITY air_jumps_left -= 1 jump_buffer_timer = 0.0 # Можно сменить направление полёта прямо в прыжке if direction != Vector3.ZERO: var h_speed: float = maxf(Vector2(velocity.x, velocity.z).length(), WALK_SPEED) velocity.x = direction.x * h_speed velocity.z = direction.z * h_speed shake_strength = maxf(shake_strength, 0.02) # --- РЫВОК --- func _try_dash() -> void: if is_dashing or is_mantling or dashes_left <= 0 or dash_cooldown_timer > 0.0: return var look := -head.global_transform.basis.z var dir: Vector3 if is_on_floor(): # На земле рвёмся по направлению ввода, а без ввода — вперёд var input_dir := _get_input_dir() if input_dir != Vector2.ZERO: dir = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() else: dir = Vector3(look.x, 0, look.z).normalized() else: # В воздухе рвёмся туда, куда смотрит камера dir = look.normalized() if is_sliding: _end_slide() if is_wallrunning: _end_wallrun() is_dashing = true dash_timer = DASH_TIME dash_dir = dir dashes_left -= 1 dash_cooldown_timer = DASH_COOLDOWN shake_strength = maxf(shake_strength, 0.06) func _end_dash() -> void: is_dashing = false velocity = dash_dir * DASH_EXIT_SPEED velocity.y *= 0.6 # не улетаем в небо после рывка вверх # --- БЕГ ПО СТЕНАМ --- func _try_start_wallrun() -> void: var horiz := Vector3(velocity.x, 0, velocity.z) if horiz.length() < WALLRUN_MIN_SPEED or velocity.y < -WALLRUN_MAX_FALL_SPEED: return var origin := global_position + Vector3.UP * BODY_MID_HEIGHT var right := global_transform.basis.x var side := 1.0 var hit := _ray(origin, origin + right * WALL_CHECK_DIST) if hit.is_empty(): side = -1.0 hit = _ray(origin, origin - right * WALL_CHECK_DIST) if hit.is_empty(): return var n: Vector3 = hit.normal if absf(n.y) > 0.3: return # это пол или потолок, а не стена n.y = 0.0 is_wallrunning = true wallrun_timer = WALLRUN_MAX_TIME wall_normal = n.normalized() wall_side = side # На стене восстанавливаем двойной прыжок и рывок — можно цепочкой лететь дальше air_jumps_left = AIR_JUMPS dashes_left = MAX_DASHES func _process_wallrun(delta: float) -> void: wallrun_timer -= delta var origin := global_position + Vector3.UP * BODY_MID_HEIGHT var hit := _ray(origin, origin - wall_normal * (WALL_CHECK_DIST + 0.3)) var horiz := Vector3(velocity.x, 0, velocity.z) if hit.is_empty() or wallrun_timer <= 0.0 or is_on_floor() \ or not Input.is_action_pressed("move_forward") \ or horiz.length() < WALLRUN_MIN_SPEED * 0.6: _end_wallrun() return var n: Vector3 = hit.normal n.y = 0.0 wall_normal = n.normalized() # Направление вдоль стены — туда, куда мы уже двигались var along := wall_normal.cross(Vector3.UP).normalized() if along.dot(horiz) < 0.0: along = -along var spd: float = clampf(maxf(horiz.dot(along), WALLRUN_SPEED), 0.0, MAX_SPEED) velocity.x = along.x * spd - wall_normal.x * WALLRUN_STICK velocity.z = along.z * spd - wall_normal.z * WALLRUN_STICK velocity.y = move_toward(velocity.y, -WALLRUN_SINK, WALLRUN_GRAVITY * delta) func _end_wallrun() -> void: is_wallrunning = false wallrun_cooldown_timer = WALLRUN_COOLDOWN # --- ПРЫЖОК ОТ СТЕНЫ --- func _wall_jump(normal: Vector3) -> void: var n := Vector3(normal.x, 0, normal.z).normalized() var horiz := Vector3(velocity.x, 0, velocity.z) horiz -= n * horiz.dot(n) # убираем составляющую, направленную в стену var new_h := horiz * 0.9 + n * WALL_JUMP_PUSH if new_h.length() > MAX_SPEED: new_h = new_h.normalized() * MAX_SPEED velocity.x = new_h.x velocity.z = new_h.z velocity.y = WALL_JUMP_UP if is_wallrunning: _end_wallrun() wallrun_cooldown_timer = WALLRUN_COOLDOWN + 0.1 dashes_left = MAX_DASHES jump_buffer_timer = 0.0 shake_strength = maxf(shake_strength, 0.04) func _try_wall_jump() -> bool: var origin := global_position + Vector3.UP * BODY_MID_HEIGHT var b := global_transform.basis var dirs: Array[Vector3] = [b.x, -b.x, -b.z, b.z] var best_normal := Vector3.ZERO var best_dist := INF for d in dirs: var hit := _ray(origin, origin + d * WALL_CHECK_DIST) if hit.is_empty(): continue var n: Vector3 = hit.normal if absf(n.y) > 0.3: continue var dist := origin.distance_to(hit.position) if dist < best_dist: best_dist = dist best_normal = n if best_normal == Vector3.ZERO: return false _wall_jump(best_normal) return true # --- ПОДТЯГИВАНИЕ НА УСТУП --- func _try_mantle() -> bool: if is_mantling or is_dashing: return false var fwd := -global_transform.basis.z fwd.y = 0.0 if fwd.length() < 0.01: return false fwd = fwd.normalized() var base := global_position # 1. Перед нами должна быть стена var low_from := base + Vector3.UP * MANTLE_WALL_Y var wall := _ray(low_from, low_from + fwd * MANTLE_REACH) if wall.is_empty(): return false var wn: Vector3 = wall.normal if absf(wn.y) > 0.3: return false # 2. На максимальной высоте перед нами должно быть свободно (значит, там край) var high_from := base + Vector3.UP * MANTLE_MAX_HEIGHT if not _ray(high_from, high_from + fwd * MANTLE_REACH).is_empty(): return false # 3. Ищем верхнюю поверхность уступа var wall_pos: Vector3 = wall.position var top_xz := wall_pos - wn * MANTLE_FORWARD var down_from := Vector3(top_xz.x, base.y + MANTLE_MAX_HEIGHT + 0.3, top_xz.z) var down_len := MANTLE_MAX_HEIGHT + 0.3 - MANTLE_MIN_HEIGHT var ledge := _ray(down_from, down_from + Vector3.DOWN * down_len) if ledge.is_empty(): return false var ln: Vector3 = ledge.normal if ln.y < 0.7: return false # 4. Над уступом должно хватать места, чтобы встать var ledge_pos: Vector3 = ledge.position if not _ray(ledge_pos + Vector3.UP * 0.05, ledge_pos + Vector3.UP * MANTLE_CLEARANCE).is_empty(): return false # Всё подходит — залезаем if is_sliding: _end_slide() if is_wallrunning: _end_wallrun() is_mantling = true mantle_t = 0.0 mantle_start = base # Центр капсулы — на полвысоты над уступом: иначе тело наполовину в уступе и физика выталкивает назад mantle_target = ledge_pos + Vector3.UP * (default_capsule_height * 0.5 + 0.05) mantle_dir = fwd velocity = Vector3.ZERO shake_strength = maxf(shake_strength, 0.03) return true func _process_mantle(delta: float) -> void: mantle_t += delta / MANTLE_TIME var t := clampf(mantle_t, 0.0, 1.0) # Сначала подтягиваемся вверх, затем перекатываемся вперёд на уступ var up_t := 1.0 - pow(1.0 - clampf(t / 0.6, 0.0, 1.0), 2.0) var fwd_t := smoothstep(0.0, 1.0, clampf((t - 0.4) / 0.6, 0.0, 1.0)) global_position = Vector3( lerpf(mantle_start.x, mantle_target.x, fwd_t), lerpf(mantle_start.y, mantle_target.y, up_t), lerpf(mantle_start.z, mantle_target.z, fwd_t)) if mantle_t >= 1.0: is_mantling = false velocity = mantle_dir * MANTLE_EXIT_SPEED dashes_left = MAX_DASHES air_jumps_left = AIR_JUMPS # --- ПОДКАТ --- func _try_start_slide() -> void: if is_sliding or slide_cooldown_timer > 0.0: return if is_dashing or is_mantling or is_wallrunning: return if not is_on_floor(): return if not Input.is_action_pressed("sprint"): return var horizontal := Vector3(velocity.x, 0, velocity.z) if horizontal.length() < MIN_SLIDE_SPEED: return is_crouching = false is_sliding = true slide_dir = horizontal.normalized() slide_speed = clamp(max(horizontal.length() * SLIDE_START_MULT, SLIDE_START_SPEED), 0.0, MAX_SPEED) _set_collider_crouched(true) func _process_slide(delta: float, direction: Vector3, horizontal: Vector3) -> Vector3: # Сорвались с края во время слайда — выходим, если есть место if not is_on_floor() and _can_stand(): _end_slide() return horizontal if slide_speed > SLIDE_END_SPEED: # Лёгкое рулёжка во время слайда if direction != Vector3.ZERO: var new_dir := slide_dir.lerp(direction, SLIDE_STEER * delta) if new_dir.length() > 0.01: slide_dir = new_dir.normalized() slide_speed = move_toward(slide_speed, 0.0, SLIDE_FRICTION * delta) return slide_dir * slide_speed # Слайд выдохся if _can_stand(): _end_slide() return slide_dir * slide_speed # Под низким потолком — ползём, пока не выберемся if direction != Vector3.ZERO: return direction * CROUCH_SPEED return horizontal.move_toward(Vector3.ZERO, CROUCH_SPEED) # --- ПРИСЕД --- # Держим C на земле — приседаем. Встаём, когда отпустили, и над головой есть место func _update_crouch() -> void: var busy := is_sliding or is_dashing or is_wallrunning or is_mantling or not is_on_floor() if not is_crouching: if Input.is_action_pressed("crouch") and not busy: is_crouching = true _set_collider_crouched(true) elif (busy or not Input.is_action_pressed("crouch")) and (is_sliding or _can_stand()): _end_crouch() func _end_crouch() -> void: is_crouching = false if not is_sliding: _set_collider_crouched(false) func _end_slide() -> void: is_sliding = false slide_cooldown_timer = SLIDE_COOLDOWN _set_collider_crouched(false) func _set_collider_crouched(crouched: bool) -> void: if capsule == null: return if crouched: var new_height := default_capsule_height * SLIDE_HEIGHT_FACTOR capsule.height = new_height collision_shape.position.y = default_collision_y - (default_capsule_height - new_height) * 0.5 else: capsule.height = default_capsule_height collision_shape.position.y = default_collision_y func _can_stand() -> bool: if capsule == null: return true var extra := default_capsule_height * (1.0 - SLIDE_HEIGHT_FACTOR) return not test_move(global_transform, Vector3.UP * extra) # --- КАМЕРА И ЭФФЕКТЫ --- func _update_camera(delta: float, input_dir: Vector2) -> void: var h_speed := Vector2(velocity.x, velocity.z).length() var sprint_factor: float = clamp((h_speed - WALK_SPEED) / (SPRINT_SPEED - WALK_SPEED), 0.0, 1.0) # Покачивание только при обычном беге/ходьбе var grounded_walk := is_on_floor() and not is_sliding and not is_mantling and not is_dashing if grounded_walk: bob_time += delta * h_speed else: bob_time = 0.0 var bob := _head_bob(bob_time) if grounded_walk else Vector3.ZERO # Опускание камеры при слайде и подтягивании var target_offset := 0.0 if is_sliding: target_offset = -SLIDE_CAMERA_DROP elif is_crouching: target_offset = -CROUCH_CAMERA_DROP elif is_mantling: target_offset = -0.25 head_offset_y = lerp(head_offset_y, target_offset, 12.0 * delta) # «Проседание» камеры при приземлении land_offset = lerpf(land_offset, 0.0, 9.0 * delta) # Камера плавно догоняет персонажа после подъёма на ступеньку step_offset = lerpf(step_offset, 0.0, STEP_CAMERA_SMOOTH * delta) # Тряска камеры (затухает сама) shake_strength = lerpf(shake_strength, 0.0, 10.0 * delta) var shake_pos := Vector3(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0), 0.0) * shake_strength head.position = head_base_pos + bob + shake_pos + Vector3(0, head_offset_y + land_offset + step_offset, 0) # Наклон камеры: на стене — от стены, иначе — при стрейфе var target_roll := -input_dir.x * deg_to_rad(STRAFE_ROLL) * sprint_factor if is_wallrunning: # Если наклон получился в сторону стены — просто поменяй знак у wall_side target_roll = wall_side * deg_to_rad(WALLRUN_ROLL) current_roll = lerpf(current_roll, target_roll, 8.0 * delta) head.rotation.z = current_roll + randf_range(-1.0, 1.0) * shake_strength * 0.3 # Зум на ЛКМ is_zooming = (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) \ or (Input.is_action_pressed("zoom") and not Net.input_blocked) # FOV: зум > рывок > слайд > стена > скорость бега var target_fov := base_fov + sprint_factor * SPRINT_FOV_BONUS if is_zooming: target_fov = ZOOM_FOV elif is_dashing: target_fov = base_fov + DASH_FOV_BONUS elif is_sliding: target_fov = base_fov + SLIDE_FOV_BONUS elif is_wallrunning: target_fov = base_fov + WALLRUN_FOV_BONUS head.fov = lerp(head.fov, target_fov, ZOOM_SPEED * delta) # Виньетка: зум > рывок > слайд > стена > бег var target_vignette := sprint_factor * SPRINT_VIGNETTE if is_zooming: target_vignette = ZOOM_VIGNETTE elif is_dashing: target_vignette = DASH_VIGNETTE elif is_sliding: target_vignette = SLIDE_VIGNETTE elif is_wallrunning: target_vignette = WALLRUN_VIGNETTE vignette_value = lerp(vignette_value, target_vignette, VIGNETTE_SPEED * delta) if vignette_material: vignette_material.set_shader_parameter("intensity", vignette_value) # Невидимая виньетка не должна стоить заливки всего экрана (важно для веба) vignette_rect.visible = vignette_value > 0.005 func _head_bob(time: float) -> Vector3: var pos := Vector3.ZERO pos.y = sin(time * BOB_FREQ) * BOB_AMP pos.x = cos(time * BOB_FREQ / 2) * BOB_AMP return pos # --- СМЕРТЬ И ВОЗРОЖДЕНИЕ --- func _setup_death_screen() -> void: var layer := CanvasLayer.new() layer.layer = 11 add_child(layer) death_overlay = ColorRect.new() death_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE death_overlay.color = Color(0.6, 0.0, 0.0, 0.0) death_overlay.visible = false layer.add_child(death_overlay) death_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) death_label = Label.new() death_label.text = "ПОЙМАЛ!" death_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER death_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER death_label.add_theme_font_size_override("font_size", 96) death_label.add_theme_color_override("font_color", Color.WHITE) death_label.add_theme_constant_override("outline_size", 12) death_label.add_theme_color_override("font_outline_color", Color.BLACK) death_label.visible = false death_overlay.add_child(death_label) death_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) func die() -> void: if is_dead: return is_dead = true death_timer = DEATH_TIME if is_sliding: _end_slide() if is_crouching: _end_crouch() is_dashing = false is_wallrunning = false is_mantling = false velocity = Vector3.ZERO shake_strength = 0.25 death_label.visible = true death_overlay.visible = true died.emit() func _process_death(delta: float) -> void: death_timer -= delta # Камера падает и заваливается набок, экран краснеет var k := 1.0 - exp(-6.0 * delta) head.position = head.position.lerp(Vector3(head_base_pos.x, DEATH_CAMERA_HEIGHT, head_base_pos.z), k) head.rotation.z = lerpf(head.rotation.z, deg_to_rad(DEATH_ROLL), k) head.fov = lerpf(head.fov, base_fov, k) shake_strength = lerpf(shake_strength, 0.0, 10.0 * delta) death_overlay.color.a = lerpf(death_overlay.color.a, 0.55, k) # Падаем на землю, если умерли в воздухе velocity.x = 0.0 velocity.z = 0.0 if not is_on_floor(): velocity += get_gravity() * delta move_and_slide() if death_timer <= 0.0: _respawn() # Поставить игрока в точку t (лицом по её повороту), сбросив скорость и способности. Для карт-режимов (Гонка). func place(t: Transform3D, pitch := 0.0) -> void: spawn_transform = t if is_dead: _respawn() else: if is_sliding: _end_slide() if is_crouching: _end_crouch() is_dashing = false is_wallrunning = false is_mantling = false global_transform = t velocity = Vector3.ZERO head.rotation.x = pitch func _respawn() -> void: global_transform = spawn_transform velocity = Vector3.ZERO head.position = head_base_pos head.rotation = Vector3.ZERO current_roll = 0.0 head_offset_y = 0.0 land_offset = 0.0 step_offset = 0.0 death_overlay.color.a = 0.0 death_label.visible = false death_overlay.visible = false is_dead = false respawned.emit()