Як я програмував новелу для Micro Visual Novel Jam 2024

У розробників завжди настає момент, коли назріває думка взяти участь в ігровому джемі. Хтось це робить уже маючи певний досвід, щоб бути впевненим у своїх силах, а хтось саме з цього починає повноцінно створювати ігри. Тож не роблячи нічого подібного до цього, я вирішив взяти участь у Micro Visual Novel Jam 2024.

Якщо бажаєте, можете спочатку зіграти в «Сусіднє вікно». Ймовірно, це допоможе краще розуміти подальший текст.

Ремарка. Перш за все, мені хотілося поділитися власним досвідом. І не просто пояснити, як працює код гри, а трохи розповісти як він писався. І саме останнє є дуже важкою задачею. Окрім того, що під час джему ти «заряджений», процес програмування ще й доволі хаотичний. Тоді я не думав, що буду писати цей текст, тож ніяк не готувався до нього. І пишучи його зараз, я намагаюся відтворити все з пам’яті. Зокрема, як змінювалася програма. Тому приклади коду, що ви побачите, будуть тільки з кінцевого варіанту гри.

Як я до цього докотився

Я досліджую ігри й вивчаю ґеймдизайн. Разом з тим веду ютуб-канал GAME DESIGN — мистецтво, де і ділюся частиною свого дослідження. Звісно, як і більшість, я випробовував свої сили в Unity, проте далі гри за підручником від Brackeys і ще пару невеликих спроб діло не пішло. Тож треба було шукати простіші варіанти.

Я знав про джеми, які влаштовував пан Ternox, і хоча мені не до вподоби цей вид інтерактивного мистецтва та ігор, низька складність і перспектива створити щось повноцінне були надто привабливими. І ще я знав, що джем створить достатньо мотивації, щоб довести справу до кінця.

Я маю друга художника, тому про арт можна було вже не хвилюватися. На мене лягало програмування. Маю знання Python, тому це було моєю страховкою, точніше нею був спеціалізований рушій Ren’Py. Проте я геть не хотів його використовувати. Вузька спеціалізованість, обмеженість python (як мови для ігрової розробки) і легкість виклику відвернули мене від «пітона» в бік MonoGame. Якщо ви не чули, що це за бутерброд, то MonoGame є фреймворком для C#, на якому, до речі, були написані Stardew Valley, Celeste, FEZ.

Я майже зробив хромівського динозавра за ютубівським курсом, тож був знайомий з цією бібліотекою. Найголовніше, я уявляв, як можна спрограмувати там гру!

Отже, ми почали чекати, коли буде новий джем. Аж тут несподівано Ternox анонсує Micro Visual Novel Jam 2024. «Мікро — то ще простіше», — подумав я. За два дні, після початку джему, я зустрівся з другом, ми обговорили ідеї й надихнулися. Він малює, я програмую, сценарій якось поділимо. Для мене справді було найголовніше і найцікавіше програмування, тому написання історії пішло на другий план (за що ми потім розплатилися). Тож я почав писати код.

Програмування візуальної новели

Спершу була спроба намалювати схему всіх класів і залежностей в застосунку NClass. Та я швидко збагнув, що мені важко ось так на папері продумати всю програму, тому просто вирішив проєктувати гру у міру написання коду.

Оскільки візуальна новела — це здебільшого слайд-шоу, де кожен «слайд» має свій фон, текст, персонажів, я придумав клас Scena, екземпляр якого і буде відповідати за кожен «слайд». Відтак, сцена малювала фон, персонажа, діалогове вікно і текст. Я намалював затички для всіх цих елементів і запустив гру, в якій була єдина сцена.

Знімок екрану першого тесту новели.

Ремарка. Планувалося, що на екрані буде два персонажі — гравець і ГГ. Це порушення ми збагнули тільки під кінець джему.

Метод Draw класу Scene

Фінальний вигляд:

public void Draw(SpriteBatch spriteBatch)
{
    Color color1 = Color.White;
    Color color2 = Color.White;
    if (_mainCharacter == 2)
    {
        color1 = _colorForSideChar;
    }
    else 
    {
        color1 = _colorForSideChar;
        color2 = _colorForSideChar;
    }
    if (ScenaName == "Scena29" || ScenaName == "Scena31" || ScenaName == "Credits32")
        GraphDevice.Clear(Color.Black);
    else
        spriteBatch.Draw(_backgroundImage, new Rectangle(0 , 0, 1920, 1080), Color.White);
    spriteBatch.Draw(_firstCharacter, _firstCharacterPosition, color1);
    spriteBatch.Draw(_secondCharacter, _secondCharacterPosition, color2);
            
    spriteBatch.Draw(_dialogeBg, _dialogeWindowPos, Color.White);
    _dialogeManager.Draw();
}

Тут, наприклад, перший блок if-else спочатку мав також опцію для значення «1», коли говорить перший персонаж.

А другий блок if-else спочатку малював чорний фон тільки для початкового екрана і титрів, що називалися «Scena0» і «Scena32» відповідно.

На цьому моменті я вже розумів, що потрібно писати менеджер, який відповідатиме за ігровий цикл. Бачив це так: є список всіх сцен, менеджер відмальовує дефолтну сцену, отримає введення від гравця, що визначає наступну сцену. Це значення призначається поточній сцені, менеджер шукає поточну сцену у списку і малює її, потім чекає інпут і по колу. Таку систему я відтворив у формі, яка імітувала роботу фінального її варіанту. Це було потрібно, щоб уже тестувати інший функціонал, як контролер гравця, малювання тексту на екрані тощо.

Тест ось цієї проміжної системи ігрового циклу:

Ранній запис "ігрового процесу"

Тут видно баґ з контролером користувача, але він був дуже швидко пофікшен. А малювання тексту, наразі, було негнучким.

Контролер гравця

public int GetInput(int scenaType)
{
    int returnValue = 2;
    MouseState mouseState = Mouse.GetState();
    _currentButtonState = mouseState.LeftButton;
    if (IsMouseInsideWindow())
    {
        if (scenaType == 2)
        {
            if (_currentButtonState == ButtonState.Pressed && _previousButtonState == ButtonState.Released)
            {
                if (_firstButton.Contains(mouseState.Position))
                {
                    returnValue = 0;
                    ButtonSound.Play(soundVolume, 0f, 0f);
                }
                else if (_secondButton.Contains(mouseState.Position))
                {
                    returnValue = 1;
                    ButtonSound.Play(soundVolume, 0f, 0f);
               }
             }
          }
          else
          {
              if (_currentButtonState == ButtonState.Pressed && _previousButtonState == ButtonState.Released)
              {
                  returnValue = 0;
              }
          }
      }
    _previousButtonState = _currentButtonState;
    return returnValue;
}

Тип сцени визначає чи має вона кнопки. Якщо ні, то зараховується клік в будь-якому місці екрана.

Малювання тексту

На цьому етапі, пам’ятаю, я трохи завис з малюванням тексту. Потрібно було забезпечити йому достатньо місця, рядки не накладалися один на одного тощо. Взагалі менеджер діалогів я оновлював до останнього. Здебільшого через те, що текстура діалогового вікна була не в пріоритеті, тому готувалася останньою й оновлювалася до самого кінця. Тож там я довго мучився, щоб підігнати це все по пікселях. Уже важко детально пригадати, як саме він еволюціонував. Проте можу сказати точно, що його основний принцип був написаний одразу, і я тільки підганяв його під умовності.

public void Draw() 
{
    if (_story != null)
    {
        for (int i = 0; i < _story.Length; i++)
        {
            var act = _story[i];
            if (act.Name == _scenaName)
            {
                _spriteBatch.DrawString(_font, $"{act.Speaker}", _speackerArea, ColorName); //speacker
                if (_scenaName == "Start0" || _scenaName == "Scena29" 
                    || _scenaName == "Scena31" || _scenaName == "Credits32") 
                    DrawOnMiddleScreen(act.Text, ScreenArea);
                else
                    DrawTextInArea(act.Text);
                DrawOnMiddleScreen(act.Button1, _firstButtonArea);
                DrawOnMiddleScreen(act.Button2, _secondButtonArea);
            }
        }
     }
     else
     {
         _spriteBatch.DrawString(_font, "Scena no found", Vector2.Zero, _color);
     }
}

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

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

private void DrawTextInArea(string text)
{
    string[] words = text.Split(' ');
    StringBuilder currentLine = new StringBuilder();
            
    float lineHeight = _font.LineSpacing;
    float yOffset = _textArea.Y;
    foreach (var word in words)
    {
        string testLine = currentLine + word + " ";
        Vector2 size = _font.MeasureString(testLine);
        if (size.X > _textArea.Width)
        {
             _spriteBatch.DrawString(_font, currentLine.ToString(), new Vector2(_textArea.X, yOffset), _color);
             yOffset += lineHeight;
             currentLine.Clear();
             currentLine.Append(word + " ");
         }
         else
         {
              currentLine.Append(word + " ");
         }  
    }
    if (currentLine.Length > 0)
    {
        _spriteBatch.DrawString(_font, currentLine.ToString(), new Vector2(_textArea.X, yOffset), _color);
    }
}

private void DrawOnMiddleScreen(string text, Rectangle area) 
{
    string[] strs = text.Split(new[] {"\\n"}, StringSplitOptions.None);
    float lineHeight = _font.LineSpacing;
    for (var i = 0; i < strs.Length; i++)
    {
        Vector2 size = _font.MeasureString(strs[i]);
        _spriteBatch.DrawString(_font, strs[i], 
        new Vector2(area.X + (area.Width / 2) - (size.X / 2), 
                    area.Y + (area.Height / 2) - (lineHeight * strs.Length / 2) + i * lineHeight), _color);
    }
}

Тож як я запхнув сценарій у гру

На моменті, коли я робив імітацію цієї системи, я вже припускав, якою вона буде в повноцінному варіанті. Розраховував, що це має бути якийсь файл зі всіма діалогами, UI текстом, що позначені унікальними ключами, за якими можна буде розрізняти сцени. Наприклад, простий текстовий файл. Та коли справа дійшла до реалізації, швидко збагнув що парсинг і конвертація txt даних дуже не зручна.

Тож треба було шукати готові рішення. І вони звісно були — вбудовані в бібліотеку. MonoGame Content Pipeline може перетворювати дані з XML-файлу в спеціальний формат, з яким ви уже можете легко взаємодіяти в коді. Пояснити це важко, тому, якщо бажаєте, можете прочитати коротку документацію.

Це був XNL-файл з будь-якою потрібною тобі структурою, для якого писався відповідний клас. До кожного елементу файлу можна звертатися по індексу, а до їхнього вмісту, як до властивості. Для тестування я накидав приблизний текст кількох сцен.

<?xml version="1.0" encoding="utf-8"?>
<XnaContent>
    <Asset Type="XMLData.ActData[]">
        <Item>
            <Name>Scens0</Name>
            <Speaker> </Speaker>
            <Text>Натисніть ліву кнопку миші, щоб почати</Text>
            <Button1> </Button1>
            <Button2> </Button2>
        </Item>
        <Item>
            <Name>Scena1</Name>
            <Speaker> </Speaker>
            <Text>Впізнаваний шурхіт донісся з вікна сусідньої квартири. Прислухавшись, ви почули знайоме дихання... Тендітне зітхання, що пронизане розпачем.</Text>
            <Button1>*Підійти до вікна*</Button1>
            <Button2> </Button2>
        </Item>
        <Item>
            <Name>Scena2</Name>
            <Speaker> </Speaker>
            <Text>Це Софія, дівчина з вашої школи. Придивившись, ви помічаєте її червоні та водночас сухі очі. Вона сумна.</Text>
            <Button1>- Чого не спиш?</Button1>
            <Button2> </Button2>
        </Item>
        <Item>
            <Name>Scena3</Name>
            <Speaker>Софія</Speaker>
            <Text>- ТИ Ж ЗНАЄШ, ЩО ВОНИ СЬОГОДНІ ЗНОВУ ЦЬКУВАЛИ МЕНЕ ЧЕРЕЗ ТІ КЛЯТІ ФОТО!!!</Text>
            <Button1>- Так, вибач...</Button1>
            <Button2>*Промовчати*</Button2>
        </Item>
    </Asset>
</XnaContent>

Клас для нього:

public class ActData
{
    public string Name {  get; set; }
    public string Speaker { get; set; }
    public string Text { get; set; }
    public string Button1 { get; set; }
    public string Button2 { get; set; }
}

І ось тут уже була зроблена основа системи ігрового циклу.

Ігровий цикл

В головному класі гри, що стоврюється за замовчуванням, оголошуємо змінну для наших користувацьких даних (ті, що з XML) ActData[] _story. А також ще пару полів:

private Scena[] _scenes;
private Dictionary<string, int[]> _storyline = new();

1. Масив усіх сцен.

Scena scena0 = new(_spriteBatch, _menuBG, _emptySprite, _emptySprite, _emptySprite, 0, "Start0", 1, _firstButtonArea, _secondButtonArea, _story, _font, Color.White);
Scena scena1 = new(_spriteBatch, _background, _dialogeBg1, _emptySprite, _character2, 0, "Scena1", 2, _firstButtonArea, _secondButtonArea, _story, _font, Color.Black);
...
Scena scena8 = new(_spriteBatch, _background, _dialogeBg1, _emptySprite, _character2, 2, "Scena8", 2, _firstButtonArea, _secondButtonArea, _story, _font, Color.Black);
Scena scena9 = new(_spriteBatch, _background, _dialogeBg0, _emptySprite, _character2, 1, "Scena9", 1, _firstButtonArea, _secondButtonArea, _story, _font, Color.Black);
...
Scena scena32 = new(_spriteBatch, _background, _emptySprite, _emptySprite, _emptySprite, 0, "Credits32", 1, _firstButtonArea, _secondButtonArea, _story, _font, Color.White);

Конструктор отримує екземпляр вбудованого класу SpriteBatch для малювання текстур; картинку фону; текстуру діалогового вікна; спрайт для першого персонажа; спрайт для другого персонажа; значення, що показувало, який персонаж говорить; назву сцени; значення, що відповідало за наявність кнопок; прямокутники для кнопок; екземпляр класу для xml файлу; шрифт і колір для тексту.

Всі наші ствоерні сцени закидуємо в масив:

_scenes = new Scena[]
{
    scena0, scena1, scena2, scena3, scena4, scena5, scena6, scena7, scena8, scena9, scena10, 
    scena11, scena12, scena13, scena14, scena15,scena16, scena17, scena18, scena19, scena20, 
    scena21, scena22, scena23, scena24, scena25, scena26, scena27, scena28, scena29, scena30,
    scena31, scena32
};

2. Словник, в якому визначається послідовність сцен.

_storyline.Add("Start0", [1, 0, 0]);
_storyline.Add("Scena1", [2, 1, 1]);
_storyline.Add("Scena2", [3, 2, 2]);
...
_storyline.Add("Scena30", [31, 30, 30]);
_storyline.Add("Scena31", [32, 31, 31]);
_storyline.Add("Credits32", [0, 0, 32]);

Ключ — назва сцени. Значення — масив: [ індекс наступної сцени для першої кнопки, індекс наступної сцени для другої кнопки, індекс поточної сцени (для страховки) ]

Update() класу ScenaManager

public void Update()
{
    if (_currentScena.ScenaName == "Scena31" && _voicePlayOnce == false)
    {
        if (_song.State == SoundState.Playing) 
            _song.Stop();
        VoiceAudio.Play();
        _voicePlayOnce = true;
    }
    if (_currentScena.ScenaName == "Start0")
    {
         _voicePlayOnce = false;
         if (_song.State == SoundState.Stopped)
         {
            _song.Resume();
         }
     }
     _nextScena = _playerInput.GetInput(_currentScena.ScenaType);
     _currentScena = _scenes[_storyline[_currentScena.ScenaName][_nextScena]];
}

Draw() класу ScenaManager

public void Draw() 
{
    _currentScena.Draw(_spriteBatch);
}

Ці методи викликаються у відповідних методах класу Game1.

Ось запис ще одного тестування:

Тест новели

Виправлення багів, полішинг т.д.

До вечора п’ятниці все працювало більш менш. Залишалося тільки отримати готові текстури, і побачити, якою вийшла гра. Я намалював схематичне діалогове вікно і намагався підігнати під нього написання тексту на екрані.

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

Більшу частину суботи я був зайнятий іншими справами. Але в перервах біг виправляти код. Зранку зловив дивний баг. При додавані звуку в гру, вона не запускалася. Навіть просто ініціалізація змінної для звукового ефекту, не давала запустити гру навіть у режимі дебаґінґа. Тож весь день мені не давали спокою роздуми про цю проблему. З 16 години й до ночі я намагався знайти віришення цього багу.

Було прикро віддати тиху гру. Музика і звуковий ефект були критичним плюсом, яких і так не багато в нашій грі. Отже, о першій годині ночі неділі я запустив код на іншому, не моєму ноутбуці. Цей унікальний баг був повʼязаний з моєю системою (а точніше з профілями і їхніми правами), як я визначав пізніше. Тому інший пристрій було єдиним рішенням, яке я побачив. Це звісно сильно уповільнювало роботу, але я хоч зміг її продовжити.

У неділю зациклив музику, художник перемалював діалогове вікно, я підігнав текст по пікселях. Також була написана нова мелодія.

Увечері оформив сторінку на itch.io, і гра була опублікована. Десь до кінця часу подачі ігор ми і помітили, що схоже порушуємо правила. Був і сум, і злість, і розчарування. В результаті хлопець перетоврився на чорний силует.

Але в понеділок, коли, як виявилося, була ще можливість оновлювати збірку, я остаточно прибрав хлопця. Це звісно дуже нас засмутило, оскільки без нього на екрані стало пусто. Щоб якось це компенсувати, замість чорного стартового екрану, я зробив красивіше меню.

Як же працює гра

Слідкуйте за пальцями!

Game1.Draw() викликається регулярно. В ньому викликається _scenaManager.Draw(), а в ньому _currentScena.Draw(_spriteBatch), що малює вже всі елементи гри, зокрема текст. Метод Draw() класу DialogeManager шукає текст за ім’ям своєї сцени, і виводить його в потрібних місцях.

Game1.Update() також викликається через регулярний інтервал, щоб оновити стан гри. Це стається за допомгою рядка _scenaManager.Update(). Там у змінну _nextScena записуєтсья значення, що повертає _playerInput.GetInput(_currentScena.ScenaType) — 0 або 1. Це значення вказує на ідекс для масиву, до якого можна звертатися по назві поточної сцени в нашому словнику. Пам’ятаєте _storyline.Add("Start0", [1, 0, 0])? Елемнти цього масива показують індекс наступної сцени в масиві всіх сцен __scenes. Отже наступна сцена призначаєтсья поточній сцені, щоб уже викликався її метод Draw(). Все це відбувається в цьому рядку: _currentScena = _scenes[_storyline[_currentScena.ScenaName][_nextScena]];

Заключні слова

Час був дуже обмежений. Але ми це зробили. Уже зараз я бачу, як можна покращити процес розробки, що можна реалізувати ще, як по-іншому зробити деякі елементи. Проте я хочу почути і вас! Ваші поради. Поки що у мене немає ідей як замінити те, що робить словник _storyline і масив __scenes. Звісно, щоб зрозуміти як працює програма, треба дивитися на весь код і відслідковувати кожен рядок. Сподіваюся, хоч щось мені вдалося описати зрозуміло. Може це було принаймні цікаво читати? :)

Чи є ще розробники, які користуються MonoGame? Відгукніться тут. Мені симпатизує цей фреймворк. І я хотів би зробити власний внесок в українську спільноту MonoGame.

Дякую, що прочитали до кінця! До зустрічі.

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
LinkedIn


Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Радий, що ти все ж написав і обрав для цього GDD

Дуже цікаво! Побільше б такого контенту)

Радий, що вам сподобалося!

Підписатись на коментарі