Всем привет! Меня зовут Яков, и я разработчик игр. Возможно, вы играли в мои предыдущие проекты: Dom Rusalok, Loretta и Anoxia Station. Сейчас я заканчиваю работу над новой игрой — Bonereader. И, поскольку я много думаю сейчас о балансе игры, я решил поделиться опытом, в свободной форме порассуждать обо разработке, и дать, надеюсь, полезные советы, которые помогут другим разрабам.

Bonereader — карточная игра с механикой покера на костях. Вас поджаривают на электрическом стуле, и вы оказываетесь в Чистилище, вдохновлённом романами Карлоса Кастанеды и Кормака Маккарти, где вынуждены играть в кости с разными духами за призрачный шанс на перерождение.
В основе механики лежит костяной покер (yatzy). В детстве мы с братом часто играли в одну из его вариаций. Когда работа над Anoxia Station подходила к концу, я, как и многие, увлёкся Balatro. А мне за короткое время нужно было придумать концепт для новой игры. И я вспомнил про покер «на костях».

Я не программист, а врач по образованию. Несмотря на то что занимаюсь этим уже лет восемь. Моя главная проблема в том, что у меня нет систематических знаний и я не знаю ни одного языка программирования, кроме 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"
Вы можете выбрать одну из стартовых колод, содержащую несколько карт‑комбинаций и россыпь разнообразных костей. Ваша цель: набрать необходимое количество очков за ограниченное количество попыток. Всё просто, верно? Ну, не совсем:)

После короткой беседы с тем или иным духом вам открывается магазин, в котором вы можете приобрести предметы и кости, продать или купить карты‑комбинации. У каждого монстра есть «правило дома» — специальное условие, которое необходимо выполнять.


С каждым противником вы играете четыре партии, последняя проходит под случайным «Знамением» — по сути, дебаффом. Наконец, на каждый ход или бросок на случайную карту из вашей руки накладывается случайное: есть как выгодные, так и не очень, так и что‑то между.


Математика подсчёта примерно такая:
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[i] % 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[i] != 0 && dice[i] % 2 == 0) counteven += tattoo_even_bonus; } temp_mult += counteven; } // --- ФИНАЛЬНЫЙ РАСЧЕТ --- var result = (final_score + temp_bonus) * temp_mult;
Короче говоря, мы суммируем значения кубиков, умножаем на уровень карты и накидываем остальные эффекты.

Предметы работают по принципу:
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; }
Конечно, таких функций ещё очень много, но суть у них примерно одинаковая: предметы влияют либо на сумму значений кубиков, либо на множитель.

Баланс
Поскольку в проекте я работаю один, то не веду таблиц (кроме локализации) и документации. У меня есть файл _Balance (раньше в GM это называлось script, но я не хочу никого путать, и когда‑то он мог нести в себе только одну функцию, но сейчас туда можно записывать сколько угодно функций), куда я в виде #macro записал основные показатели: например, базовое количество очков, которое даёт карта, её цена покупки/продажи и так далее.
В игре два режима: сюжетный и роглайк. Если сюжетный более выверен с геймдизайнерской точки зрения — враги идут по очереди, мы примерно представляем себе кривую прогрессии, определяем пул бонусов для того или иного уровня игрока, — то в роглайт‑режиме многое зависит от рандома.

Сет врагов определяется случайно, колоду игрок может взять любую, предметы и награды тоже. Детерминировано, пожалуй, только количество требуемых очков, но и оно может меняться в зависимости от того, взял ли игрок татуировку, уменьшающую очки, или использовал ли предмет для смены дебаффа.
Вот как идёт подсчёт требуемых очков очки для противника. Для своего удобства я использовал термин «анте», который взял из 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 же я гораздо больше потратил времени на создание «ощущения» от карт и кубиков. Их анимаций, наведения, шейдеров для проклятий.

Советы разработчикам
Из раза в раз я создаю определённую подборку советов для себя в будущем и, быть может, для других разработчиков, которые могут найти их полезными. Почти каждый раз я их в той или иной мере нарушаю, но искренне стремлюсь их соблюдать, а потому не стесняюсь повторить!
Если вы тоже хотите создать игру на 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, в которую я приглашаю вас сыграть и поделиться со мной своими мыслями! Мы находимся на финишной прямой. Мне осталось дописать финальные тексты, перевести их на японский и два вида китайского, вставить звуки в концовки, которых в игре будет несколько, к слову. А Даше, моему партнеру и художнице, осталось закончить артбук и несколько больших и не очень артов.

Но сейчас у меня ещё есть время, а глаз, откровенно говоря, замылен. Мне не хватает фидбека от реальных игроков и любителей жанра, потому с нетерпением жду ваши отзывы. А написать вы можете в наш тг или вк, да и просто оставить отзыв в Steam, GOG или Itch.
И спасибо за внимание!:)
PechoraDev
Крутая игрушка, кинул в закладки, на днях затестирую) И это…Удачи вам!
butuzoff_ya Автор
Спасибо-спасибо!