Pull to refresh

Использование двух редакторов анимаций в игровом проекте

Reading time14 min
Views3.5K
Два редактора анимации в игровом проекте
Два редактора анимации в игровом проекте

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

Мы – команда энтузиастов из двух человек. Занимаемся созданием игр под мобильные платформы около семи лет в свободное от основной работы время.

Подавляющее большинство наших проектов написано с использованием cocos2d-x. Из них около 90% приходится на старую версию движка - cocos2d-x-2.2.6. Полтора года назад мы решили, создать новый проект - файтинг-платформер максимально возможного для нас качества под персональные компьютеры. И перейти уже на использование новой версии cocos2d-x-3.17.2.

Так как мы ограничены в ресурсах, будь то финансы или время, мы приняли решение, что будем использовать хорошо знакомые нам инструменты для разработки и дальше.

Мы понимаем, что важную роль в восприятии людьми картинки на экране играет плавность анимации и приятная графика. Поэтому игровые персонажи, у которых будут плащи и тому подобные элементы, нам необходимо будет анимировать с использованием mesh-анимации.

В своих проектах мы используем в качестве редактора скелетной анимации CocoStudio 1.2.0.1. Хотя этот программный продукт уже несколько лет не поддерживается, он позволяет создавать хорошие анимации, с использованием точек событий внутри. Данный редактор очень хорошо нам знаком, так как используется более пяти лет.

редактор анимаций CocoStudio
редактор анимаций CocoStudio

Найти в сети его уже вряд ли получится, но если у кого-то есть желание, можно его скачать из облака. Мы храним там все инструменты разработки, которые используем в своей работе.

Для себя мы решили, что бо́льшая часть объектов и персонажей у нас будет создаваться именно в этом редакторе анимации, так как скорость работы в нем и доступный функционал нас устраивает. А для персонажей, которым необходимы будут более продвинутые анимации, мы будем использовать другой редактор.

Редактор анимации Spine мы были вынуждены исключить. Тип лицензии Essential по функционалу ничем не отличается от используемого нами редактора анимации CocoStudio. Покупать лицензию Professional только ради использования mesh-анимации для нас пока дорогое удовольствие.

По этой причине выбор второго редактора анимации пал на Dragonbones.

Он позволяет без проблем экспортировать анимации из CocoStudio. Нам только остается самостоятельно установить точки событий.

Приведу ссылку на DragonBonesC++ RunTime для добавления его в проект на cocos2d-x.

редактор анимаций Dragonbones
редактор анимаций Dragonbones

После выбора второго редактора, дело осталось за малым. Необходимо было обеспечить в игре поддержку нескольких редакторов анимации. Изначально мы думали, что у нас будет поддержка трех редакторов анимации, но от использования Spine, как я написал чуть выше, мы отказались.

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

Так как наш проект содержит элементы файтинга, обязательным для нас условием была возможность использования областей пересечений (hitbox`ов и hurtbox`ов) разных размеров. Кроме этого необходимо иметь точное время действия каждой области, отвечающей за взаимодействие при ударах персонажей.

режим debug
режим debug

При этом дополнительным условием была возможность редактировать размеры и время действия областей пересечений в редакторе анимации. Видеть эти области в режиме отладки и отключать их в обычном режиме.

режим normal
режим normal

Когда мы только начинали разработку универсального класса поддержки нескольких редакторов, я был уверен, что логика работы кода с анимацией будет примерно одинаковая, но впоследствии пришло понимание, что мы немного ошиблись в наших предположениях.

Первое с чем мы столкнулись - разная работа с прозрачностью для bone у этих редакторов анимации. Если bone для анимации в CocoStudio достаточно было сделать прозрачными "на лету" для получения необходимого нам результата.

// функция устанвливает арматуру CocoStudio
void JCAnyArmature::setArmature(cocostudio::Armature *armature)
{
	movementCCSJumpStart_ = armature->getAnimation()->getAnimationData()->getMovement("JUMPSTART");
	boneCCSHitbox_ = armature->getBone("hitbox");
	boneCCSHurtbox0_ = armature->getBone("hurtbox0");
	boneCCSHurtbox1_ = armature->getBone("hurtbox1");

	armature_ = armature;
	armatureType_ = JCArmatureEditor::Type::COCOSTUDIO;

	if (!JCGlobalSetting::getInstance()->isTestFunctions()) 
	{
		boneCCSHitbox_->setOpacity(0);
		boneCCSHurtbox0_->setOpacity(0);
		boneCCSHurtbox1_->setOpacity(0);
	}
}

То для Dragonbones такой вариант не подошел. В этом редакторе прозрачность bone устанавливается в анимации не зависимо, меняли мы ее в редакторе или нет. По этой причине было приято решение, заменять картинки областей контактов пустыми, после создания персонажа в уровне.

// функция устанавливает арматуру Dragonbones
void JCAnyArmature::setArmature(dragonBones::CCArmatureDisplay *armature)
{
	movementDGBJumpStart_ = armature->getAnimation()->getAnimations().find("JUMPSTART")->second;
	boneDGBHitbox_ = armature->getArmature()->getBone("hitbox");
	boneDGBHurtbox0_ = armature->getArmature()->getBone("hurtbox0");
	boneDGBHurtbox1_ = armature->getArmature()->getBone("hurtbox1");

	slotDGBHitbox_ = armature->getArmature()->getSlot("hitbox");
	slotDGBHurtbox0_ = armature->getArmature()->getSlot("hurtbox0");
	slotDGBHurtbox1_ = armature->getArmature()->getSlot("hurtbox1");

	armature_ = armature;
	armatureType_ = JCArmatureEditor::Type::DRAGONBONE;

	if (!JCGlobalSetting::getInstance()->isTestFunctions())
	{
		dragonBones::CCFactory::getFactory()->replaceSlotDisplay("EmptyBox", "emptybox", "emptybox", "emptybox", slotDGBHitbox_);
		dragonBones::CCFactory::getFactory()->replaceSlotDisplay("EmptyBox", "emptybox", "emptybox", "emptybox", slotDGBHurtbox0_);
		dragonBones::CCFactory::getFactory()->replaceSlotDisplay("EmptyBox", "emptybox", "emptybox", "emptybox", slotDGBHurtbox1_);
	}
}

Кроме этого, очередной особенностью работы с Dragonbones явилась необходимость дополнительной утилизации арматуры анимации из пула объектов.

// функция удаляет анимацию из родительской ноды
void JCAnyArmature::removeArmatureFromParent()
{
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		armature_->removeFromParent();
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->setEnabled(false);
		armature_->removeFromParent();
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->dispose();
		break;
	default:
		armature_->removeFromParent();
		break;
	}
}

Логика обработки обратных вызовов при завершении анимации обоих редакторов тоже немного отличается.

// функция устатавливает обработку обратного вызова на завершение анимаций
void JCAnyArmature::setAnimationEventCallFunc(std::function<void(cocostudio::MovementEventType movementType, const std::string &movementID)> listener)
{
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->setMovementEventCallFunc(CC_CALLBACK_0(JCAnyArmature::_onCCSAnimationEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->setEnabled(true);
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->addCustomEventListener(dragonBones::EventObject::COMPLETE, std::bind(&JCAnyArmature::_onDGBAnimationEvent, this, std::placeholders::_1));
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->addCustomEventListener(dragonBones::EventObject::LOOP_COMPLETE, std::bind(&JCAnyArmature::_onDGBAnimationEventLoop, this, std::placeholders::_1));
		break;
	default:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->setMovementEventCallFunc(CC_CALLBACK_0(JCAnyArmature::_onCCSAnimationEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
		break;
	}
	eventAnimation_ = listener;
}
// функция обратного вызова при завершении анимации CocoStudio
void JCAnyArmature::_onCCSAnimationEvent(cocostudio::Armature *, cocostudio::MovementEventType movementType, const std::string& movementID)
{
	this->_onAnimationEvent(movementType, movementID);
}
// функция обратного вызова при завершении конечной анимации Dragonbones
void JCAnyArmature::_onDGBAnimationEvent(cocos2d::EventCustom *event)
{
	const auto eventObject = static_cast<dragonBones::EventObject*>(event->getUserData());
	this->_onAnimationEvent(cocostudio::MovementEventType::COMPLETE, eventObject->animationState->name);
}
// функция обратного вызова при завершении круговой анимации Dragonbones
void JCAnyArmature::_onDGBAnimationEventLoop(cocos2d::EventCustom *event)
{
	const auto eventObject = static_cast<dragonBones::EventObject*>(event->getUserData());
	this->_onAnimationEvent(cocostudio::MovementEventType::LOOP_COMPLETE, eventObject->animationState->name);
}
// функция обрабатывает события анимации и дергает функцию обратного вызова
void JCAnyArmature::_onAnimationEvent(cocostudio::MovementEventType movementType, const std::string &movementID)
{
	if (eventAnimation_)
		eventAnimation_(movementType, movementID);
}

Также есть отличия и в обработке точек событий у CocoStudio и Dragonbones, которые необходимо было учесть.

// функция устатавливает обработку обратного вызова на точки событий анимации
void JCAnyArmature::setFrameEventCallFunc(std::function<void(const std::string &eventName, const std::string &movementID)> listener)
{
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->setFrameEventCallFunc(CC_CALLBACK_0(JCAnyArmature::_onCCSFrameEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4));
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->setEnabled(true);
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getEventDispatcher()->addCustomEventListener(dragonBones::EventObject::FRAME_EVENT, std::bind(&JCAnyArmature::_onDGBFrameEvent, this, std::placeholders::_1));
		break;
	default:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->setFrameEventCallFunc(CC_CALLBACK_0(JCAnyArmature::_onCCSFrameEvent, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4));
		break;
	}
	eventFrame_ = listener;
}
// функция обратного вызова при обработке точек анимации CocoStudio
void JCAnyArmature::_onCCSFrameEvent(cocostudio::Bone *, const std::string &eventName, int, int)
{
	this->_onFrameEvent(eventName);
}
// функция обратного вызова при обработке точек анимаций Dragonbones
void JCAnyArmature::_onDGBFrameEvent(cocos2d::EventCustom* event)
{
	const auto eventObject = (dragonBones::EventObject*)event->getUserData();
	this->_onFrameEvent(eventObject->name);
}
// функция обрабатывает события точек анимации и дергает функцию обратного вызова
void JCAnyArmature::_onFrameEvent(conststd::string&eventName)
{
	if (eventFrame_)
		eventFrame_(eventName, this->getCurrentMovementID());
}

Так как в нашей игре есть превращения одного персонажа в другого, нам необходимо было реализовать это максимально незаметно. Т.е. к примеру, крокодил - Crocco, анимация которого выполнена в CocoStudio, теоретически может превратиться в льва - King`a, анимация которого уже будет выполнена в Dragonbones.

превращение персонажа
превращение персонажа

При этом превращение может произойти в любой момент времени, независимо от того, наносит персонаж удар, отлетает в оглушенном состоянии или стоит задумавшись. Для этого нам необходимо запоминать текущую анимацию персонажа и кадр этой анимации.

// функция запоминает фрейм вопроизводимой анимации 
void JCAnyArmature::rememberAnimationFrame()
{
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		currFrameIndex_ = static_cast<cocostudio::Armature*>(armature_)->getAnimation()->getCurrentFrameIndex();
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		{
			dragonBones::AnimationState *animationState = static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getAnimation()->getLastAnimationState();
			const dragonBones::AnimationData *animationData = animationState->getAnimationData();
			currFrameIndex_ = animationData->frameCount * animationState->getCurrentTime() / animationData->duration;
		}
		break;
	default:
		currFrameIndex_ = static_cast<cocostudio::Armature*>(armature_)->getAnimation()->getCurrentFrameIndex();
		break;
	}
}

А далее для нового персонажа, после его создания, запускать запомненную анимацию с нужного кадра.

// воспроизводит запомненную анимацию персонажа
void JCAnyArmature::playAnimationRemembered()
{
	int currFrameIndex = currFrameIndex_;
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->play(currentMovementID_);
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->gotoAndPlay(currFrameIndex);
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getAnimation()->gotoAndPlayByFrame(currentMovementID_, currFrameIndex_);
		break;
	default:
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->play(currentMovementID_);
		static_cast<cocostudio::Armature*>(armature_)->getAnimation()->gotoAndPlay(currFrameIndex);
		break;
	}
}

И, конечно, в игре необходимо корректно рассчитывать пересечения областей hitbox`ов и hurtbox`ов. Учитывать при этом нужно взаимодействие персонажей с анимациями, имеющих разный размер, а также масштаб уровня момент удара.

взаимодействи областей пересечения персонажей
взаимодействи областей пересечения персонажей

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

// функция возвращает результат пересечения области удара с областью контакта
bool JCAnyArmature::isIntersectsRectBone(const Rect &rect, int boneTag)
{
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		return this->_isCCSIntersectsRectBone(rect, boneTag);
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		return this->_isDGBIntersectsRectBone(rect, boneTag);
		break;
	default:
		return this->_isCCSIntersectsRectBone(rect, boneTag);
		break;
	}
}
// функция возвращает результат пересечения области удара с областью контакта CocoStudio
bool JCAnyArmature::_isCCSIntersectsRectBone(const Rect &rect, int boneTag)
{
	bool result = false;
	cocostudio::Bone *bone = nullptr;

	switch (boneTag)
	{
	case HertboxTag::HURTBOX0:
		bone = boneCCSHurtbox0_;
		break;
	case HertboxTag::HURTBOX1:
		bone = boneCCSHurtbox1_;
		break;
	default:
		bone = boneCCSHurtbox0_;
		break;
	}

	if (!bone)
		return result;

	Node *node = bone->getDisplayRenderNode();

	if (node)
	{
		Node *parallaxNode = armature_->getParent()->getParent();
		float scale = fabs(armature_->getScaleX()) * parallaxNode->getScale();

		Vec2 pos = node->convertToWorldSpaceAR(Vec2(0, 0));
		Size size = node->getContentSize();
		cocostudio::BaseData *baseData = bone->getWorldInfo();
		size.width *= fabs(baseData->scaleX) * scale;
		size.height *= fabs(baseData->scaleY) * scale;

		Rect rectBone = Rect(pos - (size / 2), size);
		if (rectBone.intersectsRect(rect)) 
		{
			result = true;
			this->_calculateIntersectPoint(rectBone, rect);
		}
	}

	return result;
}
// функция возвращает результат пересечения области удара с областью контакта Dragonbones
bool JCAnyArmature::_isDGBIntersectsRectBone(const Rect &rect, int boneTag)
{
	bool result = false;
	dragonBones::Bone *bone = nullptr;
	dragonBones::Slot *slot = nullptr;

	switch (boneTag)
	{
	case HertboxTag::HURTBOX0:
		bone = boneDGBHurtbox0_;
		slot = slotDGBHurtbox0_;
		break;
	case HertboxTag::HURTBOX1:
		bone = boneDGBHurtbox1_;
		slot = slotDGBHurtbox1_;
		break;

	default:
		bone = boneDGBHurtbox0_;
		slot = slotDGBHurtbox0_;
		break;
	}

	if (slot->getDisplay())
	{
		int sign = (armature_->getScaleX() > 0) - (armature_->getScaleX() < 0);
		dragonBones::Transform *transform = bone->getGlobal();
		float scale = fabs(armature_->getScaleX());
		Vec2 pos = Vec2(armature_->getPositionX() + transform->x * scale * sign, armature_->getPositionY() + transform->y * scale);

		Node *parallaxNode = armature_->getParent()->getParent();
		float parallaxScale = parallaxNode->getScale();
		scale *= parallaxScale;
		pos *= parallaxScale;
		pos += parallaxNode->getPosition();

		dragonBones::ImageDisplayData *imageDisplayData = static_cast<dragonBones::ImageDisplayData*>(slot->getRawDisplayDatas()->at(0));
		dragonBones::Rectangle* region = imageDisplayData->getTexture()->getRegion();

		Size size = Size(region->width, region->height);
		size.width *= fabs(transform->scaleX) *scale;
		size.height *= fabs(transform->scaleY) *scale;

		Rect rectBone = Rect(pos - (size / 2), size);
		if (rectBone.intersectsRect(rect))
		{
			result = true;
			this->_calculateIntersectPoint(rectBone, rect);
		}
	}

	return result;
}

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

Engine
Engine

В CocoStudio вопрос решался довольно просто, так как реализация нам давно знакома. Что касается Dragonbones, в демонстрационных примерах можно найти информацию, как заменить готовый слот. Но чтобы добавить новый пришлось разбираться и создать дополнительные функции класса.

// функция создает двигатель
void JCAnyArmature::createEngine(JCEngineInfo &engineInfo, PhysicsBody *physicsBody)
{
	if (engineInfo.isNull())
		return;

	JCEngine *engine = JCEngine::create(engineInfo.fileId, armatureType_);
	switch (armatureType_)
	{
	case JCArmatureEditor::Type::COCOSTUDIO:
		this->_addCCSEngine(engine, engineInfo);
		break;
	case JCArmatureEditor::Type::DRAGONBONE:
		this->_addDGBEngine(engine, engineInfo);
		break;
	default:
		this->_addCCSEngine(engine, engineInfo);
		break;
	}

	engine->setPhysicsBody(physicsBody);
}
// функция добавляет двигатель для анимации CocoStudio
void JCAnyArmature::_addCCSEngine(JCEngine *engine, const JCEngineInfo &engineInfo)
{
	cocostudio::Armature *armature = static_cast<cocostudio::Armature*>(engine->getArmature());
	cocostudio::Bone *boneEngine = cocostudio::Bone::create("engine");
	boneEngine->addDisplay(armature, 0);
	boneEngine->changeDisplayWithIndex(0, true);
	boneEngine->setLocalZOrder(engineInfo.zOrder);
	boneEngine->setPosition(engineInfo.shift);
	boneEngine->setRotation(engineInfo.angle);
	boneEngine->setScale(engineInfo.scale);
	boneEngine->setVisible(true);
	boneEngine->setIgnoreMovementBoneData(true);
	static_cast<cocostudio::Armature*>(armature_)->addBone(boneEngine, "body");
}
// функция добавляет двигатель для анимации Dragonbones
void JCAnyArmature::_addDGBEngine(JCEngine *engine, const JCEngineInfo &engineInfo)
{
	dragonBones::CCArmatureDisplay* armatureEngine = static_cast<dragonBones::CCArmatureDisplay*>(engine->getArmature());
	dragonBones::SlotData *slotData = dragonBones::BaseObject::borrowObject<dragonBones::SlotData>();
	slotData->name = "engine";
	slotData->color = dragonBones::SlotData::createColor();
	slotData->zOrder = engineInfo.zOrder;
	slotData->parent = static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getArmature()->getArmatureData()->getBone("body");

	dragonBones::CCSlot *slotEngine = dragonBones::BaseObject::borrowObject<dragonBones::CCSlot>();
	dragonBones::DBCCSprite *rawDisplay = dragonBones::DBCCSprite::create();

	rawDisplay->setCascadeOpacityEnabled(true);
	rawDisplay->setCascadeColorEnabled(true);
	rawDisplay->setAnchorPoint(cocos2d::Vec2::ZERO);
	rawDisplay->setLocalZOrder(slotData->zOrder);

	slotEngine->init(slotData, static_cast<dragonBones::CCArmatureDisplay*>(armature_)->getArmature(), rawDisplay, rawDisplay);
	slotEngine->offset.x = engineInfo.shift.x;
	slotEngine->offset.y = -engineInfo.shift.y;
	slotEngine->offset.rotation = CC_DEGREES_TO_RADIANS(engineInfo.angle);
	slotEngine->offset.scaleX = engineInfo.scale;
	slotEngine->offset.scaleY = engineInfo.scale;
	slotEngine->setChildArmature(armatureEngine->getArmature());
}

В результате проделанной работы, мы все также имеем возможность быстрого создания анимации различных объектов и персонажей привычным для нас инструментом.

На создание контента для игр уходит очень большое количество времени, которого у нас катастрофически не хватает. Но благодаря такому подходу, мы можем изучать возможности Dragonbones, не снижая при этом нашу продуктивность.

Безусловно, не всем данный подход покажется правильным.

В нашем случае, мы решили, что время, потраченное мной на создание кода, будет намного меньше, чем на изучение Dragonbones с нуля и полный переход на его использование.

Например, при создании прототипа какого-то игрового объекта (персонажа, оружия, бонуса или платформы) мы можем взять анимацию готового объекта, из выпущенной нами ранее игры. Использовать его для реализации необходимого функционала, а далее уже заменить этот объект новым. Другими словами, отказ от использования CocoStudio повлек бы за собой дополнительные затраты времени на конвертирование анимаций из существующей у нас базы, в анимации Dragonbones.

Не смотря на то, что в России и странах СНГ разработчиков игр, использующих cocos2d-x, очень мало, я все же надеюсь, что данная статья окажется полезной коллегам, использующим его в работе, или тем, кто собирает начать им пользоваться.

Я адекватно отношусь к конструктивной критике результатов своего труда. Поэтому обязательно прислушаюсь советам и замечаниям.

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

В завершение приведу ссылку на страницу игры в Steam. В ролике можно увидеть геймплей с реализованным функционалом из статьи.

Ссылка на продолжение

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments5

Articles