Пишемо текстову гру на Python/Ren'Py ч. 2: міні-ігри і підводні камені

Короткий зміст попередніх двадцяти п'яти тисяч серій: ми пишемо текстову гру про плавання по морю і ввязывание в історії. Пишемо ми її на движку Ren'Py, який спочатку призначений для візуальних новел, але після мінімальної налаштування здатний робити все, що нам треба. У минулій статті я розповів, як зробити простеньку інтерактивну читалку, а в цій ми додамо ще пару екранів з більш складними функціями і нарешті попрацюємо на пітоні.
image


Минула стаття закінчилася на тому, що гравець може читати текст, дивитися картинки і впливати на розвиток сюжету. Якби ми збиралися перенести книги-ігри Браславського або писати CYOA в дусі Choice of Games – цього б вистачило. Але в грі про мореплавство потрібен хоча б простенький інтерфейс власне мореплавства. Зібрати повноцінний симулятор, як в Sunless Sea, в Ren'Py можна, але трудомістке і в цілому безглуздо. До того ж ми не хочемо зробити своє Sunless Sea з бурятками і буузами, тому обмежимося чимось на зразок глобальної карти в «Космічних Рейнджерах»: десяток-інший активних точок, з кожною з яких можна відправитися в кілька сусідніх. Приблизно ось так:

image

Displayables, екрани і шари



Для самого інтерфейсу нам потрібно просто вивести на екран купу кнопок (по одній на кожну точку) та фоновий малюнок; для цього вистачить однієї Displayable типу Imagemap. Принципово вона не сильно відрізняється від свого аналога в HTML: це картинка, на якій визначені активні зони, кожна зі своїм дією. Однак працювати з голою Displayable незручно, так і з архітектурної точки зору це негарно. Тому, перш ніж братися за код, варто розібратися з ієрархією елементів, які Ren'Py виводить на дисплей.

Самий нижній рівень – це Displayables, тобто віджети, вбудовані або створені розробниками конкретної гри. Кожен з них виконує якусь базову функцію: показує картинку, виводить текст, забезпечує введення і т. п. Displayables зазвичай групуються в екрани, які забезпечують вже більш абстрактні взаємодії: наприклад, висновок тексту і внутрішньоігрові меню виконуються екраном nvl, який містить власне текст, фон, рамку і кнопки меню. Одночасно можна показувати скільки завгодно екранів: на одному, наприклад, може бути текст, на іншому кнопки, що регулюють гучність музики, а на третьому якісь літаючі поверх усього цього сніжинки. По мірі необхідності можна показувати або прибирати окремі екрани з міні-іграми, меню збережень і всім іншим, що потрібно для гри. І, нарешті, існують шари. Працювати безпосередньо з шарами потрібно нечасто, але знати про їх існування необхідно. Фоновий малюнок, наприклад, не має відношення до системи екранів і виводиться нижче них в шарі master.

Отже, опис екрану подорожей виглядає наступним чином:

screen map_screen():
tag map 
modal True 
zorder 2 
imagemap: 
auto 'images/1129map_%s.png' 
# Main cities 
hotspot monet.hotspot action Travel(monet) 
hotspot tartari.hotspot action Travel(tartari) 
# ...
# More of the same boilerplate
# ...
add 'images/1024ship.png': 
at shiptransform(old_coords, coords) 
anchor (0.5, 1.0) 
id 'ship'
transform shiptransform(old_coords, coords): 
pos old_coords 
linear 0.5 pos coords 


Спершу оголошується екран map_screen(). Тег опціональний; він просто дозволяє групувати екрани для команд типу «Прибрати всі екрани, пов'язані з переміщенням». Zorder – це «висота» екрану, щодо якої вирішується, які елементи будуть затуляти один одного. Оскільки ми поставили zorder=2, а біля екрану nvl дефолтний zorder=1, під час подорожі гравець не буде бачити вікно з текстом. Але найголовніше – цей екран модальний, тобто ніякі елементи нижче нього не будуть отримувати події вводу. Виходить, що екран nvl з появою картки блокується, тому нам не потрібно самим відслідковувати game state і стежити, щоб клік по карті заодно не проциндрив текст.

Сам Imagemap складається з двох основних елементів: тега auto і списку активних зон. Auto містить посилання на набір файлів, які будуть використовуватися в якості фону. Саме набору, а не єдиної картинки: окремо лежить файл, в якому всі кнопки намальовані як неактивні, окремо – в якому всі вони натиснуті і так далі. А Ren'Py вже сам вибирає потрібний фрагмент з кожного файлу і складає картинку на екрані. І, нарешті, активні зони (hotspot). Вони описуються кортежем з чотирьох цілих чисел (координати і розмір) і об'єктом дії. Кортеж ми не хардкодим, а використовуємо атрибут об'єкта; в описі екрана замість значення завжди можна вставити змінну або атрибут. Об'єкт дії описує, що відбудеться після натискання кнопки і контролює, повинна бути кнопка активна в даний момент. Ren'Py надає досить багато вбудованих дій для рутинних завдань типу переходів за сценарієм або збереження гри, але можна зробити і свій. Нарешті, останнім додається малюнок корабля і трансформація, завдяки якій він не просто виводиться на екран, а повзе з точки А в точку Б. Трансформації описуються окремою мовою ATL (Animation & Transformation Language).

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

Граємо в карти



В попередній статті я обіцяв показати два способи робити інтерфейси в Ren'Py. Перший ми тільки що побачили: він досить простий, але аж ніяк не універсальний. Я так і не зрозумів, наприклад, на проксі мовою описати перетягування об'єктів мишею і обробляти колізії. Або як верстати екрани, якщо кількість елементів на них визначається в ході гри. На щастя, Displayables – це повноцінні віджети: вони можуть включати в себе інші Displayables, ловити події вводу і при необхідності змінюватися. Отже, можна описати в одній Displayable всю міні-гру практично так само, як ми б зробили для окремого додатка, і вставити в проект.

Міні-гра у нас буде карткова. Вся потенційно корисна власність і пізнання головного героя представлені у вигляді колоди карт номіналом від 1 до 10 у чотирьох мастях: Сила, Знання, Інтриги і Гроші. Скажімо, двійка сили – це безцінне знання про те, де у людини дих, під який треба бити, а вісімка інтриг – достовірний список агентів Контори серед контрабандистів. Нескладна гра з хабарами визначає, чи впорався гравець зі стоїть перед ним проблемою і який мастю він виграв чи програв. В результаті конфлікту може бути максимум вісім випадків: перемога і поразка кожної мастю в принципі можуть призводити до різних наслідків.

Карткова гра — значить, в ній фігурують карти. Код Displayable, що відображає маленьку картку, виглядає ось так:

class CardSmallDisplayable(renpy.Displayable): 
""" 
Regular card displayable 
""" 

suit_bg = {u 'Гроші': 'images/MoneySmall{0}Card.jpg', 
u 'Знання': 'images/KnowledgeSmall{0}Card.jpg', 
u 'Інтриги': 'images/IntrigueSmall{0}Card.jpg', 
u 'Сила': 'images/ForceSmall{0}Card.jpg'} 

def __init__(self, card, **kwargs): 
super(CardSmallDisplayable, self).__init__(xysize=(100, 140), xfill=False, yfill=False, **kwargs) 
self.bg = Image(self.suit_bg[card.suit].format((card.spendable and 'Spendable' or 'Permanent'))) 
self.text = Text(u'{0}'.format(card.number), color = '#6A3819', font='Hangyaboly.ttf') 
self.xpos = 0 
self.ypos = 0 
self.xsize = 100 
self.ysize = 140 
self.x_offset = 0 
self.y_offset = 0 
self.transform = Transform(child=self) 

def render(self, width, height, st, at): 
""" 
Return 100*140 render for a card 
""" 
bg_render = renpy.render(self.bg, self.xsize-4, self.ysize-4, st, at) 
text_render = renpy.render(self.text, width, height, st, at) 
render = renpy.Render(width, height, st, at) 
render.blit(bg_render, (2, 2)) 
render.blit(text_render, (15-int(text_render.width/2), 3)) 
render.blit(text_render, (88-int(text_render.width/2), 117)) 
return render 

def visit(self): 
return[self.bg, 
self.text] 


Це, по суті, найпростіша можлива Displayable. Вона складається з двох інших: фонової картинки, вибирається в залежності від масті, і тексту з номіналом. Обидва методу (не рахуючи конструктора) необхідні для роботи Displayable: self.render повертає текстуру, а self.visit –
список всіх Displayables, що входять в дану. І вона дійсно малює маленьку карту; ось кілька таких карт на екрані колоди:

image

Вже непогано, але карта сама по собі вміє тільки знаходитися на екрані, і то тільки якщо її хто-небудь туди поставить. Щоб в міні-гру можна було, власне, грати, потрібно додати зовнішню Displayable, здатну обробляти enter і обраховувати ігрову логіку. Карти та інші елементи інтерфейсу будуть входити в неї так само, як текстові поля і фон є частинами карти. Відрізнятися ця Displayable буде наявністю методу self.event(), який отримує на вхід події PyGame. ПОСИЛАННЯ НА PYGAME.EVENT Приблизно ось так (повний код доступний на гітхабі в класі Table):

def event(self, ev, x, y, st): 
if ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1: 
# ...
# Process click at x, y
# ...
elif ev.type == pygame.MOUSEMOTION and self.dragged is not None:
# ...
# Process dragging card to x, y
# ...
renpy.restart_interaction()


Про черги подій можна не турбуватися: движок сам роздасть всі події всім елементам, які активні в даний момент. Методом залишається лише перевірити, чи цікавить його подію, відповісти на подію і завершити взаємодія. Взаємодія в Ren'Py приблизно еквівалентно тику ігровий логіки в інших движках, але не обмежене у часі. У загальному випадку це одна команда гравця і відповідь гри на неї, хоча іноді (наприклад, при промотке тексту) взаємодії можуть завершуватися самі собою.
Цю Displayable, як і всі інші, ми загорнемо в екран:

screen conflict_table_screen():
modal True
zorder 9
add conflict_table


conflict_table в даному випадку не ім'я класу, а глобальна змінна, в якій зберігається відповідна Displayable. Вище згадувалося, що код екрану в принципі може виконуватися в будь-який час, але перед показом він виконається неодмінно, інакше гра не буде знати, що їй, власне, виводити. Тому цілком безпечно безпосередньо перед міні-грою виконати щось на зразок conflict_table.set_decks(player_deck, opponent_deck) і покладатися на те, що перед гравцем виявиться рівно те, що потрібно. Аналогічним чином по завершенні міні-ігри можна звернутися до результатів, які зберігаються в тому ж об'єкті.

Треба сказати, що використання глобальних змінних – це не обмеження Ren'Py, а наше власне рішення. Підтримуються екрани і Displayables, які здатні приймати аргументи і повертати значення, але з ними дещо складніше. По-перше, поведінка таких екранів слабо задокументовано. Принаймні, розібратися, в який саме момент екран починає своє перше взаємодія і коли саме повертає контроль сценарієм, досить складно. А це дуже важливе питання, оскільки не знаючи відповіді на нього, складно гарантувати, що весь попередній конфлікту текст буде показаний до початку конфлікту, а наступний за конфліктом текст показаний не буде. По-друге, з використанням глобальних змінних більшість потрібних для міні-ігри об'єктів ініціалізується тільки один раз, а потім ми замінюємо їх атрибути при кожному наступному запуску. В іншому випадку довелося б з кожним конфліктом витрачати час на підвантаження всіх необхідних файлів; гра відчутно лагает, якщо до HDD паралельно звертаються ще, наприклад, торрент і антивірус. Нарешті, до карт звертається не тільки екран конфлікту, але і кілька інших екранів, тому логічно використовувати одні і ті ж Displayables скрізь, де вони потрібні.

Післямова



На цьому власне програмна частина розробки закінчується і починається наповнення гри контентом. Літературою в грі займаюся в основному не я, тому про структуру оповіді та стилістики міркувати не буду. На цю тему можу порадити почитати, наприклад, класичну статтю про структуру CYOA. Або непогане керівництво з написання переконливих незалежних NPC від сценариста 80 days.
Ще більше посилань (а також оглядів на свіжі англомовні роботи і статей на суміжні теми) можна знайти в блозі Емілі Шорт.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.