|
Professor Seleznov
|
Всем привет! Меня зовут Яков, и я разработчик игр. Возможно, вы играли в мои предыдущие проекты: 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 % 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;
Короче говоря, мы суммируем значения кубиков, умножаем на уровень карты и накидываем остальные эффекты.

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

Всех монстров художница сначала рисует от руки, а затем уже создает digital-версию. Баланс Поскольку в проекте я работаю один, то не веду таблиц (кроме локализации) и документации. У меня есть файл _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. И спасибо за внимание! -Источник
|