Разтърсването на камерата е най-евтиният начин един удар, експлозия или приземяване да се усетят тежки, и едновременно с това е от първите неща, които се чупят в проект на Godot. Обичайната първа версия всеки кадър слага на камерата случайно отместване. Резултатът прилича на смущение в картината, а не на удар, бори се с кода, който вече движи камерата, и спира напълно в мига, в който добавите кратко замразяване при попадение. В това ръководство изграждаме разтърсване за Godot 4, което избягва и трите проблема, за Camera2D и Camera3D, с код от нашата безплатна добавка за Godot и с числа, измерени на 10 ноември 2026 г. в Godot 4.7.1.
#Защо случайното отместване изглежда грешно
Ново случайно отместване всеки кадър няма приемственост: камерата скача на несвързано място шестдесет пъти в секунда и окото вижда шум, а не движение. Две идеи решават това. Първата е плавният шум: Godot 4 има вграден FastNoiseLite и когато го четем по оста на времето, стойностите се променят плавно от кадър на кадър, вместо да скачат. Ако четем същия шум в отдалечени точки (ние ползваме 0, 1000 и 2000 по втората ос), получаваме независими криви за x, y и завъртането от един обект, а фиксираното начално число на шума прави движението повторимо, което е важно за тестовете и за записаните видеа.
Втората идея е „травмата“, описана в лекцията от GDC 2016 за математиката на камерите. Всеки удар добавя травма, число между 0 и 1, което с времето пада обратно до нула. Видимото разтърсване не е самата травма, а травмата на степен, обикновено 2 или 3. Така лекият удар почти не мести камерата, няколко тежки удара подред я разтърсват силно, а разтърсването заглъхва с мека опашка, вместо да спре рязко.
func add_trauma(amount: float) -> void:
trauma = clampf(trauma + amount, 0.0, 1.0)
func get_shake() -> float:
return pow(trauma, trauma_power)
Ограничението до 1 пази от безкраен растеж, например при пушка, която добавя травма за всяка сачма. При нашето измерване три удара по 0,35 през 0,1 секунди стигнаха травма 0,77, защото всеки удар се ограничава, а затихването продължава да работи между тях.
#Добавяйте разликата, никога не презаписвайте отместването
Повечето уроци присвояват offset направо всеки кадър. В празна сцена това работи, а в истинска игра се чупи, защото отместването на камерата вече върши нещо: гледа напред в посоката на движение, кадрира бос, плъзга се в кътсцена. Ако разтърсването записва отместването, то печели, а кодът на играта губи. Решението: помним какво е добавило разтърсването в предишния кадър и прилагаме само промяната.
func sample() -> Array:
var s := get_shake()
var t := _time * frequency
var off := Vector2(max_offset.x * s * _noise.get_noise_2d(t, 0.0),
max_offset.y * s * _noise.get_noise_2d(t, 1000.0))
var roll := deg_to_rad(max_roll_degrees) * s * _noise.get_noise_2d(t, 2000.0)
return [off, roll]
func step(delta: float) -> void:
if camera == null:
return
var was_shaking := trauma > 0.0
if was_shaking and not _roll_warned:
_warn_if_roll_hidden()
_time += delta
var res := sample()
camera.offset += res[0] - _applied_offset
camera.rotation += res[1] - _applied_roll
_applied_offset = res[0]
_applied_roll = res[1]
trauma = maxf(trauma - decay * delta, 0.0)
if was_shaking and trauma == 0.0:
_clear()
settled.emit()
Понеже разтърсването добавя само собствената си промяна, играта може да премести отместването по средата на разтърсването и промяната ѝ остава. Тестовете на добавката проверяват точно това: камерата започва с отместване (100, 50), играта добавя 10 пиксела по време на разтърсването и след като то утихне, отместването е (110, 50). Когато травмата стигне нула, _clear() връща всичко, което разтърсването още държи, така че камерата спира там, където я е сложила играта, а не на част от пиксел встрани.
Справочникът на Godot посочва разтърсването като едно от предназначенията на Camera2D.offset. Той предупреждава и че отместената камера може да излезе извън границите на превъртане, затова дръжте max_offset малко, ако нивото ви има твърди ръбове.
#Завъртането, което не се вижда
Няколко градуса завъртане добавят много към тежкия удар, но в 2D тук има капан. Camera2D.ignore_rotation по подразбиране е true и тогава изобразеният изглед не следва завъртането на възела. Разтърсване, което добавя завъртане към camera.rotation, променя число в инспектора и нищо на екрана. Ако искате завъртането, задайте ignore_rotation на false за тази камера и проверете дали собственият ви код не я върти по друга причина. Открихме това, докато подготвяхме ръководството: камерата в демото на нашата добавка беше с настройката по подразбиране, затова завъртането в 2D не се виждаше. Версия 2.0.1 поправя демото и предупреждава за скрито завъртане. Отместването не е засегнато.
#Същото разтърсване в 3D
Триизмерната камера има своя двойка отмествания на изгледа. Camera3D.h_offset и v_offset местят изгледа, без да местят възела, точно както Camera2D.offset, така че контролерът на героя или пружинното рамо, което позиционира камерата, продължава да работи. Наклонът, обръщането и завъртането се добавят към ротацията като разлика, както в 2D:
camera.h_offset += off.x - _applied_offset.x
camera.v_offset += off.y - _applied_offset.y
camera.rotation += rot - _applied_rot
В 2D отместванията са в пиксели (по подразбиране 28 на 20 при пълна травма), а в 3D са в метри (0,18 на 0,12), с ъгли от 2, 2 и 4 градуса.
#Разтърсването продължава и при замразяване
Замразяването при удар, тоест спирането на играта за няколко стотни от секундата, когато ударът попадне, обикновено се прави чрез Engine.time_scale. Справочникът на Godot казва, че тази стойност мащабира всяко delta, което получават _process() и _physics_process(), както и таймерите. Разтърсване, което напредва с delta, замръзва заедно със света, точно в момента, в който трябва да се вижда най-добре. Измерихме го: при замразяване от 0,08 секунди с мащаб на времето 0 разтърсването с реалното изминало време измина 21,1 пиксела за пет кадъра, а същото разтърсване с мащабираното delta направи един скок от 1,2 пиксела и застина.
Делението на delta на мащаба на времето, което предлагат повечето публикации, означава деление на нула при пълно замразяване. Добавката сама мери реалното време между кадрите и го ограничава до 0,1 секунди, така че кадър след засичане при зареждане не прескача шума напред. В режима Movie Maker следва часовника на записа, затова записаното видео има същия ритъм като играта на живо:
func unscaled_delta(delta: float) -> float:
if Engine.get_write_movie_path() != "":
if Engine.time_scale > 0.0:
return delta / Engine.time_scale
return 1.0 / float(ProjectSettings.get_setting("editor/movie_writer/fps", 60))
return _real_delta()
func _real_delta() -> float:
var now := Time.get_ticks_usec()
var dt := 0.0
if _last_ticks >= 0:
dt = float(now - _last_ticks) / 1000000.0
_last_ticks = now
return minf(dt, 0.1)
#Замразяване, което връща мащаба на времето
Самото замразяване има свои капани: втори удар по време на първото замразяване не бива да запомни мащаб 0 като този, който трябва да върне, а игра, която вече е в забавен каданс, трябва да си получи забавянето обратно, а не мащаб 1. Функцията по-долу запомня мащаба само когато тръгва от покой, удължава до най-дългото оставащо време, пази най-ниския мащаб и ограничава продължителността:
func freeze(duration: float, time_scale: float = -1.0) -> void:
if time_scale < 0.0:
time_scale = default_scale
duration = minf(duration, max_duration)
if duration <= 0.0:
return
if not _active:
_saved_scale = Engine.time_scale
_scale = time_scale
_active = true
started.emit()
else:
_scale = minf(_scale, time_scale)
_remaining = maxf(_remaining, duration)
Engine.time_scale = _scale
Обратното броене върви по същия реален часовник, така че свършва дори при мащаб 0. Ако предпочитате съвета на документацията мащабът да остане над нула, подайте малка стойност, например 0,05, като втори аргумент: светът пълзи, вместо да спре. Същите идеи в Unity, с неговите капани около корутините, са в нашето ръководство за замразяване при удар и разтърсване на камерата в Unity.
#Числа за начало
Пуснахме 2D разтърсването с фиксирани 60 кадъра в секунда и настройките по подразбиране (затихване 1,4 в секунда, степен 2, начално число на шума 1) и за един удар от всеки размер измерихме колко дълго се движи камерата и най-голямото отместване, което достига. Плавният шум рядко стига крайните си стойности, затова дори пълната травма остава доста под max_offset.
| Травма | Видимо разтърсване | Кадри | Най-голямо отместване (px) |
|---|---|---|---|
| 0,2 | 0,04 | 9 | 0,4 |
| 0,45 | 0,2 | 20 | 2,6 |
| 0,5 | 0,25 | 22 | 3,2 |
| 0,75 | 0,56 | 33 | 7,5 |
| 0,9 | 0,81 | 39 | 10,9 |
| 1 | 1 | 43 | 13,5 |
Удвояването на травмата от 0,45 на 0,9 дава четири пъти по-голямо отместване: това е степенната крива в действие. Леки удари около 0,45 и тежки около 0,9, всеки с кратко замразяване, е точно това, което прави демото на добавката с боксова круша:
func hit(heavy: bool) -> void:
hits += 1
shake.add_trauma(0.9 if heavy else 0.45)
hit_stop.freeze(0.12 if heavy else 0.05)
#Тествайте без екран
Усещането трудно се проверява с поглед, но лесно се проверява със стъпки. Когато auto_process е изключено, тестът движи всеки възел чрез step(delta) с фиксирани стъпки, независимо от честотата на кадрите на машината. Този тест проверява случая със забавения каданс отгоре:
func test_hit_stop_overlap_and_slow_motion() -> void:
Engine.time_scale = 0.5 # game is in slow motion already
var h: Node = add(HitStop.new())
h.auto_process = false
h.freeze(0.05, 0.2)
h.step(0.03)
h.freeze(0.1, 0.05) # longer, stronger hit lands during the first
near(Engine.time_scale, 0.05, 1e-9, "lowest scale wins")
near(h.get_remaining(), 0.1, 1e-9, "remaining extended to the longer freeze")
h.step(0.09)
check(h.is_active(), "still active")
h.step(0.02)
near(Engine.time_scale, 0.5, 1e-9, "restores the earlier slow motion, not 1")
h.freeze(5.0)
near(h.get_remaining(), h.max_duration, 1e-9, "duration capped")
Целият набор, 11 теста с 51 проверки, минава без екран за около една трета от секундата и беше зелен в Godot 4.7.1 в деня, в който писахме това. Ботове, които играят цяло ниво, ползват същата идея в по-голям мащаб, както в статията ни за ботове, които доказват, че всяко ниво може да се мине.
#Използвайте го в своя проект
Целият код по-горе е от Whumpkit Lite, нашата безплатна добавка за Godot 4 с лиценз MIT: възел за разтърсване в 2D, един за 3D и възел за замразяване при удар, всеки се слага в сцената и се задейства с един ред код. Пълното издание, Whumpkit, добавя свиване и разтягане, проблясване при удар, отблъскване и числа за щети; двете са описани на страницата на Whumpkit. За още усещане в браузърните игри вижте как една браузърна игра започва да се усеща добре, а готовите ни игри можете да пробвате в PN Scripts Games.
#Често задавани въпроси
В _process или в _physics_process да върви разтърсването?
В _process, с реалното изминало време. Камерата се рисува веднъж на изобразен кадър, а разтърсване, обновявано с честотата на физиката, накъсва на екрани, по-бързи от физичния такт. Реалното време го държи в движение и докато замразяването сваля мащаба на времето.
Защо Camera2D не се завърта, когато променям ротацията ѝ?
Защото ignore_rotation по подразбиране е true за Camera2D и тогава изобразеният изглед не следва ротацията на възела. Задайте го на false за камерата, която трябва да се завърта.
Могат ли играчите да изключат разтърсването?
Би трябвало да могат. Разтърсването може да причини неразположение, затова предложете настройка от 0 до 100 процента и умножавайте по нея травмата, която добавяте, или max_offset. Стойност нула трябва да махне разтърсването изцяло, а не само да го направи малко.
#Източници
- Godot Engine: справочник за Camera2D (на английски)
- Godot Engine: справочник за Camera3D (на английски)
- Godot Engine: справочник за Engine, time_scale (на английски)
- Godot Engine: справочник за FastNoiseLite (на английски)
- GDC Vault: Math for Game Programmers, Juicing Your Cameras With Math, 2016 (на английски)
- Изходният код на Whumpkit Lite, MIT (на английски)
Коментари
Коментари
Все още няма коментари. Задайте въпрос или споделете какво проработи при вас.
Оставете коментар