14/09/2020

Dash? Dash… Dash!

О красочном и веселом многопользовательском экшене Flea Madness от небольшой инди-команды Missset мы уже рассказывали. С тех пор у игры и студии дела пошли в гору — их взял под крыло издатель Crytivo, сейчас проект принимает участие в Конкурсе разработчиков на UE4 и продолжает активно продвигаться к релизу. Программист игры — Максим Багинский — рассказывает о реализации одной из задуманных фич — даш-атаки.

* * *

В какой-то момент нам взбрело в голову, что игре просто необходима даш-атака. Я не уверен, общепринятое это в индустрии слово или мы просто договорились так ее называть (скорее всего, второе), но суть в том, что персонаж делает рывок вперед и дамажит тех, кого затрагивает по траектории движения. Ну, тут ситуация: раз уж сильно надо — будем делать. Особого шарма придает необходимость сделать так, чтобы "это все" бегало по сети.

Эволюция подходов. Первая версия

Первая версия была очень примитивна. Персонаж просто ускоряется, при этом, что важно, двигается по земле. В реализации это супер простая механика (по задумке), по факту — копипаста кода спринт:

  • флажок с репликацией IsDashToggled;
  • RPC void DashOnServer(bool Enabled);
  • перегруженный GetMaxSpeed в CharacterMovementComponent, который возвращает скорость в соответствии IsDashToggled в OwnerCharacter.

Налепили, запустили — и увидели, как смешно персонаж перебирает лапками. Для аним блупринта это ничем не отличается от бега. Сразу замечаем, что еще надо бы убрать возможность крутить персонажа во время даш-атаки, чтобы это было движение только по прямой линии. Обтыкиваем костылями — вуаля, даш работает.

В результате получаем:

  • очень быструю реализацию (что особенно важно в случае ограниченных ресурсов и/или тестирования механики);
  • таки работающий, в том числе по сети, даш.

Из негативного:

  • персонаж как-то неправдоподобно движется по кривым поверхностям. Все потому, опять же, что для движка это GroundMovement, хоть и с нестандартно большой скоростью;
  • механика немного топорная и ограничена в использовании.

Само собой, надо прикрутить кулдаун, что и было почти сразу сделано (может, после первого теста). Прикрутили шкалу энергии. Энергия считается на сервере и клиенте. Энергия есть — даш можно активировать. Даш активирован — уменьшается энергия. Энергия заканчивается — даш деактивируется. Учитывая, что IsDashToggled реплицируется с сервера на клиент, даш можно отключить только на сервере. Чтобы компенсировать пинг, отключается и там, и там. В качестве бонуса — сервер остается авторитарным на случай читаков.

Эволюция подходов. Вторая версия

Довольно скоро стало понятно, что существующая механика недостаточно функциональна. В следующей итерации даш должен был лететь в небо в направлении прицела. 

Рецепт приготовления:

  • прицел, который определяет вектор движения;
  • SetMovementMode(MOVE_Flying) — на даш не должно влиять трение поверхности, иначе дальность даша в воздухе и на земле будет разной;
  • движение персонажу задается через Launch или просто импульсом;
  • при деактивации переводим MovementMode в MOVE_Falling и ограничиваем скорость движения к какому-то комфортному значению.

Такой вариант реализации уже значительно лучше: он более гибкий и, собственно, более функциональный. Бонусом получаем отскакивание персонажа от стен, в которые он ударяется, и вообще физическое поведение. Другой бонус — выяснилось, что таким образом значительно (ЗНАЧИТЕЛЬНО) удобнее передвигаться по карте :) После этого следовало много итераций ребаланса, чтобы оставить даш эффективным с одной стороны и избавиться от полётов «джедаев» с другой.

Теперь к негативному:

  • так как сервер авторитарный, появилась потребность в RPC CancelDashOnClient. Не такая уж большая проблема, но лишние вызовы и условия никогда не бывают красивыми;
  • иногда, при странных стечениях обстоятельств, даш не выключался, и персонаж продолжал летать. Это при том, что все необходимые проверки были сделаны (ну, вроде как). Данный баг так и не был пофикшен…
  • ОЧЕНЬ большой проблемой стали пролаги при атаке, когда на клиенте атака заканчивалась, и персонаж начинал падать вниз, включался телепорт вперед/назад, и персонаж заново падал. Стоит отметить, что при росте пинга данный пролаг становился особенно неприятным, а при пинге более 100 мс его использование было проблематичным и не имело смысла.

Последний пункт, а точнее его анализ, заслуживает большего внимания и является ключевым в понимании следующего варианта реализации данной механики (решение двух вечных проблем: "кто виноват" и "что делать" — так вот, это про вторую). Если иметь понимание, как работает UCharacterMovementComponent и motion prediction, то сейчас уже можно догадаться, что пролаги, технически, не были таковыми — это была обычная коррекция движения. Сервер "выполнял" свой вариант даша, который объективно был "немножко другой", и начинался с опозданием, равным величине пинга. Ну, а потом делал коррекцию.

Эволюция подходов. Третья версия

На самом деле, причиной третьего варианта был даже не вышеупомянутый пролаг. Суть в том, что персонаж (его движение) был полностью физическим: он рикошетил не только от стен, в которые ударялся, но и ОТО ВСЕХ МЕЛКИХ ОБЪЕКТОВ, и усложнял использование атаки, которой и без того было сложно в кого-то попасть. С точки зрения правильности, логичности и здравого смысла это именно так, как и должно быть. Но этот случай как раз показательный в том, что "правильно" и "приятно" не всегда сочетаются. По факту, у нас было логичное поведение, но плохой UX.

Для реализации юзер-френдли даш-атаки на каждом тике надо перенаправить персонажа до первоначального вектора движения (а в реализации — до конечной точки). То есть, в предыдущем варианте персонажу задавался импульс с определенным вектором в начале движения. Во втором варианте — на каждом тике задавался импульс, вектор которого был направлен из текущей позиции актора в финальную точку. Собственно, это и есть вся суть нового варианта механики. 

После этого было несколько попыток реализовать эту механику "по старинке", задавая импульс, а затем силу, каждого на каждом тике, использование дэмпинга, чтобы ограничить ускорение и скорость, и подобные костыли. Результат был неудовлетворительный: движение было трудно контролировать (а именно —  настраивать продолжительность, дальность и скорость), движение зависело от FPS, ну и как следствие — еще больше пролагов.

Учитывая все неудачные попытки (и пролаг, который к тому времени уже довольно сильно надоел), было решено реализовать даш атаку как часть функционала MovementComponent’а. Забегая вперед, скажу, что такая реализация является единственно правильной:

  • с точки зрения логики даш — это движение. Соответственно, ему следовало бы хендлится там, где хендлится все остальное движение;
  • коррекцию/предсказания движения (motion prediction) никто не отменял, а оно было очень нужно;
  • для нормального движения, без лишних костылей, надо было просто задавать значение Velocity MovementComponent'a, которое является протектед полем.

Опять же, забегая вперед, в результате были получены одни плюсы:

  • легко настраиваемое движение;
  • юзер-френдли атака;
  • ОТСУТСТВИЕ рывков и пролагов даже при высоком пинге;
  • разобравшись в том, как реализовывать даш, можно реализовать и доджи, почти даром.

Собственно реализация

Для начала перегружаем UCharacterMovementComponent, а также структуры FSavedMove_Character и FNetworkPredictionData_Client_Character. Последняя неинтересна, ее перегрузка нужна только для того, чтобы создать инстанс нашего перегруженного FSavedMove_Character. Эта структура, если не углубляться в детали, является важной частью механизма оптимизации, передачи и коррекции движения. Стоит также заметить, что она не передается непосредственно по сети.

class FSavedMove_FleaCharacter : public FSavedMove_Character {
public:
	typedef FSavedMove_Character Super;
 
	virtual void Clear() override;
	virtual uint8 GetCompressedFlags() const override;
	virtual bool CanCombineWith(...) const override;
	virtual void CombineWith(...) override;
	virtual void SetMoveFor(...) override;
	virtual void PrepMoveFor(class ACharacter* Character) override;
 
	uint32 bSavedWantsToRun:1; // все правильно, спринт имплементирован также через MovementComponent
	uint32 bSavedWantsToDash:1;
	uint32 bSavedWantsToDodge:1;
	uint32 bWasDashing:1;
	uint32 bWasDodging:1;
 
	FVector SavedDashMovementDestination;
	float SavedDashMovementRemainingTime;
};

Метод GetCompressedFlags упаковывает флажки bSavedWantsTo * в одно значение, для передачи по сети. SetMoveFor и PrepMoveFor — фактически сериализация/десериализация. CanCombineWith — можно реализовать в самый топорный способ, а CombineWith НЕ реализовывать вовсе, но для оптимизации все же лучше сделать "как надо".

uint8 FSavedMove_FleaCharacter::GetCompressedFlags() const {
	uint8 Result = Super::GetCompressedFlags();
 
	if (bSavedWantsToRun) {
		Result |= FLAG_Custom_0;
	}
 
	// даш и додж одновременно невозможны, потому сохраняем именно таким способом
	if (bSavedWantsToDash) {
		Result |= (1 << 5);
	} else if (bSavedWantsToDodge) {
		Result |= (2 << 5);
	}
 
	return Result;
}

bool FSavedMove_FleaCharacter::CanCombineWith(...) const {
	if (bWasDashing != ((FSavedMove_FleaCharacter*)&NewMove)->bWasDashing) {
		return false;
	}
 
...
 
	return Super::CanCombineWith(NewMove, Character, MaxDelta);
}
 
void FSavedMove_FleaCharacter::SetMoveFor(...) {
	Super::SetMoveFor(Character, InDeltaTime, NewAccel, ClientData);
 
	if (auto Movement = Character->GetCharacterMovement()) {
		bSavedWantsToRun = Movement->bWantsToRun;
 
		...
	}
}
 
void FSavedMove_FleaCharacter::PrepMoveFor(...) {
	Super::PrepMoveFor(Character);
 
	if (auto Movement = Character->GetCharacterMovement()) {
		Movement->bWasDashing = bWasDashing;
 
		...
	}
}

Само движение базируем на полете и дописываем кусочек кода в перегруженном MovementComponent::PhysFlying.

void UFleaCharacterMovement::PhysFlying(float deltaTime, int32 Iterations) {
	if (DashMovementRemainingTime > 0) {
		float DashTime = FMath::Min(DashMovementRemainingTime, deltaTime);
		float RemainingTime = deltaTime — DashTime;
		Super::PhysFlying(DashTime, Iterations);
 
		DashMovementRemainingTime = DashMovementRemainingTime — deltaTime;
 
		if (DashMovementRemainingTime <= 0) {
			StopDashMovement();
		}
 
		if (RemainingTime > 0) {
			Super::StartNewPhysics(RemainingTime, Iterations);
		}
	} else {
		Super::PhysFlying(deltaTime, Iterations);
	}
}

Когда заканчиваем летать — "Приземляемся" — имеется в виду переход в MOVE_Falling.

void UFleaCharacterMovement::StopDashMovement() {
	DashMovementRemainingTime = 0;
	DashMovementDestination = FVector::ZeroVector;
	Velocity = Velocity.GetClampedToMaxSize(AfterDashSpeed);
	MovementMode = MOVE_Falling;
 
	bWasDashing = false;
	bWasDodging = false;
	bWantsToDash = false;
	bWantsToDodge = false;
}

Ну, и "запуск" происходит в перегруженном PeformMovement.

void UFleaCharacterMovement::PerformMovement(float DeltaTime) {
	if (MovementMode != MOVE_None && !UpdatedComponent->IsSimulatingPhysics()) {
		if (bWantsToDash && MovementMode != MOVE_Flying && !DashMovementDestination.IsZero()) {
 
			bWasDashing = true;
			MovementMode = MOVE_Flying;
			if (DashMovementRemainingTime <= 0) {
				DashMovementRemainingTime = DashDuration;
			}
		} else if (bWantsToDodge && IsMovingOnGround() && Velocity.SizeSquared() > 0) {
 
			bWasDodging = true;
			MovementMode = MOVE_Flying;
			DashMovementDestination = UpdatedComponent->GetRightVector();
			DashMovementDestination *= (DashMovementDestination | Velocity) > 0 ? 1000 : -1000;
			DashMovementDestination += UpdatedComponent->GetComponentLocation();
			if (DashMovementRemainingTime <= 0) {
				DashMovementRemainingTime = DodgeDuration;
			}
		} else if (!bWantsToDodge && bWasDodging) {
			StopDashMovement();
		}
	}
 
	Super::PerformMovement(DeltaTime);
}

Вместо заключения

В сухом остатке, такой вариант реализации стоит того, чтобы инвестировать немного времени в раскуривание UCharacterMovementComponent и реализовать дополнительные режимы как часть Unreal ’ловского механизма репликации движения. Я не даю полные листинги кода, так как для того, чтобы разобраться, все равно надо поковырять код базового компонента, а просто скопипастить НЕ прокатит, потому что, скорее всего, что-то надо будет кастомизировать. 

Рекомендую потестировать движение в режиме эмуляции плохого интернета, введя в консоли: Net PktLoss = 1 + Net PktLag = 300, ну и p.netshowcorrections 1, чтобы понимать, какое безобразие происходит. Обращаю внимание, что важно тестировать не только высокий пинг, но и потерю пакетов. Объективно, движение не может быть супергладким при таких условиях, но оно должно быть стабильным и не вываливаться в эдж кейсы. Даши и доджи должны начинаться и заканчиваться, а персонаж не должен оставаться в режиме неконтролируемого полета навсегда.

В идеале было бы еще хорошо написать, какой юнит-тест, но не в этот раз. Код написан, код работает, атака атакует. Все происходит плавно, без телепортов, рывков и пролагов. При этом игра остается играбельной даже при пинге более 150 мс, а она по своей сути очень динамична.

Недавние статьи

Сценарий вертикального среза

16/09/2020
Алексей Савченко на конкретном примере показывает и рассказывает, как сделать очень важный для разработки игры документ. Образец документа — прилагается.

ON AIR: обратная сторона американских отелей

15/09/2020
Один из победителей гран-при всенародного конкурса разработчиков в прошлом году — хоррор от первого лица ON AIR — решил снова испытать удачу уже на UEDC-2020.

"Науки юношей питают, Отраду старым подают..."

11/09/2020
Философский факультет МГУ, Конкурс разработчиков на UE, Epic Games и долина “Воробьевы Горы” — что объединяет, зачем и как будет осуществляться сотрудничество?