Аватарка пользователя Андрей Масленников
Андрей Масленников

Браузерная MMORPG — конкурс пет-проектов

Много лет любил игры. Захотелось сделать что-то своими руками — и получился код браузерной MMORPG.

594
Обложка поста Браузерная MMORPG — конкурс пет-проектов

Привет, меня зовут Андрей и я занимаюсь разработкой уже около 16 лет (если то, что я разрабатывал в 15, можно назвать разработкой).

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

  • Я играю в игры. Кто-то с ностальгией вспомнит как он играл в HoMM 3 или NFS, у меня же в памяти останутся преимущественно онлайн игры, от браузерных (Территория, Бойцовский клуб), до реальных MMORPG (типа LA2 и WoW)
  • Я хочу сам написать игру. На дворе ~2007, я в восторге от браузерок, дядя показал мне что можно делать с PHP (но забыл рассказать про базы данных), и первая попытка сделать что-то своё закончилась на окне персонажа, где я через простой if переключал предметы на разных персонажах и думал, что всё так и работает. Совет себе из будущего:
Сохраняй всё что пишешь, потом можно поржать над тем, что делал давно

Думаю неудивительно, что здесь ничего не вышло.

  • Я пытаюсь запустить готовую игру и доработать её. На 2 или 3 курсе института я начал усиленно гуглить и искать готовые игры (ну хотелось ведь что-то запустить и развивать, а на чисто своё не хватало энтузиазма). В это время мы с друзьями играли в OGame (браузерная игра), и мне удалось откопать движок XNova (не могу найти ссылку на него сейчас), который по сути был клоном OGame. Было решено запустить его самостоятельно, и поуправлять проектом. По итогу на протяжении пары месяцев был онлайн порядка 5–10 человек, потом игру взломали, и на этом всё кончилось =)
  • Читаю об играх, изучаю опенсорс-движки. С работой времени на развлечения стало гораздо меньше, но мечты об игровом проекте никуда не ушли, нашёл утешение в чтении исходников открытых проектов. Наверное, основным, откуда я многое подчерпнул, в том числе для текущего проекта, является CMangos — опенсорс-сервер для WoW. Хотелось бы ещё иметь открытый клиент, но, думаю, мечтам не суждено будет сбыться.
  • Довёл игровой пет-проект до состояния, когда им можно поделиться. Сперва хотелось сделать что-нибудь в режиме реального в 3D, но, потыкавшись по разным сторонам, понял, что для освоения всех тонкостей нужно потратить очень много времени, и лучше взять то, с чем в последнее время много работал.

Как начиналось?

Идея возникла на стыке моего игрового опыта и опыта веб-разработчика. Захотелось сделать что-то своими руками (наконец-то), а поскольку руки приспособлены только под клавиатуру — получился код. Но точно могу сказать, что формат браузерной MMORPG появился после мытарств с C/C++ и 3D (брать Unreal Engine, Unity или любой другой готовый движок не хотелось, причина банальная — хотелось по максимум своё), когда я понял, что туда надо всаживать гораздо больше времени, чем есть, и, может быть, через год-другой что-то и получится. То, что буду делать именно онлайн-игру, также было ясно с самого начала, так как под большим впечатлением от времени, проведённого в онлайн-играх, казалось, что только такие игры и должны быть.

Из того, что сделать в вебе, было несколько идей: от симулятора кота, который терроризирует хозяина (но показалось что тут лучше всё же single в 3D сделать), до переосмысления вышеупомянутой OGame, но потыкавшись по текущим браузерным MMORPG, показалось, что можно попытаться привнести что-то новое (к тому же их несколько и проще было бы найти интересные механики и референсы).

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

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

Базовые механики

Перемещение персонажа

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

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

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

Пример кода, отвечающего за проверку состояния квеста.

			questStatus := models.QuestStatus(item.Field1) // Определяем, какой статус должен быть у квеста

if err := questStatus.Validate(); err != nil { // Валидируем, что это реальный статус, а не кто-то ошибся при заполнении базы данных
  return nil, fmt.Errorf("unable to validate quest status due [%s]", err)
}

questId, err := item.Value1ToInt64() // Извлекаем из базы ID квеста, для которого необходимо проверить статус
if err != nil {
  return nil, fmt.Errorf("unable to extract quest id due [%s]", err)
}

personageQuestStatus, err := c.Services().Quest().PersonageQuestStatus(r.ActorObjectId, questId) // Получаем статус квеста для текущего персонажа
if err != nil {
	return nil, fmt.Errorf("unable to retrieve personage [%d] quest [%d] status due [%s]", r.ActorObjectId, questId, err)
}

passed := personageQuestStatus == questStatus // Если статусы совпадают, то проверка пройдена и действие можно делать дальше
		

Характеристики и предметы

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

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

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

Квесты

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

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

  1. Набор фраз для NPC (для получения квеста, для завершения квеста, для промежуточного состояния квеста). Все их нужно перелинковать между собой и разработать систему, которая будет подставлять определённые реплики в разные моменты времени.
  2. Бонус на выпадение квестовых предметов, с ограничением на них: квест должен быть взят, а предметов в инвентаре должно быть меньше десяти (зачем нам выбивать и плодить больше предметов, чем нужно).
  3. Ограничение на сдачу квеста (должно быть десят квестовых предметов в инвентаре).
  4. Бонус за сдачу квеста.

А если квест подразумевает большое количество таких связей и условий, то это большая и кропотливая работа.

И всё остальное

Если описывать все остальные реализованные возможности игры так же полно, как и предыдущие 3, то будет слишком много текста. Поэтому небольшой список того, что ещё есть:

1. Чат (с сообщениями от пользователей и системными, о разных событиях)

2. Аукцион (с возможность покупки и продажи предметов)

3. Почта (на которую приходят покупки и деньги после завершения сделки на аукционе)

4. Группы (с автоматическим или ручным распределением лута)

5. Подземелья (в которые можно войти только группой с нападением монстров на каждом переходе и босом в конце)

6. События (поэтапные, с появлением новых монстров или добычей специальных предметов и наградами за это)

7. Профессии (добывающие и крафтовые, их можно прокачивать, изучать новые рецепты)

Ну и конечно же, боевая система, которую мне хотелось бы описать наиболее подробно

Боевая система

То, чего хотелось достичь в боевой системе — это некое приближение к привычной мне системе из WoW, а именно: таргетная система боя, использование заклинаний со временем восстановления и позиционирование для избегания различных атак. В первую очередь, я решил посмотреть на реализацию боевых систем в существующих браузерках. По большей части, все они сводились к тому, что система выбирает тебе противника и ты с ним сражаешься. Иногда можно было выбрать цель для специальной способности среди всех противников или союзников. В одной из игр даже была возможность позиционирования, но всё было не тем, чего бы мне хотелось.

В итог, сперва я попробовал сделать 2D-поле, состоящее из квадратов. Вдоволь наигравшись с ним, я понял что визуально мне не очень нравится. После долгих размышлений я вспомнил ещё одну игру, где поле было реализовано с помощью шестиугольников. Реализовал, понравилось, на том и решил остановиться. В кратчайшие сроки я нашёл реализацию поля с шестиугольниками на ThreeJS в 3D, а также очень крутую статью с описанием математики работы с такими полями.

На базе всего вышесказанного удалось создать с помощью ThreeJS и 3D-поле, и добавить на него объекты персонажа и ботов и реализовать управление.

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

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

Код навыков выглядит всегда примерно одинаково (за исключением случаев, когда он не укладывается в существующие реалии), например код навыка «Рывок» представлен ниже.

			func (a *warriorCharge) DoAttack(fight models.Fight, actor, _ models.FightParticipant, positionX, positionY int64) models.SkillEffect {
	effect := effects.MakeEffect() // Вся боёвка построена на генерации эффектов, которые применяются после хода (или с задержкой)
	effect.AddMovement(effects.MakeMovementEffect(actor, positionX, positionY)) // Добавляем эффект перемещения текущего персонажа в указанную точку

	targetHex := models.Hex{X: positionX, Y: positionY}
	currentHex := actor.GetHex()

	path := models.PathFind(currentHex, targetHex, nil) // Строим маршрут (массив из Hex), которые встретятся нам по пути к цели

	var damageSplit []models.FightParticipant

	for _, hex := range path {
		target := fight.TargetOnHex(hex)
		if target == nil { // Если по пути маршрута нет цели, здесь никого нет
			continue
		}

		if target.GetTeam() == actor.GetTeam() { // Мы не наносим урон по своей команде (а можно сделать, что бы наносили)
			continue
		}

		damageSplit = append(damageSplit, target) // Добавляем вражескую цель в список целей, между которыми будет распределён урон

		if checkProbability(20) { // Проверяем вероятность оглушения для этой цели
			effect.AddAura(auras.MakeBase(target, auras.MakeStun(actor, 2, "Сбит с ног"))) // Добавляем оглушение на 2 хода к указанной цели
		}
	}

	if len(damageSplit) > 0 { // Если у нас были вражеские цели по пути
		damageMultiplier := 1.6 / float64(len(damageSplit)) // Делим равномерно урон между ними

		for _, target := range damageSplit {
			effect.AddDamage(effects.MakeDamageEffect(target, makeBaseDamage(actor).multiple(damageMultiplier).int64())) // И применяем его к этим целям
		}
	}

	return effect // Возвращаем эффекты, которые буду дальше обработаны движком боёвки
}
		

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

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

			func (f *fire) DoTurn(_ models.Fight, actor, target models.FightParticipant) models.SkillEffect {
	f.duration-- // Уменьшаем время действия зоны

	effect := effects.MakeEffect()

	if f.duration >= 4 { // Если зона только появилась - ничего не делаем
		return effect
	}

	if target == nil { // Если на зоне никто не находится - ничего не делаем
		return effect
	}

	if target.GetTeam() == actor.GetTeam() { // Если владелец зоны - дружеская цель, ничего не делаем
		return effect
	}

	if f.duration == 3 { // Если зона начала наносить урон, он увеличивается со временем
		effect.AddDamage(effects.MakeDamageEffect(target, f.baseDamage))
	} else {
		effect.AddDamage(effects.MakeDamageEffect(target, f.baseDamage*2))
	}

	return effect
}
		

Любая такая зона может совершать все те же действия, что и бот или персонаж. Она может просканировать поле боя и заспавнить новые зоны, или применить оглушение к стоящему на ней игроку. Путём смены логики, можно наносить урон всем игрокам в определённом радиусе, если на самой зоне никто не стоит.

Ещё одной составляющей боевой системы является, как не трудно догадаться, AI-боты. И в данном случае они просто заскриптован. Для каждого бота вызывается метод DoTurn, который так или иначе вызывает какой-нибудь из навыков и порождает применение эффектов.

Базовый AI позволяет просто преследовать ближайшего игрока, и если радиус навыка позволяет, ударить персонажа этой способностью.

			func (b *base) DoTurn(fight models.Fight, actor, target models.FightParticipant, moveToNearestTarget func() error) error {
	if target != nil { // Если есть цель, которую выбрал движок
		for code, skill := range actor.GetSkills() { // Перебираем навыки бота
			if skill.IsOnCoolDown() { // Если навыки на восстановлении, мы не сможем их использовать
				continue
			}

			return fight.DoAttack(actor, target, code, skill.Skill(), 0, 0) // Если есть навык без восстановления, используем его
		}

		return fight.DoAttack(actor, target, "BOT_BASE_ATTACK", skills.BaseAttack(), 0, 0) // У всех ботов есть автоатака с радиусом = 1
	}

	return moveToNearestTarget() // Если целей нет, нам остаётся только двигаться к ближайшей, но, это поведение можно переопределить для специальных ботов
}
		

Данный AI нужен для самых простых ботов, что бы не разрабатывать AI под каждого, но если речь идёт о сложных механиках, тут не обойтись без описания конкретных взаимодействий. Например, у меня есть реализованное подземелье, в рамках которого есть босс-дракон, он умеет использовать разные способности в зависимости от общего таймера боя (на третьем ходу запустить определённое заклинание) или бот-призыватель, который использует предмет для призывы помощников, в случае если его здоровье опустилось ниже 50%.

			func (s *summoner) DoTurn(fight models.Fight, actor, target models.FightParticipant, moveToNearestTarget func() error) error {
	if !s.summonUsed && isHealthPointPercentLowerThan(actor, 50) { // Если здоровье персонажа меньше
		if err := fight.BotUseEffect(actor.GetId(), ArtifactArtikulSummonTestBandit); err == nil { // Мы используем предмет, применяя эффекты, действующие на него
			s.summonUsed = true // После призыва пропускаем ход, не наносим урон

			return nil
		} else {
			fmt.Printf("bot [%d] unable to use effect [%d] due [%s]\n", actor.GetId(), ArtifactArtikulSummonTestBandit, err)
		}
	}

	if target != nil {
		return fight.DoAttack(actor, target, "BOT_BASE_ATTACK", skills.BaseAttack(), 0, 0)
	}

	return moveToNearestTarget()
}
		

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

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

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

Технологический стек

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

В качестве базы данных взял MySQL, так как давно не с ней не работал (а опыта на Go с ней вообще не было). Это привело к страшным последствия в аукционе, когда мне пришлось составлять фильтрационные запросы в зависимости от наличия в фильтре тех или иных параметров.

			if haveQualityFilter && haveTitleFilter && havePersonageFilter {
  results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE `quality` = ? AND LOWER(`title`) LIKE '%%?%%' AND `personage_id` = ? LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), quality, strings.ToLower(title), personageId, models.AuctionLotsPerPage, offset)
} else if haveQualityFilter && haveTitleFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE `quality` = ? AND LOWER(`title`) LIKE '%%?%%' LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), quality, strings.ToLower(title), models.AuctionLotsPerPage, offset)
} else if haveQualityFilter && havePersonageFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE `quality` = ? AND `personage_id` = ? LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), quality, personageId, models.AuctionLotsPerPage, offset)
} else if haveTitleFilter && havePersonageFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE LOWER(`title`) LIKE '%%?%%' AND `personage_id` = ? LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), strings.ToLower(title), personageId, models.AuctionLotsPerPage, offset)
} else if haveQualityFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE `quality` = ? LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), quality, models.AuctionLotsPerPage, offset)
} else if haveTitleFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE LOWER(`title`) LIKE '%%?%%' LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), strings.ToLower(title), models.AuctionLotsPerPage, err)
} else if havePersonageFilter {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` WHERE `personage_id` = ? LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), personageId, models.AuctionLotsPerPage, offset)
} else {
	results, err = r.db.Query(fmt.Sprintf("SELECT %s FROM `%s` LIMIT ? OFFSET ?", auctionLotFields, auctionLotTableName), models.AuctionLotsPerPage, offset)
}
		

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

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

Одной из основных вещей, которые мне хотелось попробовать при реализации данного проекта, это GRPC, поэтому всё общение (и между микросервисами, и запросы с фронта) происходит через этот протокол. Главным разочарованием для меня стало то, что пакет grpc-web не поддерживает stream’ы, хотя казалось бы, можно это обернуть через веб-сокеты, поэтому пришлось делать свой сервис, который занимался проксированием таких запросов. Мне также не очень нравится, как у меня организовано хранение и распространение артефактов от .proto-файлов, но что с этим делать я пока не знаю.

Выбор для фронта был не очень велик, я неплохо знаю Vue, а моё общение с React не задалось ещё очень давно (когда я не смог развернуть его без create-react-app). Поэтому я выбрал React, в конце концов, все мои пет-проекты так или иначе связаны с познанием чего-то нового. Написание React-приложения с помощью хуков мне зашло куда больше, чем старые классовые компоненты. Я использовал библиотеку MUI для основной части интерфейса, и написанные самостоятельно компоненты для того, чего нет в библиотеке.

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

Всё окружение (включая наполнение БД первичными данными), сейчас поднимается с использованием docker-compose, в котором нет чего бы то ни было интересного.

Статус проекта

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

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

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

594