Разработка карточной игры в Game Maker

Страницы:  1

Ответить
 

Professor Seleznov


Всем привет! Меня зовут Яков, и я разработчик игр. Возможно, вы играли в мои предыдущие проекты: Dom Rusalok, Loretta и Anoxia Station. Сейчас я заканчиваю работу над новой игрой — Bonereader. И, поскольку я много думаю сейчас о балансе игры, я решил поделиться опытом, в свободной форме порассуждать обо разработке, и дать, надеюсь, полезные советы, которые помогут другим разрабам.
pic
Костяной барон
Bonereader — карточная игра с механикой покера на костях. Вас поджаривают на электрическом стуле, и вы оказываетесь в Чистилище, вдохновлённом романами Карлоса Кастанеды и Кормака Маккарти, где вынуждены играть в кости с разными духами за призрачный шанс на перерождение.
В основе механики лежит костяной покер (yatzy). В детстве мы с братом часто играли в одну из его вариаций. Когда работа над Anoxia Station подходила к концу, я, как и многие, увлёкся Balatro. А мне за короткое время нужно было придумать концепт для новой игры. И я вспомнил про покер "на костях".
pic
Вот такое поле мы с братом рисовали, когда играли.
Я не программист, а врач по образованию. Несмотря на то что занимаюсь этим уже лет восемь. Моя главная проблема в том, что у меня нет систематических знаний и я не знаю ни одного языка программирования, кроме GML. Если я нахожу элегантное решение какой-то проблемы в чужом проекте на GitHub, я, конечно же, «заимствую» его, но всегда существенно переписываю. Хотя сейчас в коде для меня нет задачи, которая могла бы поставить меня в тупик. В крайнем случае, я всегда могу обратиться за советом к бесплатной версии Claude.
Пожалуй, единственное, чего я не освоил — сетевой код, но лишь потому, что такая задача никогда передо мной и не стояла. В остальном есть лишь два ограничения: невозможность работать в 3D и фантазия.
Код
По сути, в моём проекте есть три главных объекта: obj_dice, obj_combination и obj_controller, который отвечает за global.game_state (то есть за всё происходящее в игре).
/// [GAME STATES]
/// "start_round"
/// "rolling"
/// "in_play"
/// "tally_score"
/// "finalize_score"
/// "menu_screen"
/// "in_shop"
/// "in_hub"
Вы можете выбрать одну из стартовых колод, содержащую несколько карт-комбинаций и россыпь разнообразных костей. Ваша цель: набрать необходимое количество очков за ограниченное количество попыток. Всё просто, верно? Ну, не совсем
pic
Экран с выбором исходной колоды
После короткой беседы с тем или иным духом вам открывается магазин, в котором вы можете приобрести предметы и кости, продать или купить карты-комбинации. У каждого монстра есть «правило дома» — специальное условие, которое необходимо выполнять.
pic
Реролл магазина такой дорогой из-за выбранной колоды.
pic
На своём пути вы встретите много заблудших душ, некоторые из которых полностью потеряли человеческое обличие.
С каждым противником вы играете четыре партии, последняя проходит под случайным «Знамением» — по сути, дебаффом. Наконец, на каждый ход или бросок на случайную карту из вашей руки накладывается случайное: есть как выгодные, так и не очень, так и что-то между.
pic
Игровой стол у каждого противника тоже свой собственный!
pic
Все виды костей (пока что).
Математика подсчётапримерно такая:
function _check_card_effect_to_final_score(add_to_score_all, card_level) {
var final_score = add_to_score_all;
var temp_mult = 1;
var temp_bonus = 0;
var basepips = ceil(card_base + global.dice_slot1 + global.dice_slot2
+ global.dice_slot3 + global.dice_slot4 + global.dice_slot5);
var base_score_with_pips = basepips;
var item_bonus_score = global.item_bonus_score;
var item_bonus_mult = global.item_bonus_mult;
Затем мы накладываем проклятие карты, если оно есть:
if (ENEMY_EFFECT_CARD_HAND_CURSE) {
final_score = max(0, final_score - (100 * eff_power));
}
Затем проверяем татуировки — перманентные баффы, награды за повышение уровня. Ну и наконец, считаем результат:
if (global.GET_BONUS_TATTOO_ODD_BONUS) {
var countodd = 0;
var dice = [global.dice_slot1, global.dice_slot2,
global.dice_slot3, global.dice_slot4,
global.dice_slot5];
for(var i=0; i<5; i++) { if(dice % 2 == 1) countodd += tattoo_odd_bonus; }
temp_mult += countodd;
}
if (global.GET_BONUS_TATTOO_EVEN_BONUS) {
var counteven = 0;
var dice = [global.dice_slot1, global.dice_slot2,
global.dice_slot3,
global.dice_slot4, global.dice_slot5];
for(var i=0; i<5; i++) { if(dice != 0 && dice % 2 == 0)
counteven += tattoo_even_bonus; }
temp_mult += counteven;
}
// --- ФИНАЛЬНЫЙ РАСЧЕТ ---
var result = (final_score + temp_bonus) * temp_mult;
Короче говоря, мы суммируем значения кубиков, умножаем на уровень карты и накидываем остальные эффекты.
pic
Также в игре есть разнообразные предметы.
Предметы работают по принципу:
if (sprite_index == spr_item_golden_tooth)
{
audio_play_sound(snd_item_golden_thoot, 0, false, global.SOUNDS_VOLUME_MAX)
add_bonus_per_dice_value(6, 10);
ACTIVATED = true
}
Где add_bonus_per_dice_value ():
function add_bonus_per_dice_value(dice_value, bonus_per_die) {
var count = 0;
if (global.dice_slot1 == dice_value) count++;
if (global.dice_slot2 == dice_value) count++;
if (global.dice_slot3 == dice_value) count++;
if (global.dice_slot4 == dice_value) count++;
if (global.dice_slot5 == dice_value) count++;
global.item_bonus_score += count * bonus_per_die;
}
Конечно, таких функций ещё очень много, но суть у них примерно одинаковая: предметы влияют либо на сумму значений кубиков, либо на множитель.
pic
Всех монстров художница сначала рисует от руки, а затем уже создает digital-версию.
Баланс
Поскольку в проекте я работаю один, то не веду таблиц (кроме локализации) и документации. У меня есть файл _Balance (раньше в GM это называлось script, но я не хочу никого путать, и когда-то он мог нести в себе только одну функцию, но сейчас туда можно записывать сколько угодно функций), куда я в виде #macro записал основные показатели: например, базовое количество очков, которое даёт карта, её цена покупки/продажи и так далее.
В игре два режима: сюжетный и роглайк. Если сюжетный более выверен с геймдизайнерской точки зрения — враги идут по очереди, мы примерно представляем себе кривую прогрессии, определяем пул бонусов для того или иного уровня игрока, — то в роглайт-режиме многое зависит от рандома.
pic
Сет врагов определяется случайно, колоду игрок может взять любую, предметы и награды тоже. Детерминировано, пожалуй, только количество требуемых очков, но и оно может меняться в зависимости от того, взял ли игрок татуировку, уменьшающую очки, или использовал ли предмет для смены дебаффа.
Вот как подсчёт требуемых очков очки для противника. Для своего удобства я использовал термин «анте», который взял из Balatro и который и в самом Balatro, на самом деле, означает не своё первое смысловое значение, но мы это опустим.
function calculate_roguelite_level_settings () {
// Текущий анте (противник) - от 0 до 5
var _current_ante = array_length(global.DEFEATED_ENEMIES_CASINO);
// Базовые очки для каждого раунда (уровня)
var _base_points = 0;
switch (global.level) {
case 0: _base_points = LEVEL_ONE_TARGET_POINT; break;
case 1: _base_points = LEVEL_TWO_TARGET_POINT; break;
case 2: _base_points = LEVEL_THREE_TARGET_POINT; break;
case 3: _base_points = LEVEL_BOSS_TARGET_POINT; break;
case 4: _base_points = LEVEL_BOSS_TARGET_POINT; break;
}
var _ante_multipliers = [
1.0,
3.2,
6.0,
10.0,
14.0,
];
// Если антов больше 6, продолжаем увеличивать множитель
var _ante_multiplier = 1.0;
if (_current_ante < array_length(_ante_multipliers)) {
_ante_multiplier = _ante_multipliers[_current_ante];
} else {
// Для антов больше 6: продолжаем экспоненциальный рост
_ante_multiplier = _ante_multipliers[array_length(_ante_multipliers) - 1] *
power(1.6, _current_ante - array_length(_ante_multipliers) + 1);
}
// Рассчитываем финальные очки
global.target_score_base = floor(_base_points * _ante_multiplier);
// Применяем бонус татуировки (-15% к требуемым очкам)
if (global.GET_BONUS_TATTOO_MINUS_10_TARGET_POINTS) {
global.target_score_base = floor(global.target_score_base * 0.85);
}
global.target_score = global.target_score_base;
if (global.BOSS_DEBUFF == "HALF_COMBO" && (global.level == 3))
{
global.target_score = round(global.target_score/3)
}
// Назначаем "Знамение" для финального раунда
if (global.level == 0) {
SET_UP_THE_BOSS_EFFECT();
}
}
Другие технические сложности, вроде смены разрешений, локализации, системы визуальных настроек, системы сохранений, управления геймпадом для меня сегодня уже не представляют такого уж большого вызова. Я использую наработки из предыдущих проектов, оптимизируя под текущий и, по возможности, улучшая.
В Bonreader же я гораздо больше потратил времени на создание "ощущения" от карт и кубиков. Их анимаций, наведения, шейдеров для проклятий.
pic
Моя идея в том, чтобы игрок мог выбирать, как двигаться к огромным числовым значениям. Выборов и синергий масса.
Советы разработчикам
Из раза в раз я создаю определённую подборку советов для себя в будущем и, быть может, для других разработчиков, которые могут найти их полезными. Почти каждый раз я их в той или иной мере нарушаю, но искренне стремлюсь их соблюдать, а потому не стесняюсь повторить!
Если вы тоже хотите создать игру на GM, вот немного сермяжной правды:
  • Используйте минимум шрифтов, при этом по возможности заранее подыскивайте аналоги в CJK-шрифтах, чтобы стиль текста и дизайна игры был похожим для всех игроков.
  • Продумывайте текстурные и аудиогруппы заранее. Вообще, продумывайте архитектуру проекта (систему сохранений, контроллер). Это сэкономит много времени при создании уровней. Учитывайте ограничения выбранного движка и свои навыки ещё на этапе планирования.
  • Создавать игры с поддержкой геймпада выгоднее.
  • Не создавайте «hard-сoded» текст. Используйте .csv-таблицы. Если не знаете, как это сделать, обязательно научитесь.
  • Сохраняйте лицензии на все используемые звуки и музыку. Делайте это сразу, чтобы потом не терять время на поиск источников.
  • Не используйте внутри игры видео. Кодеки — это отдельная головная боль, которая вряд ли стоит результата.
  • По возможности используйте меньше шейдеров. Особенно если вы не пишете их сами, а покупаете или находите и внедряете. Нет гарантии, что шейдер на одной платформе будет работать так же хорошо, как на другой. Keep it simple.
  • Чаще рассказывайте о своей игре. Общайтесь с аудиторией, делитесь процессом и идеями.
  • Я бы советовал держать код для API-разных магазинов в пределах одного участка кода. Ну, к примеру, ачивки я делаю так:
// Achievement #1
function Achievement_Unlock_1()
{
if (IS_DEMO_BUILD) {exit}
switch (os_type)
{
case os_windows:
switch(WINDOWS_TARGET_PLATFORM)
{
case WindowsTargetPlatforms.Steam:
if !steam_get_achievement ("ach_01_win_shaman")
steam_set_achievement ("ach_01_win_shaman");
break;
case WindowsTargetPlatforms.GOG:
if !instance_exists(obj_gog_ach_1)
{instance_create_depth(x, y, -999, obj_gog_ach_1)}
break;
case WindowsTargetPlatforms.EOS:
//
break;
}
break;
case os_ps4:
//
break;
case os_ps5:
//
break;
case os_gdk:
//
break;
}
}
В апреле вышла бесплатная демка Bonereader, в которую я приглашаю вас сыграть и поделиться со мной своими мыслями! Мы находимся на финишной прямой. Мне осталось дописать финальные тексты, перевести их на японский и два вида китайского, вставить звуки в концовки, которых в игре будет несколько, к слову. А Даше, моему партнеру и художнице, осталось закончить артбук и несколько больших и не очень артов.
pic
Но сейчас у меня ещё есть время, а глаз, откровенно говоря, замылен. Мне не хватает фидбека от реальных игроков и любителей жанра, потому с нетерпением жду ваши отзывы. А написать вы можете в наш тг или вк, да и просто оставить отзыв в Steam, GOG или Itch.
И спасибо за внимание! -Источник
 
Loading...
Error