Текст
                    1


Валерий Рубанцев Python. Компьютерные игры на движке Arcade 2
Бесплатное издание Все права защищены. Никакая часть этой книги не может быть воспроизведена в любой форме без письменного разрешения правообладателей. Автор книги не несёт ответственности за возможный вред от использования информации, составляющей содержание книги и приложений. Copyright 2022 Валерий Рубанцев Лилия Рубанцева 3
От автора Лучше и полезнее учиться программировать на занимательных примерах. К самым занимательным примерам, тут нет сомнения, относятся компьютерные игры. Их любят все или почти все. Одни – любят в них играть, а другие - писать. В этой книге мы совместим и то, и другое, то есть приятное с полезным. Мы напишем дюжину игр, в которые интересно играть. Но ещё интереснее их писать! Несмотря на кажущуюся простоту, разработка компьютерных игр – занятие непростое. Нужно твёрдо знать не только основы языка программирования, но и более продвинутые технологии. Например, объектноориентированное программирование. Также нужно уметь работать с графическими и звуковыми файлами, добавлять к программе элементы управления, чтобы сделать её мультимедийной и интерактивной. И это - непростая задача. В наших проектах мы будем пользоваться библиотекой Arcade (её также называют игровым движком). По ходу изучения этой книги вы напишете 12 программ и ещё десяток их разновидностей. Все они относительно простые: одни очень, другие не очень. Как раз такие, чтобы научиться писать компьютерные игры. Среди них есть известные игры – Игра Баше, Угадай число, Закраска – и совсем новые – Пузыри, Охота на Скалоеда и Скалоедов, Лабиринты, Деньги любят счёт, Космический охотник, две программы про Незнайку, который взялся за ум, а потом и за программирование, и великолепная головоломка Ножки вверх! Эти проекты помогут вам закрепить свои знания о переменных, циклах, списках, условных операторах, классах, объектах и атрибутах. Цель книги: научиться разрабатывать компьютерные игры на языке Питон с использованием библиотеки Arcade. Книга адресуется: школьникам, учителям информатики и всем любителям программирования. Валерий Рубанцев 4
Условные обозначения, принятые в книге: Дополнение или замечание Требование или указание Исходный код: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # создаём Лабиринт: self.createLab() self.findPath() # Охотник занимает стартовую позицию: self.playerCol = 1 self.playerRow = 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH # счётчик ходов: self.nMoves = 1 Задание для самостоятельного решения Заголовок проекта: Проект … Исходные коды всех проектов находятся в папке _Projects 5
Оглавление От автора ............................................................... 4 Оглавление ............................................................. 6 Игра #1. Пузыри ...................................................... 8 Каркас игровых программ ....................................................................................... 8 Проект Создаём окно ................................................................................................ 8 Проект Пузыри ......................................................................................................... 23 Проект Пузыри 2 ..................................................................................................... 40 Проект Пузыри 3 ..................................................................................................... 43 Игра #2. Закраска .................................................... 51 Проект Закраска ....................................................................................................... 51 Игра #3. Говорящий алфавит ....................................... 85 Как Незнайка учил немецкий алфавит ............................................................. 85 Проект Говорящий алфавит ................................................................................. 88 Игра #4. Говорящие слова .......................................... 98 Как Незнайка учил немецкие слова ................................................................... 98 Проект Говорящие слова......................................................................................101 Игра #5. Угадай число ............................................ 107 Правила игры.......................................................................................................... 107 Стратегия игры........................................................................................................ 107 Игра #6. Игра Баше ................................................ 121 Правила игры...........................................................................................................121 Искусственный интеллект ................................................................................... 139 Как организовать паузу в игре? ......................................................................... 140 Игра #7. Ножки вверх! ............................................. 141 Чёт и нечет ................................................................................................................141 Проект Ножки вверх!............................................................................................. 144 Игра #8. Охота на Скалоеда ...................................... 162 Проект Охота на Скалоеда.................................................................................. 164 Игра #9. Охота на Скалоедов..................................... 187 6
Проект Охота на Скалоедов ............................................................................... 187 Головоломка Космический охотник .............................................................. 202 Игра #10. Как построить Лабиринт? ............................. 203 Проект Как построить Лабиринт? .................................................................... 203 Проект Лабиринт со стеком ............................................................................... 217 Проект Лабиринт со стеком 2 ........................................................................... 222 Игра #11. Как выбраться из Лабиринта .......................... 227 Проект Как выбраться из Лабиринта ............................................................... 227 Проект Как выбраться из Лабиринта 2 ........................................................... 238 Игра #12. Космический охотник.................................. 242 Проект Проект Проект Проект Проект Проект Космический охотник .......................................................................... 243 Большой космический охотник ........................................................ 252 Деньги любят счёт ................................................................................ 263 Деньги любят счёт 2 .............................................................................. 281 Рекурсивный лабиринт ......................................................................... 284 Дальнобойный лабиринт...................................................................... 291 Как создать исполняемый файл .exe............................. 295 Литература ........................................................ 303 Cерия Cерия Cерия Cерия Cерия Программирование для детей ............................................................. 303 Программирование на Питоне ............................................................ 305 Учись программировать с Котлином................................................... 310 Учись программировать с Процессингом ......................................... 315 Программирование на ЯваСкрипте ...................................................... 317 7
Игра #1. Пузыри У всех компьютерных программ много общего, поэтому мы, прежде всего, создадим основу для всех наших будущих игр → Каркас игровых программ Установите на компьютер Питон версий 3.9 или 3.10. Лучше пользоваться предыдущей версией Питона. В данном случае 3.9. Она уже хорошо проверена, и для неё наверняка имеются многочисленные библиотеки. Среда разработки может быть любая – Thonny, Spider, Mu, Visual Studio Code, PyScripter или PyCharm. Кроме Питона и среды разработки, нам нужен игровой движок Arcade. Всю информацию о нём вы найдёте на официальном сайте https://arcade.academy/ Чтобы установить движок (или библиотеку) на компьютер, наберите в окне Terminal строчку pip install arcade и нажмите клавишу ВВОД (Рис. 1). Рис. 1 Через некоторое время движок установится. Создайте папку для игровых программ, а в ней файл Окно.py. Проект Создаём окно Исходный код программы находится в файле Окно.py. Поскольку все наши программы будут графическими, то нам нужно научиться создавать Графическое окно приложения. 8
Все программы, которые используют движок Arcade, начинаются с импорта библиотеки: # Создаём окно программы import arcade Атрибуты окна лучше задавать константами: # размеры окна: SCREEN_WIDTH = 640 SCREEN_HEIGHT = 480 # заголовок окна: SCREEN_TITLE = "Окно программы" # ЦВЕТО ФОНА BACKGROUND_COLOR = arcade.color.DARK_GREEN В этом проекте мы создадим окно размерами 640 х 480 пикселей с заголовком Окно программы и тёмно-зелёным фоном. В игровой программе, естественно, должен быть игровой класс Game, который наследует классу arcade.Window: # КЛАСС МГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window = True) В конструктор нужно передать размеры окна программы и заголовок. width – ширина окна в пикселях. По умолчанию ширина окна равна 800 пикселям. height – высота окна в пикселях. По умолчанию высота окна равна 600 пикселям. title – строка, которая появится в заголовке окна после запуска программы. Заголовок по умолчанию - Arcade Window. Конструктор окна имеет и другие параметры, но мы ограничимся только параметром center_window, который отвечает за центрирование окна на экране. По умолчанию значение этого параметра равно False, то есть окно появится не по центру экрана, а в его левом верхнем углу. В конструкторе 9
окна много параметров со значениями по умолчанию, поэтому необходимо явно указать имя параметра, которому мы передаём значение. Кроме конструктора, в игровом классе должен быть метод setup, в котором выполняются все подготовительные операции. Мы пока только окрашиваем фон окна программы в заданный цвет: # ПОДГОТОВКА def setup(self): # цвет фона окна: arcade.set_background_color(BACKGROUND_COLOR) И ещё один обязательный метод - on_draw. Он вызывается автоматически при каждом обновлении сцены: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): arcade.start_render() По умолчанию частота обновления сцены – 60 кадров в секунду. В каждой программе на Питоне должна быть функция main, с которой начинается выполнение программы. В ней мы создаём экземпляр класса Game и вызываем метод setup игрового объекта: # ГЛАВНАЯ ФУНКЦИЯ def main(): # создаём экземпляр игры: game = Game(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) game.setup() После этого запускаем движок: # запускаем программу: arcade.run() Когда игра закончится, мы закроем окно программы: # закрываем программу: arcade.close_window() 10
if __name__ == "__main__": main() И вот теперь можно запустить программу! И что мы видим? – В центре экрана появилось долгожданное окно (Рис. 1). Рис. 1 Мы отцентрировали окно непосредственно в конструкторе класса Game. Это можно сделать и вручную. Или вы можете переместить окно в любую позицию на экране. В методе setup метод arcade.get_display_size возвращает размеры текущего монитора в пикселях: # ПОДГОТОВКА def setup(self): # цвет фона окна: 11
arcade.set_background_color(BACKGROUND_COLOR) # размеры экрана: ds = arcade.get_display_size() w, h = ds[0], ds[1] print(w, h) Для проверки распечатываем значения и убеждаемся, что функция работает верно (Рис. 2). Рис. 2 Метод set_location игрового объекта устанавливает окно в заданную позицию. Координаты (x, y) отсчитываются от верхнего левого угла экрана. Чтобы установить окно по центру экрана, нужно вычислить координаты левого верхнего угла окна: # центрируем окно: x = (w - SCREEN_WIDTH) // 2 y = (h - SCREEN_HEIGHT) // 2 self.set_location(x, y) В данном случае мы получим тот же самый результат, что и в автоматическом режиме, но подобным образом вы можете переместить окно в любую позицию на экране. Метод set_background_color окрашивает фон окна в заданный цвет color: arcade.set_background_color(color: Color) color – это кортеж из трёх байтов, которые задают RGB-составляющие цвета. В прозрачные цвета фон окна выкрасить нельзя. Все встроенные цвета обозначены константами в модуле color. Если у вас хорошая память на английские названия цветов, то вы легко запомните все 1003 цвета. Я пожалел книгу и вас, поэтому привожу здесь только начало и конец списка цветов, а также красивый цвет OCHRE, который, состоит из 204 частей красного цвета, 119 частей зелёного и 34 частей синего: AERO_BLUE = (201, 255, 229) AFRICAN_VIOLET = (178, 132, 190) 12
AIR_FORCE_BLUE = (93, 138, 168) AIR_SUPERIORITY_BLUE = (114, 160, 193) ALABAMA_CRIMSON = (175, 0, 42) OCHRE = (204, 119, 34) YELLOW_GREEN = (154, 205, 50) YELLOW_ORANGE = (255, 174, 66) YELLOW_ROSE = (255, 240, 0) ZAFFRE = (0, 20, 168) ZINNWALDITE_BROWN = (44, 22, 8) Именованные цвета удобно выбирать из раскрывающегося списка, если знать их в лицо (Рис. 3). Рис. 3 Я выбрал фиолетовый цвет, и вот что я получил на экране (Рис. 4). Для людей с менее развитой памятью на английские слова, существует более короткий список из 147 именованных цветов: ALICE_BLUE = (240, 248, 255) ANTIQUE_WHITE = (250, 235, 215) AQUA = (0, 255, 255) AQUAMARINE = (127, 255, 212) AZURE = (240, 255, 255) BEIGE = (245, 245, 220) WHEAT = (245, 222, 179) WHITE = (255, 255, 255) WHITE_SMOKE = (245, 245, 245) YELLOW = (255, 255, 0) YELLOW_GREEN = (154, 205) 13
Ищите эти цвета в модуле csscolor. Рис. 4 Охряного цвета там нет. Но давайте тогда испытаем в деле синюю Элис, которую также можно выбрать из списка (Рис. 5): # цвет фона окна: #arcade.set_background_color(BACKGROUND_COLOR) #arcade.set_background_color(arcade.color.DEEP_MAGENTA) arcade.set_background_color(arcade.csscolor.ALICE_BLUE) Не охра и не тёмно-зелёный цвет, а бледная синяя тень (Рис. 6). Эти 147 цветов применяются и в веб-графике, так что их изучение сможет обогатить вас как в прямом, так и в переносном смысле. 14
Рис. 5 Рис. 6 По адресу https://www.w3.org/TR/2018/PR-css-color-3-20180315/ вы можете не только прочитать названия веб-цветов, но и увидеть их вживую (Рис. 7). В таблице хорошо видно, что цвет можно задавать в виде строки с 16ричными значениями RGB-составляющих цвета. Такие строки можно передавать в метод arcade.color_from_hex_string: 15
arcade.set_background_color(arcade.color_from_hex_string('#006400')) Рис. 7 Таким образом вы можете задать любой цвет в строковом формате. Но гораздо удобнее задавать составляющие цвета в десятичном формате. Тогда в метод arcade.set_background_color нужно передать кортеж из трёх составляющих цвета в диапазоне 0..255: arcade.set_background_color((0, 100, 0)) В обоих случаях окно окрасится в тёмно-зелёный цвет (Рис. 8). Способ задания цветов именованными константами удобен, если вы знаете английские названия цветов. Но всё равно таким способом невозможно задать цвет, для которого нет названия. 16
Рис. 8 Для выбора цвета имеется много программ. Например, по адресу https://colorpicker.me/ вы найдёте программу, которая прямо в браузере позволяет удобно выбирать нужный цвет (Рис. 9). RGB-составляющие цвета вы можете скопировать в программу. Там же есть кнопка Random, которая создаёт случайный цвет. Поскольку случайные цвета могут пригодиться и нам, то мы напишем собственный метод random_color, который возвращает случайный цвет: # ВОЗВРАЩАЕТ СЛУЧАЙНЫЙ ЦВЕТ def random_color(self): r = randint(0, 255) g = randint(0, 255) 17
b = randint(0, 255) return (r, g, b) Рис. 9 Не забудьте импортировать функцию randint: from random import randint В методе change_color мы вызываем метод random_color, чтобы перекрасить фон окна в случайный цвет: # ИЗМЕНЯЕМ ЦВЕТ ФОНА def change_color(self, _delta_time): arcade.set_background_color(self.random_color()) Параметр _delta_time нам не нужен, но его требует метод arcade.schedule, которую мы используем, чтобы задать интервал между вызовами метода change_color, иначе окно будет перекрашиваться 60 раз в секунду, то есть будет попросту мигать. Добавляем в метод setup метод arcade.schedule и передаём ей указатель на метод change_color, а также промежуток времени между вызовами этого метода: 18
# ПОДГОТОВКА def setup(self): # цвет фона окна: arcade.set_background_color((0, 100, 0)) # изменяем цвет окна 1 раз в секунду: arcade.schedule(self.change_color, 1) Запускаем программу, и окно исправно перекрашивается в случайный цвет каждую секунду (Рис. 10). Рис. 10 Довольно часть вместо одноцветной заливки в играх используются фоновые картинки. Например, вот такая фотография с тюльпанами (Рис. 11). Для этого нам нужна текстура, которую мы загружаем из файла на диске в функции setup в переменную texture: # ПОДГОТОВКА 19
def setup(self): # загружаем фоновую картинку: self.texture = arcade.load_texture("Images/тюльпаны.jpg") # цвет фона окна: arcade.set_background_color(BACKGROUND_COLOR) Рис. 11 Я увеличил размеры окна, чтобы показать разные способы получения картинного фона: # размеры окна: WINDOW_WIDTH = 900 #640 WINDOW_HEIGHT = 600 #480 Размеры картинки – 800 х 533 пикселя, то есть меньше размеров окна программы. Сцена обновляется в методе on_draw: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): #arcade.start_render() 20
self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.texture.draw_scaled(xc, yc) Здесь мы стираем предыдущий кадр, окрашиваем фон в заданный цвет и впечатываем текстуру с помощью метода texture.draw_scaled. По умолчанию исходные размеры картинки не изменяются, поэтому достаточно передать в этот метод координаты центра текстуры в окне программы. Я нарисовал картинку по центру окна, но координаты могут быть любыми: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): #arcade.start_render() self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.texture.draw_scaled(xc, yc) Запускаем программу. Фон окна – тёмно-зелёный, а картинка сохранила исходные размеры и легла точно по центру окна (Рис. 12). Рис. 12 21
Метод texture.draw_scaled может одновременно изменять размеры текстуры по ширине и высоте. В нашем случае картинка не закроет часть фона, поскольку пропорции окна и картинки не совпадают. Метод texture.draw_sized растягивает текстуру до заданных размеров по двум направлениям: #self.texture.draw_scaled(xc, yc) self.texture.draw_sized(xc, yc, WINDOW_WIDTH, WINDOW_HEIGHT) Запускаем программу. Картинка заняла всю клиентскую область окна, но при этом нарушились пропорции картинки (Рис. 13). Из этого следует, что не всегда такой способ печати картинок на экране годится. Рис. 13 22
Проект Пузыри Очень простая, но притягательная игра BubbleWrap Popper имитирует всем известный процесс лопания шариков на пластиковой упаковке. В Интернете вы без труда найдёте её реализацию на Adobe Flash (Рис. 1). Рис. 1 Уральские пельмени доказали, что лопание пузырьков доставляет людям истинное удовольствие, поэтому они назвали их кайфушками (Рис. 2). Рис. 2 23
Вячеслав Мясников спел о кайфушках песню, а зрители с удовольствием подавили пузыри прямо на концерте (Рис. 3). Рис. 3 В книге Простые компьютерные игры на Питоне (Рис. 4) я рассказал, как написать игру BubbleWrapPopper на Питоне, но в среде Processing. Рис. 4 24
Там игра выглядит совсем неплохо (Рис. 5). Рис. 5 Поскольку конечный результат нам известен, то мы можем быстро создать каркас новой программы. Размеры окна, заголовок и цвет фона берём из процессинговой программы: # Игра "Пузыри" import arcade # размеры окна: WINDOW_WIDTH = 422 WINDOW_HEIGHT = 384 # заголовок окна: WINDOW_TITLE = "Пузыри" # цвет фона: BACKGROUND_COLOR = (200, 200, 200) Класс игры с минимальным кодом вам уже известен: # КЛАСС ИГРЫ 25
class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window = True) # ПОДГОТОВКА def setup(self): # цвет фона окна: arcade.set_background_color(BACKGROUND_COLOR) # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() И в главной функции для вас нет ничего нового: # ГЛАВНАЯ ФУНКЦИЯ def main(): # создаём экземпляр игры: game = Game(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) game.setup() # запускаем программу: arcade.run() # закрываем программу: arcade.close_window() if __name__ == "__main__": main() Для проверки запускаем программу. Окно пока без пузырей, но абсолютно правильное (Рис. 6). Пузыри имеют 2 состояния. В папку images я скопировал 2 картинки с пузырями. Один пузырь раздавленный, а второй – целый (Рис. 7). Формат картинок – PNG, а размеры 42 на 42 пикселя, что важно знать при расчётах игрового поля, поэтому мы сохраняем размеры поля и клеток (картинок с пузырями) в константах. Будем считать, что игровое поле не изменяет свои размеры и всегда равно 10 картинок с пузырями по ширине и 8 по высоте: 26
# размеры поля в клетках: FIELD_WIDTH = 10 FIELD_HEIGHT = 8 # размеры клеток в пикселях: B_WIDTH = 42 B_HEIGHT = 42 Рис. 6 Рис. 7 Вызываем метод setup непосредственно из конструктора класса Game: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) self.setup() Главная функция программы упростилась до предела: 27
# ГЛАВНАЯ ФУНКЦИЯ def main(): # создаём экземпляр игры: game = Game(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) # запускаем программу: arcade.run() # закрываем программу: arcade.close_window() В методе setup загружаем картинки с пузырями в переменные b0 и b1: # загружаем картинки с пузырями: b0 = arcade.load_texture("images/b0.png") b1 = arcade.load_texture("images/b1.png") Обратите внимание, что в них теперь хранятся ссылки на текстуры. В играх обычно используют спрайты. Это вполне логично, если игровые объекты перемещаются. В нашей игре все пузыри остаются на месте, поэтому мы можем просто рисовать на канве картинки-текстуры с пузырями. Эта процедура вам уже известна - так мы рисовали фоновую картинку в предыдущем проекте. Поскольку пузыри образуют таблицу 10 х 8 клеток, то игровое поле мы можем представить в виде двумерного массива объектов типа Bubble: # массив игрового поля: self.bubble = [[Bubble(b0, b1) for row in range(FIELD_HEIGHT)] for col in range(FIELD_WIDTH)] Класс Bubble Обычно все игровые объекты описываются в классах. Поведение пузырей совершенно бесхитростное, поэтому и класс Bubble очень простой: # КЛАСС ПУЗЫРЯ class Bubble(): def __init__(self, b0, b1): # пузырь не лопнут: self.live = True # размеры и координаты пузыря: self.w = B_WIDTH self.h = B_HEIGHT self.xc = 0 self.yc = 0 28
# картинки с пузырями: self.b0 = b0 self.b1 = b1 Переменная live показывает состояние пузыря. Для определения клика мышки на пузыре нам нужны его координаты и размеры. И пара переменных b0 и b1 – это картинки с пузырями. Итак, создавая массив поля, мы передали пузырю картинки. Также он получил значение True для переменной live – он пока целый. Размеры картинок пузырь приобрел от констант B_WIDTH и B_HEIGHT. Пузырю не хватает только координат на сцене. Для этого в методе setup вызываем новый метод createField: # создаём игровое поле: self.createField() Здесь мы вычисляем координаты всех пузырей на сцене и передаём их переменным xc и yc: # СОЗДАЁМ ИГРОВОЕ ПОЛЕ def createField(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): x = col * B_WIDTH + OFFSET_X y = row * B_HEIGHT + B_HEIGHT + OFFSET_Y self.bubble[col][row].xc = x self.bubble[col][row].yc = y Отступы пузырей от левой и верхней границы сцены запоминаем в константах: # отступы клеток от краёв окна: OFFSET_X = B_WIDTH // 2 + 1 OFFSET_Y = B_HEIGHT // 2 + 4 Теперь нужно показать пузыри на экране. В функции on_draw вызываем метод drawBubbles для рисования пузырей: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.drawBubbles() 29
В методе drawBubbles вызываем метод draw для всех пузырей: # РИСУЕМ ПУЗЫРИ def drawBubbles(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): b = self.bubble[col][row] b.draw() Дополняем класс Bubble функцией draw. По значению переменной live определяем текущее состояние пузыря и рисуем на экране соответствующую картинку: # РИСУЕМ ПУЗЫРЬ def draw(self): if self.live: self.b1.draw_scaled(self.xc, self.yc) else: self.b0.draw_scaled(self.xc, self.yc) Запускаем программу – все пузыри на месте и чувствуют себя прекрасно (Рис. 8). Рис. 8 30
Информация об игре В нижней части сцены мы предусмотрительно оставили место для информационной строки. В методе setup вызываем новый метод newGame: # начинаем игру: self.newGame() В этом методе начинается каждая новая игра. В переменной lopnuli мы будем считать лопнутые пузыри. Когда все пузыри будут раздавлены переменная isGameOver получит значение True, и давка закончится. Перед началом следующей игры мы оживляем все лопнутые пузыри: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self, b=False): self.lopnuli = 0 self.isGameOver = False for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].live = True Чтобы игрок мог следить за прогрессом борьбы с пузырями, в методе on_draw вызываем новый метод drawInfo: def on_draw(self): self.clear() self.drawBubbles() self.drawInfo() Метод arcade.draw_text печатает заданную строку message в окне программы: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): message = f"Лопнули: {self.lopnuli}" arcade.draw_text(message, 140, 14, arcade.color_from_hex_string('#1659FF'), font_size=20, bold=True) 31
Ему также следует передать координаты начала строки, цвет и размер символов, а также атрибуты шрифта – в данном случае мы хотим напечатать сообщение жирным шрифтом. Запускаем программу. Информация на экране скудная, но полная (Рис. 9). Рис. 9 Давим пузыри Чтобы раздавить пузырь, нужно нажать на нём кнопку мышки. При этом вызывается метод on_mouse_press, который нужно поместить в класс Game. В начале этого метода проверяем, не закончилась ли уже игра. Если это так, то игнорируем действия игрока: # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # игра уже закончена: if self.isGameOver: return Чтобы найти пузырь, на котором нажата мышка, мы последовательно извлекаем пузыри из массива bubble: 32
# ищем нажатый пузырь: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): Если пузырь под мышкой цел и невредим, то лишаем его жизни и добавляем 1 балл в копилку игрока: # нашли и он живой: if self.bubble[col][row].over(x, y) and self.bubble[col][row].live: # звук: #arcade.play_sound(self.buljk) # лопаем пузырь: self.lopnuli += 1 self.bubble[col][row].live = False За 1 щёлк можно прихлопнуть только одного пузыря, поэтому дальнейшие поиски прекращаем: return Добавляем в класс Bubble метод over, который возвращает True, если курсор находится на пузыре. Так как пузырь квадратный, а его координаты и размеры нам хорошо известны, то проверка в методе over очень простая: # проверяем, находится ли заданная точка # на пузыре: def over(self, px, py): x = self.xc - OFFSET_X y = self.yc - OFFSET_Y return (x <= px <= x + self.w) and (y <= py <= y + self.h) Теперь вы можете лупить и лопать пузыри совершенно безнаказанно (Рис. 10). Пузырь не рыба, поэтому при лопании должен соответствующим образом бумкнуть. Подобная история приключилась с мультяшным Пятачком, когда он раздавил воздушный шарик (Рис. 11). Чтобы наши пузыри бумкали не хуже воздушного шарика, раскомментируйте строку: 33
# звук: #arcade.play_sound(self.buljk) # звук: arcade.play_sound(self.buljk) Рис. 10 Рис. 11 Метод arcade.play_sound умеет воспроизводить заданный звук. Наша программа пока нема, как рыба. Но в папке sounds я приготовил бумкающий 34
звук. В функции setup загружаем звук в переменную buljk с помощью метода arcade.load_sound, которому нужно передать путь к звуковому файлу на диске: # загружвем звук: self.buljk = arcade.load_sound("sounds/buljk.wav") Проверьте – давленый пузырь реально бумкает! Так, пузырь за пузырём мы двигаем процесс игры. Число пузырей неуклонно и неумолимо сокращается, и нам становится понятно, что игра должна закончиться с последним бульком пузырей. Это значит, что в методе on_mouse_press мы должны проверять, не закончилась ли игра нашей победой (а иначе она и закончиться не может): # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # игра уже закончена: if self.isGameOver: return # ищем нажатый пузырь: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли и он живой: if self.bubble[col][row].over(x, y) and \ self.bubble[col][row].live: # звук: arcade.play_sound(self.buljk) # лопаем пузырь: self.lopnuli += 1 self.bubble[col][row].live = False # проверяем, не закончилась ли игра: self.testGameOver() return Проверка в методе testGameOver очень простая. Если лопнуты все пузыри, то переменная isGameOver получает значение True, и игра заканчивается: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def testGameOver(self): # все пузыри лопнули: if (self.lopnuli == FIELD_HEIGHT * FIELD_WIDTH): # игра закончена: self.isGameOver = True 35
Теперь мы вполне можем сыграть 1 раз. Чтобы начать новую игру, нужно бы нажать кнопку, а у нас её пока нет. Возвращаемся в метод setup и создаём менеджер элементов управления - arcade.gui.UIManager: # ПОДГОТОВКА def setup(self): # цвет фона окна: arcade.set_background_color(BACKGROUND_COLOR) # загружаем картинки с пузырями: b0 = arcade.load_texture("images/b0.png") b1 = arcade.load_texture("images/b1.png") # загружвем звук: self.buljk = arcade.load_sound("sounds/buljk.wav") # массив игрового поля: self.bubble = [[Bubble(b0, b1) for row in range(FIELD_HEIGHT)] for col in range(FIELD_WIDTH)] # создаём игровое поле: self.createField() # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() В этой программе всего 1 кнопка, но в других может быть больше, поэтому готовим место для них: self.box = arcade.gui.UIBoxLayout() Вот теперь можно создать кнопку: # кнопка: self.button = arcade.gui .UITextureButton(texture=arcade.load_texture('images/button_a.png'), texture_hovered=arcade.load_texture('images/button_b.png'), texture_pressed=arcade.load_texture('images/button_c.png')) Назначить ей метод-обработчик: # функция, которая вызывается при нажатии на кнопку: self.button.on_click = self.on_buttonclick И отправить кнопку в менеджер элементов управления: 36
self.uimanager.add(arcade.gui.UIAnchorWidget(child=self.box)) # начинаем игру: self.newGame() Нажатие на кнопку вызывает метод on_buttonclick, который начинает новую игру: # НАЖИМАЕМ КНОПКУ def on_buttonclick(self, event): self.newGame(True) Я поместил кнопку в середине игрового поля. Это красиво (Рис. 12), но она создаёт нам большие проблемы, потому что «экранирует» пузыри под собой, даже если не нарисована на экране. Рис. 12 Поэтому перед началом игры мы удаляем кнопку из менеджера элементов управления: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self, b=False): self.lopnuli = 0 37
self.isGameOver = False for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].live = True if b: self.box.remove(self.button) А когда игра закончится, в методе testGameOver мы возвращаем кнопку в менеджер элементов управления: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def testGameOver(self): # все пузыри лопнули: if (self.lopnuli == FIELD_HEIGHT * FIELD_WIDTH): # игра закончена: self.isGameOver = True self.box.add(self.button) И, если игра закончена, в методе on_draw мы рисуем кнопку на экране: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.drawBubbles() self.drawInfo() if self.isGameOver: self.uimanager.draw() Кнопка arcade.gui.UITextureButton получает 3 картинки для разных состояний. Первая картинка –мышка находится не на кнопке (Рис. 13). Рис. 13 Вторая картинка – мышка находится на кнопке (Рис. 14). Рис. 14 Третья картинка – кнопка нажата (Рис. 15). Рис. 15 38
Первая игра полностью готова! Запускаем программу, отчаянно давим пузыри. На экране появляется кнопка для запуска новой игры. Наводим на неё мышку, и кнопка становится цветной (Рис. 16). Рис. 16 Нажимаем кнопку и начинаем давку пузырей с самого начала (Рис. 17). Рис. 17 39
Проект Пузыри 2 В компьютерных играх часто вместо цифр (или одновременно с ними) используют геометрические фигурки (progress bar), чтобы показать значение каких-либо параметров. Они могут иметь самую затейливую форму (Рис. 1). Рис. 1 Но это могут быть и простые цветные полоски (Рис. 2). Рис. 2 В нашей незамысловатой игре вполне органично будут смотреться толстые цветные линии (Рис. 3). 40
Рис. 3 Полоска красного цвета показывает относительное число целых пузырей, а зелёная – лопнутых. В начале игры видна только красная полоска (Рис. 4). Рис. 4 41
Рис. 5 Полоски можно чертить в методе drawInfo, который мы используем для информирования игрока. Прямые линии чертит метод arcade.draw_line, которому нужно передать толщину линии h, координаты начала и конца линии - x_start, x_end. А вертикальная координата линии y не изменяется, поскольку линия горизонтальная. Сначала мы вычерчиваем красную линию почти по ширине окна программы: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # толщина линий: h = 38 # красная полоса --> # начало линии: x_start = 5 # длина линии: len = WINDOW_WIDTH-x_start*2 # конец линии: x_end = len + x_start # отступ линии от нижнего края: y = 23 arcade.draw_line(x_start, y, x_end, y, arcade.color.RED, h) 42
Длина зелёной полоски прямо пропорциональна числу лопнутых пузырей: # зелёная полоса --> len *= self.lopnuli / (FIELD_WIDTH * FIELD_HEIGHT) x_end = len + x_start arcade.draw_line(x_start, y, x_end, y, arcade.color.DARK_GREEN, h) Завершает метод drawInfo сообщение о числе лопнутых пузырей в штуках. Цвет полосок красный и зелёный, поэтому цвет шрифта меняем на жёлтый: # печатаем сообщение: message = f"Лопнули: {self.lopnuli}" arcade.draw_text(message, 140, 14, arcade.color.YELLOW, font_size=20, bold=True) Мы затратили совсем немного сил, но освоили новую технику, которую вы можете с успехом применять в разных играх. Проект Пузыри 3 Если вам стало скучно просто давить пузыри, то можно ускориться и давить пузыри на время. Вы можете устанавливать личный рекорд или соревноваться с другими пузыредавителями. В нашей простой программе мы будем просто показывать текущее время игрока (Рис. 1) и рекорд (Рис. 2), если он состоялся (после первой игры рекорд будет непременно установлен, поэтому важно самому начинать игру!) В следующих играх программа показывает текущее время зелёным цветом, если оно пока ещё меньше рекордного (Рис. 3). И красным цветом – если уже больше (Рис. 4). Если в очередной игре рекорд не установлен, то информационная строка просто показывает число задавленных пузырей (Рис. 5). 43
Рис. 1 Рис. 2 44
Рис. 3 Рис. 4 45
Рис. 5 Для этой игры нам нужен картинный фон, поэтому цвет закраски окна нам не понадобится: # размеры окна: WINDOW_WIDTH = 600 WINDOW_HEIGHT = 384 # заголовок окна: WINDOW_TITLE = "Пузыри 3" # цвет фона: #BACKGROUND_COLOR = (200, 200, 200) Я увеличил ширину окна, чтобы в правой части нарисовать часы и кубок, которые поясняют числа под ними. Такую фоновую картинку вы легко осилите в любом графическом редакторе (Рис. 6). В функции setup загружаем фоновую картинку: # ПОДГОТОВКА def setup(self): # цвет фона окна: #arcade.set_background_color(BACKGROUND_COLOR) # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") 46
Рис. 6 В методе on_draw впечатываем фоновую картинку в самый центр окна программы: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) . . . Эта процедура вам хорошо известна. Для отсчёта времени игры импортируем модуль time: import time Добавляем в класс Game новые переменные: class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # стартовое время: 47
self.start_time = 0.0 # текущее время: self.current_time = 0.0 # рекордное время: self.record_time = 1000_000.0 self.isRecord = False self.setup() Перед каждой новой игрой в методу newGame мы устанавливаем значение переменной isRecord в False, потому что рекорд ещё не установлен: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self, b=False): self.lopnuli = 0 self.isGameOver = False self.isRecord = False . . . Отсчёт времени игры начинается в методе on_mouse_press, когда игрок задавит первый пузырь: # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # игра уже закончена: if self.isGameOver: return # ищем нажатый пузырь: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли и он живой: if self.bubble[col][row].over(x, y) and \ self.bubble[col][row].live: # звук: arcade.play_sound(self.buljk) # лопаем пузырь: self.lopnuli += 1 self.bubble[col][row].live = False # начинаем отсчёт времени: if (self.lopnuli == 1): И тогда в переменную start_time мы записываем время, которое прошло с момента старта программы. Это время нам сообщает метод time.perf_counter: 48
self.start_time = time.perf_counter() # проверяем, не закончилась ли игра: self.testGameOver() return В методу drawInfo обновляем текущее время. Здесь нам добавились новые хлопоты, чтобы правильно показать число раздавленных пузырей, текущее и рекордное время. Если рекорда нет, то в нижней строке печатаем число лопнутых пузырей. Если рекорд состоялся, значит, игра закончена, и в нижней строке мы печатаем сообщение о новом рекорде и рекордное время: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): . . . if not self.isRecord: # печатаем сообщение: message = f"Лопнули: {self.lopnuli}" arcade.draw_text(message, WINDOW_WIDTH/2, 14, arcade.color.YELLOW, anchor_x="center", font_size=20, bold=True) else: message = f"НОВЫЙ РЕКОРД: {self.record_time}!" arcade.draw_text(message, WINDOW_WIDTH/2, 14, arcade.color.YELLOW, anchor_x="center", font_size=20, bold=True) Под картинкой с часами печатаем текущее время игры: # печатаем текущее время: if (self.lopnuli > 0): color = arcade.color.DARK_GREEN if not self.isGameOver: self.current_time = round(time.perf_counter() - self.start_time, 2) message = f"{self.current_time}" if self.current_time > self.record_time: color = arcade.color.RED arcade.draw_text(message, WINDOW_WIDTH - 120, WINDOW_HEIGHT - 160, color, font_size=20, bold=True) А под кубком – рекордное время: 49
# печатаем рекордное время: if self.record_time < 1000_000.0: message = f"{self.record_time}" arcade.draw_text(message, WINDOW_WIDTH - 120, WINDOW_HEIGHT - 310, color = arcade.color.BROWN, font_size=20, bold=True) Рекорд фиксируем в методе testGameOver. Когда со всеми пузырями будет покончено, мы найдём затраченное время как разницу между текущим показанием таймера time.perf_counter и временем начала игры start_time: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def testGameOver(self): # все пузыри лопнули: if (self.lopnuli == FIELD_HEIGHT * FIELD_WIDTH): self.current_time = round(time.perf_counter() - self.start_time, 2) Если время игры оказалось меньше рекордного, то обновляем рекордное время и поднимаем флажок isRecord: if self.current_time < self.record_time: self.record_time = self.current_time self.isRecord = True # игра закончена: self.isGameOver = True self.box.add(self.button) И последнее, что нам нужно сделать, - передвинуть кнопку влево, чтобы она появлялась по центру игрового поля, а не по центру окна программы: self.uimanager.add(arcade.gui.UIAnchorWidget(align_x=-90, child=self.box)) Игра стала более интересной и даже азартной, а вы попутно научились считать секунды. 50
Игра #2. Закраска Эта игра сродни Пузырям. Здесь тоже нужно щёлкать мышкой по «пузырям». Но она гораздо интереснее, поскольку придётся немного подумать, прежде чем нажать на кнопку мышки. Проект Закраска Вместо двух картинок с пузырями нам потребуется уже 6 картинок с цветными фишками (Рис. 1). Рис. 1. Цветные фишки Они не только красивее пузырей, но и больше по размерам - 62 х 62 пикселя. Сразу же объявляем все константы: # Игра "Закраска" import arcade import arcade.gui from random import randint # размеры окна: WINDOW_WIDTH = 830 WINDOW_HEIGHT = 622 # заголовок окна: WINDOW_TITLE = "Закраска" # размеры поля в клетках: FIELD_WIDTH = 10 FIELD_HEIGHT = 10 # размеры клеток в пикселях: B_WIDTH = 62 B_HEIGHT = 62 # число цветов: NUM_COLOR = 6 # названия файлов с картинками: imageNames = ["darkblue.png", 51
"red.png", "green.png", "lila.png", "blue.png", "yellow.png"] # отступы клеток от краёв окна: OFFSET_X = B_WIDTH // 2 + 1 OFFSET_Y = B_HEIGHT // 2 Их назначение понятно из комментариев. Все игровые программы начинаются с загрузки картинок и звуков: # ПОДГОТОВКА def setup(self): # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") # загружаем картинки с фишками: self.fishki = [] for i in range(NUM_COLOR): path = "images/" + imageNames[i] img = arcade.load_texture(path) self.fishki.append(img) # загружвем звук: self.buljk = arcade.load_sound("sounds/buljk.wav") Звук в игре один-единственный и тот же самый, что и пузырьковом проекте. А вот картинок стало больше, поэтому из удобнее загружать в список в цикле for, а для этого нужны имена графических файлов из списка imageNames. Для хранения звука нам потребовалась переменная buljk. А для хранения картинок – список imgFishki: # загружаем картинки с фишками: self.fishki = [] Фоновую картинку я нарисовал по размерам окна программы. Следы поясняют, что под ними будет информация о числе ходов, а кисть – число закрашенных клеток (Рис. 2). 52
Рис. 2 Родословная нашей игры игры Прежде чем идти дальше, давайте познакомимся с игрой поближе. Она известна под названием Flood-It!, что можно перевести как Закраська! В Интернете вы найдёте немало вариантов этой игры – и браузерных, и настольных, и мобильных (Рис. 3). Многие игры бесплатные, так что вы можете скачать и поиграть в них. В варианте игры, которая называется Globs, соседние шарики одного цвета сливаются в «каплю» (Рис. 4). 53
Рис. 3 Рис. 4 Мысль программистов вырвалась из плоскости и создала трёхмерную игру, в которой вам нужно померяться силами с компьютером (Рис. 5). 54
Рис. 5 Чаще всего поле состоит из цветных клеток, но встречаются и поля с цветными фишками – как у нас. Поле обычно квадратное, размеры от 4 х 4 до 16 х 16 (и даже больше). Классическое поле имеет размеры 14 х 14 клеток. В самом начале игры все клетки поля выкрашены в разные цвета (от трёх до семи) совершенно случайным образом. А цель игрока состоит в том, чтобы перекрасить все клетки в один цвет за наименьшее число ходов. Пожалуй, было бы интереснее перекрасить поле в какойлибо заранее выбранный цвет, а не в произвольный. Подумайте над этим! Сначала область игрока включает всего одну клетку (или больше, если она граничит по сторонам с клетками того же цвета), которая находится в левом верхнем углу поля. Клетки игрока называют также областью, захваченной территорией или перекрашенными клетками. 55
Чтобы выполнить ход, нужно перекрасить область игрока в любой цвет из возможных. Поскольку у нас 6 разных цветов, то можно выбрать любой из них. Но не каждый цвет будет удачным выбором! Если вы сделаете ход цветом области игрока, то ничего на поле не изменится, и вы только зря сделаете холостой выстрел. То же самое касается и тех цветов, которые отсутствуют в приграничных с областью игрока клетках. В этом случае область перекрасится, но ни одна новая клетка к ней не добавится. Из этого следует вывод: ходить нужно только цветом граничных клеток. Так, ход за ходом игрок увеличивает свою территорию, пока не захватит всё поле. Для выбора нужного цвета в играх используют кнопки, которые окрашены в соответствующие цвета. Однако у нас цветных кнопок не будет. И вот почему. Они не только не нужны, но даже и вредны. В самом деле, если на поле не осталось клеток, к примеру, жёлтого цвета, то кнопка с этим цветом будет только вводить игрока в заблуждение. Второе неудобство заключается в том, что кнопки находятся далеко от области боевых действий, а это может привести к ошибкам. В нашей игре вместо кнопок мы будем использовать фишки. Как и в Пузырях, нужно кликнуть по фишке нужного цвета, чтобы перекрасить область игрока. Мы уже выяснили, что для очередного хода необходимо выбирать цвет одной из граничных клеток. Таким образом, для выполнения хода далеко ходить не надо. Наша игра Картинки загружены, правила изучены, пора вернутьcя к игре. Остальная часть функции setup вам хорошо известна: # массив игрового поля: self.bubble = [[Bubble(self.fishki) for row in range(FIELD_HEIGHT)] for col in range(FIELD_WIDTH)] # создаём игровое поле: self.createField() # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() self.box = arcade.gui.UIBoxLayout() # кнопка: self.button = arcade.gui.UITextureButton(texture=arcade.load_texture('images/button_a.png'), texture_hovered=arcade.load_texture('images/button_b.png'), texture_pressed=arcade.load_texture('images/button_c.png')) # функция, которая вызывается при нажатии на кнопку: 56
self.button.on_click = self.on_buttonclick self.uimanager.add(arcade.gui.UIAnchorWidget(align_x=-102, align_y=2, child=self.box)) # начинаем игру: self.newGame() Каждый элемент двумерного списка bubble – это фишка, которая описана в классе Bubble. Конструктору класса нужно передать список картинок с фишками. Размеры фишек мы задали константами, а координаты вычисляем в методе createField: # СОЗДАЁМ ИГРОВОЕ ПОЛЕ def createField(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): x = col * B_WIDTH + OFFSET_X y = row * B_HEIGHT + OFFSET_Y self.bubble[col][row].xc = x self.bubble[col][row].yc = WINDOW_HEIGHT – y Класс фишки очень простой. В конструкторе, в переменных x, y, w и h мы запоминаем координаты и размеры картинки, в поле color – номер цвета фишки, а вспомогательное поле n пригодится нам при закрашивании фишек игрока: # КЛАСС ПУЗЫРЯ class Bubble(): def __init__(self, fishki): # закрашен? self.flood = False # цвет: self.color = 0 self.n = 0 # размеры и координаты пузыря: self.w = B_WIDTH self.h = B_HEIGHT self.xc = 0 self.yc = 0 # картинки с пузырями: self.fishki = fishki Метод over достался нам от предыдущего проекта, а метод draw рисует на экране фишку из списка fishki по её индексу color: 57
# РИСУЕМ ПУЗЫРЬ def draw(self): if self.flood: arcade.draw_lrtb_rectangle_filled(self.xc - self.w / 2 + 1, self.xc + self.w / 2 - 1, self.yc + self.h / 2 - 1, self.yc - self.h / 2 + 1, (255,255,255)) arcade.draw_lrtb_rectangle_outline(self.xc - self.w / 2 + 1, self.xc + self.w / 2 - 1, self.yc + self.h / 2 - 1, self.yc - self.h / 2 + 1, (0,0,0), 1) self.fishki[self.color].draw_scaled(self.xc, self.yc) # проверяем, находится ли заданная точка # на пузыре: def over(self, px, py): x = self.xc - OFFSET_X y = self.yc - OFFSET_Y return (x <= px <= x + self.w) and (y <= py <= y + self.h) После подготовительных работ начинаем игру. Из любопытства в методе newGame подсчитываем число ходов moves. Во вложенных циклах for выбираем случайный цвет для каждой фишки: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self, b=False): self.isGameOver = True # восстанавливаем фишки: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].flood = False # случайный цвет фишки: clr = int(randint(0, NUM_COLOR-1)) self.bubble[col][row].color = clr # число ходов: self.moves = 0 Уже в начальной позиции у игрока имеется 1 клетка – она находится в верхнем левом углу поля. Забежим немного вперёд и посмотрим на неё. Вот она, фишка зелёного цвета (Рис. 6). Но область игрока может оказаться и больше, если к угловой фишке примыкают по сторонам фишки того же цвета. А также нужно посчитать фиш- 58
ки, которые примыкают к примыкающим фишкам и так далее. На Рис. 7 видно, что уже в начале игры у игрока оказалось сразу 6 фишек. Рис. 6 Чтобы узнать, какие фишки принадлежат игроку, и перекрасить их, мы вызываем метод calcFlood, которому передаём цвет угловой клетки, а он возвращает число фишек того же цвета, которые образуют связную область. Число клеток игрока храним в переменной filled: # находим число клеток игрока в начальной позиции: clr1 = self.bubble[0][0].color self.filled = self.calcFlood(clr1, clr1) # игра не закончена: self.isGameOver = False Так как соседние (ортогонально) фишки одного цвета могут образовать очень сложные области, то для их подсчёта мы применяем волновой алгоритм, который обычно используется для поиска кратчайшего пути в лабиринте. 59
Рис. 7 Сначала мы обнуляем поле n у всех клеток поля. Затем в угловую клетку, которая всегда принадлежит игроку, ставим 1. Из этой клетки мы проверяем все соседние клетки слева, справа, сверху и снизу, вызывая метод flood: # ПОДСЧИТЫВАЕМ ЧИСЛО ПЕРЕКРАШЕННЫХ КЛЕТОК def calcFlood(self, clr1, clr2): # обнуляем поле n во всём массиве: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].n = 0 # ставим 1 в левую верхнюю клетку: self.bubble[0][0].n = 1 # выделяем её: self.bubble[0][0].flood = True if (clr1 != clr2): self.bubble[0][0].color = clr2 # номер очередного шага: nStep = 1 # число клеток в области: nCells = 1 60
# проверяем поле: while True: # число закрашенных на очередном шаге клеток: nStepFlood = 0 # ищем соседей для клеток с очередным числом: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли: if (self.bubble[col][row].n == nStep): # справа: nStepFlood += self.flood(col + 1, row, # ниже: nStepFlood += self.flood(col, row + 1, # слева: nStepFlood += self.flood(col - 1, row, # выше: nStepFlood += self.flood(col, row - 1, # добавляем новые клетки к общему результату: nCells += nStepFlood # следующий шаг: nStep += 1 # пока удаётся найти хотя бы одну новую клетку: if nStepFlood == 0: break return nCells clr1, clr2, nStep) clr1, clr2, nStep) clr1, clr2, nStep) clr1, clr2, nStep) В методе flood проверяем координаты соседней клетки. Если она находится за пределами поля, то это не фишка: # ПЕРЕКРАШИВАЕМ КЛЕТКУ def flood(self, col, row, clr1, clr2, n): # проверяем координаты клетки: if (col < 0 or col >= FIELD_WIDTH or row < 0 or row >= FIELD_HEIGHT): return 0 Также эта клетка должна иметь тот же цвет, что и начальная (или та соседняя, из которой мы ищем соседей), и её поле n должно иметь значение 0, которое показывает, что эту клетку мы ещё не проверяли: # проверяем цвет клетки и число в ней: if (self.bubble[col][row].color != clr1 or self.bubble[col][row].n != 0): return 0 Если соседняя клетка имеет тот же цвет (в данном случае цвета clr1 и clr2 совпадают, поэтому клетку перекрашивать не нужно), что исходная, то мы записываем в переменную flood значение True, чтобы отметить её как фишку игрока. А также записываем в переменную n число, которое на 1 больше того, которое стоит в исходной клетке: 61
# всё нормально - перекрашиваем клетку: self.bubble[col][row].color = clr2 self.bubble[col][row].flood = True # и ставим очередной номер: self.bubble[col][row].n = n + 1 Если в начальной клетке стоит 1, то все её ортогональные соседи получат 2. Их ортогональные соседи - 3. И так далее, пока найдутся ортогональные соседи того же цвета. Если представить область фишек одного цвета как лабиринт, то эти числа означают минимальное число ходов, которое необходимо сделать для перехода в эту клетку из начальной (мы поставили в неё 1, чтобы отличить от не посещённых клеток с нулём, поэтому ходов потребуется на 1 меньше). Если соседняя клетка того же цвета найдена, то метод flood возвращает 1: return 1 Итак, метод flood возвращает 1, если соседняя клетка имеет нужный цвет и ещё не посчитана, и 0 в противном случае. Это число добавляется к значению переменной nStepFlood. Если при поиске из 1 будет найдена хотя бы 1 клетка того же цвета, то эта переменная получит положительное значение, и поиск будет продолжен из всех клеток с двойкой. Если опять будет найдена хотя бы одна клетка того же цвета, то поиск будет продолжен из клеток с тройкой. И так далее, пока на очередном шаге ни одна клетка не будет найдена. Тогда метод calcFlood вернёт общее число фишек nCells в области игрока. Теперь в методе newGame всё готово к игре, и мы сбрасываем флаг окончания игры: # игра не закончена: self.isGameOver = False Тут же начинает свою работу метод on_draw, который показывает текущую игровую ситуацию на поле. Здесь мы сразу очищаем окно: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 62
yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) В методу drawBubbles вызываем функцию draw для каждой фишки: self.drawBubbles() # РИСУЕМ ПУЗЫРИ def drawBubbles(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): b = self.bubble[col][row] b.draw() Клетки поля с фишками игрока выделяем белым квадратом с чёрным контуром: # РИСУЕМ ПУЗЫРЬ def draw(self): if self.flood: arcade.draw_lrtb_rectangle_filled(self.xc - self.w / 2 + 1, self.xc + self.w / 2 - 1, self.yc + self.h / 2 - 1, self.yc - self.h / 2 + 1, (255,255,255)) arcade.draw_lrtb_rectangle_outline(self.xc - self.w / 2 + 1, self.xc + self.w / 2 - 1, self.yc + self.h / 2 - 1, self.yc - self.h / 2 + 1, (0,0,0), 1) self.fishki[self.color].draw_scaled(self.xc, self.yc) Все фишки хранят свои координаты в переменных xc и yc, а номер картинки – в переменной color, поэтому их очень просто нарисовать на экране. Дополнительно печатаем число ходов и закрашенных клеток: self.drawInfo() Для обновления информации на экране вызываем метод drawInfo: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # печатаем число ходов: 63
message = f"{self.moves}" arcade.draw_text(message, WINDOW_WIDTH - 110, WINDOW_HEIGHT - 220, arcade.color.GOLD, font_size=26, bold=True) # печатаем число закрашенных клеток: message = f"{self.filled}" arcade.draw_text(message, WINDOW_WIDTH - 110, WINDOW_HEIGHT - 385, arcade.color.DARK_BYZANTIUM, font_size=26, bold=True) Если игра закончена, то мы закрываем игровое поле полупрозрачным прямоугольником, чтобы кнопка не потерялась на пёстром фоне: # игра азкончена: if self.isGameOver: arcade.draw_lrtb_rectangle_filled(0, B_WIDTH * FIELD_WIDTH, WINDOW_HEIGHT - 1, 0, (200,200,200, 160)) self.uimanager.draw() А игра заканчивается, когда игрок перекрасит все клетки поля (или фишки) в один цвет: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def testGameOver(self): # все клетки перекрасили: if (self.filled == FIELD_HEIGHT * FIELD_WIDTH): # игра закончена: self.isGameOver = True self.box.add(self.button) Но до этого радостного события ещё далеко. Сейчас игрок может сделать только свой первый ход. Для этого он должен щёлкнуть мышкой по какой-либо фишке на поле. Во вложенных циклах for вызываем метод over для каждой фишки: # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # игра уже закончена: if self.isGameOver: return # ищем нажатый пузырь: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли: if self.bubble[col][row].over(x, y): 64
Если щелчок пришёлся по фишке, то мы определяем её цвет: # её цвет: clr2 = self.bubble[col][row].color Если он совпадает с цветом области игрока, то ситуация на поле не изменится, и мы сразу выходим из функции: # цвет области: clr1 = self.bubble[0][0].color # если цвета совпадают, то ничего делать не нужно: if (clr1 == clr2): return Если щёлкнутая фишка имеет другой цвет, то засчитываем игроку результативный ход, а она в знак благодарности издаёт булькающий звук: # звук: arcade.play_sound(self.buljk) # добавляем ход: self.moves += 1 Перекрашиваем в новый цвет все фишки игрока, а также смежные с ними. На этот раз мы передаём методу calcFlood разные цвета, чтобы перекрасить фишки: # перекрашиваем клетки области в цвет clr2: self.calcFlood(clr1, clr2) Снова вызываем метод calcFlood, чтобы подсчитать фишки игрока: # подсчитываем новое число клеток: self.filled = self.calcFlood(clr2, clr2) Так как каждый результативный ход добавляет фишки к области игрока, то нужно проверить, не закончилась ли игра: # проверяем, не закончилась ли игра: self.testGameOver() 65
Каждый раз можно щёлкнуть только одну фишку, поэтому дальнейшие поиски прекращаем: return Если игрок крепко усвоил правила игры, то довольно быстро перекрасит все клетки (Рис. 8). Рис. 8 А если он ещё не наигрался, то нажимает кнопку НОВАЯ ИГРА, и метод newGame перекрашивает все фишки случайным образом: # восстанавливаем фишки: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].flood = False # случайный цвет фишки: clr = int(randint(0, NUM_COLOR-1)) self.bubble[col][row].color = clr Игра интересная, поэтому можно продлить удовольствие. 66
Стратегии игры Поскольку игра-закраска – пошаговая стратегия, то давайте поищем выигрышные стратегии. И если компьютер легко справится с любой стратегией, то для игрока-человека стратегия должна быть достаточно простой. 1. Самая элементарная стратегия – случайная. Каждый ход выбирается совершенно произвольно один из тех цветов, которые граничат с областью игрока. В результате хода к области игрока будет добавлена, по крайней мере, одна новая клетка, так что игра закончится не более чем за FIELD_WIDTH х FIELD_HEIGHT – 1 ход. На самом деле гораздо быстрее, потому что на поле наверняка окажутся группы, состоящие из нескольких клеток одного цвета. Из этого следует, что для полного захвата территории понадобится не более N ходов, если обозначить через N число обособленных групп клеток, исключая начальную группу игрока. Здесь мы под группой понимаем клетки одного цвета, имеющие общие границы слева, справа, снизу и сверху. Например, на рисунке мы насчитаем 66 групп: 67
Следовательно, даже играя по случайной стратегии, мы закончим игру не более чем за 66 ходов (на самом деле не более 29). 2. Так как цель игры состоит в полном захвате территории, то мы естественным путём приходим к жадной стратегии, которая заключается в том, чтобы всегда выбирать такой ход, который добавляет к области игрока наибольшее число клеток. Рассмотрим применение жадной стратегии на простом примере. С начальной областью граничат клетки голубого и красного цвета. (Рис. 9). Рис. 9. Исходная позиция и группы клеток Жадная стратегия предлагает нам сделать ход красным цветом, чтобы присоединить к области 4 красные клетки, а не 3 голубые (Рис. 10). Теперь нужно сделать ход голубым цветом, чтобы захватить 4 клетки (Рис. 11). Ещё один допустимый ход – фиолетовым цветом – добавит только 1 клетку. А затем зелёным, чтобы присоединить группу из четырёх клеток (Рис. 12). 68
Рис. 10 Рис. 11 Рис. 12 Проверьте: ходы фиолетовым и красным цветом – менее жадные. Пришла очередь фиолетового цвета, и мы добавляем ещё 4 клетки (Рис. 13). Продолжаем игру ходами голубого и красного цвета (Рис. 14). И последний ход – фиолетовый (Рис. 15). 69
Рис. 13 Рис. 14 Рис. 15 Итого нам потребовалось выполнить 7 ходов. 3. Третья стратегия тоже жадная, но другого рода. Она основана на том наблюдении, что в конце игры должна остаться единственная группа клеток одного цвета. Наша задача – выбирать такие ходы, чтобы к области игрока присоединялось наибольшее число групп. Если таких ходов несколько, то выбирайте ход по первой жадной стратегии. 70
Сделав в начальной позиции ход красными, мы уничтожим только одну группу клеток, а если голубыми – две. Выбираем голубые фишки (Рис. 16). Рис. 16. Начальная позиция и после первого хода Дальше мы можем уничтожить две группы красных клеток и две группы фиолетовых. Однако мы замечаем, что ход зелёным цветом сразу уничтожает все зелёные клетки. Выбираем ход зелёными (Рис. 17). Рис. 17 Теперь мы можем уничтожить 2 группы красных клеток (5 штук), 2 группы голубых (2 штуки) и 4 группы фиолетовых (4 штуки). Выбираем фиолетовый цвет (Рис. 18). 71
Рис. 18 Легко видеть, что теперь нужно выполнить - в любом порядке - ходы красным и голубым цветом (Рис. 19). Рис. 19 И наконец, фиолетовым (Рис. 20). Мы управились с игрой за 6 ходов. Это значит, что жадничать тоже нужно с умом! В начале игры, когда трудно выбрать оптимальный ход, вполне можно положиться на третью стратегию. А вот в миттельшпиле и в эндшпиле число групп невелико, а потому легко просчитать позицию на несколько ходов 72
вперёд. Здесь уже совсем необязательно слепо выполнять предписания какой бы то ни было стратегии, так как последовательность ходов достаточно легко просматривается. Таким образом, именно удачный выбор ходов в начале игры и определяет её общий итог. Рис. 20 Попробуйте самостоятельно перекрасить клетки вот в такой игре: Пасьянс 73
Чтобы при каждом запуске игры получать одну и ту же начальную позицию, что важно при отладке программы, используйте функцию randomSeed с неизменным значением параметра seed: random.seed(seed) Если вы хотите создавать собственные головоломки, то уменьшите размеры поля до 5 х 5 клеток, а число цветов до 4. Как закрасить область клеток Хотя самое главное действие в игре - перекраска клеток, мы начнём с подсчёта клеток в замкнутой области. Опыт подсказывает нам, что уже в начальной позиции у игрока может оказаться больше одной клетки (Рис. 21). Рис. 21 Обычно для закраски замкнутых областей используют рекурсивный алгоритм, который называется flood fill (что наполовину совпадает с названием нашей игры). Но мы прибегнем к другому, итерационному алгоритму, который можно назвать волновым (или методом волновой трассировки). Его часто используют в теории графов, где он известен как метод поиска в ширину, а также для поиска пути в лабиринте. Суть этого метода такова. 1. В заданную клетку поля записывают единицу (что означает: первый шаг волны). Так как область игрока всегда включает левую верхнюю клет- 74
ку поля с координатами (0,0), то мы всегда начинаем с неё. Для записи чисел мы заранее подготовили в классе Bubble переменную n. Во всех остальных клетках поля должен стоять нуль (неисследованные клетки), поэтому мы начнём метод calcFlood с того, что обнулим поле n во всех элементах массива bubble: # ПОДСЧИТЫВАЕМ ЧИСЛО ПЕРЕКРАШЕННЫХ КЛЕТОК def calcFlood(self, clr1, clr2): # обнуляем поле n во всём массиве: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.bubble[col][row].n = 0 Вот теперь мы можем смело вписать единицу в начальную клетку (Рис. 22): # ставим 1 в левую верхнюю клетку: self.bubble[0][0].n = 1 # выделяем её: self.bubble[0][0].flood = True Рис. 22. Массив поля с помеченной угловой клеткой Угловая клетка всегда принадлежит игроку, поэтому её свойства следует задать в самом начале работы программы, что мы и выполняем в методе newGame, который вызывает метод calcFlood с цветом угловой клетки в качестве аргументов: # находим число клеток игрока в начальной позиции: clr1 = self.bubble[0][0].color self.filled = self.calcFlood(clr1, clr1) 75
Поскольку мы хотим в одном методе и подсчитывать клетки, и перекрашивать их, то нужно как-то сообщить ей о своих действиях. Мы поступаем просто: если нужно считать, то задаём оба аргумента одинаковыми (действительно, зачем перекрашивать клетку, если она того же цвета), в противном случае цвета clr1 и clr2 должны быть разными. Тогда по условию clr1 != clr2 мы легко определим намерения пользователя и, если заданы разные цвета, перекрашиваем угловую клетку в цвет clr2: if (clr1 != clr2): self.bubble[0][0].color = clr2 При подсчёте клеток это действие пропускается. Определяем переменные: # номер очередного шага: nStep = 1 # число клеток в области: nCells = 1 Один шаг (он иначе называется итерацией) мы уже выполнили, когда поставили единицу в угловую клетку, поэтому значение переменной nStep равно единице. Угловая клетка всегда принадлежит игроку, что мы и фиксируем в переменной nCells. Переменная nStepFlood потребуется нам для подсчёта новых клеток на очередном шаге. На втором шаге мы просматриваем все клетки поля и отыскиваем в них единицу (Рис. 23). Конечно, мы заранее знаем, что в списке field одноединственное поле n равно единице, но, чтобы не усложнять код, на каждом шаге мы просматриваем всё поле. Так как его размеры очень невелики, то времени на лишние просмотры понадобится совсем немного. 76
Рис. 23 # проверяем поле: while True: # число закрашенных на очередном шаге клеток: nStepFlood = 0 # ищем соседей для клеток с очередным числом: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли: if (self.bubble[col][row].n == nStep): Если у неё есть ортогональные соседи (то есть клетки со смежными сторонами), то мы должны их проверить (Рис. 24): # справа: nStepFlood # ниже: nStepFlood # слева: nStepFlood # выше: nStepFlood += self.flood(col + 1, row, clr1, clr2, nStep) += self.flood(col, row + 1, clr1, clr2, nStep) += self.flood(col - 1, row, clr1, clr2, nStep) += self.flood(col, row - 1, clr1, clr2, nStep) Начинаем обходить их в таком порядке (это непринципиально): справа – снизу-слева-сверху. Легко сообразить, что координаты клетки справа равны (col + 1, row), то есть координата по горизонтальной оси Х на единицу больше, чем у исходной клетки. Y-координата, естественно, такая же, как и у исходной клетки. С координатами остальных соседей вы легко разберётесь самостоятельно. Только не забывайте, что ось Y направлена вниз, то есть вертикальная координата нижней соседки на единицу больше, чем у исходной клетки. 77
Рис. 24. Ортогональные соседи угловой клетки Поскольку проверка любой соседки проводится одинаково, то мы перенесли эти действия в метод flood. Здесь мы сначала проверяем новые координаты. На Рис. 24⬆ хорошо видно, что слева и сверху от угловой клетки соседи отсутствуют. Конечно, мы не можем выходить за границы поля, поэтому «запредельные» клетки мы игнорируем и сразу же возвращаем нуль: # ПЕРЕКРАШИВАЕМ КЛЕТКУ def flood(self, col, row, clr1, clr2, n): # проверяем координаты клетки: if (col < 0 or col >= FIELD_WIDTH or row < 0 or row >= FIELD_HEIGHT): return 0 В методе calcFlood возвращаемое значение добавляется к переменной nStepFlood. Так что в этом случае её значение не изменится. В следующей проверке мы должны убедиться, что в клетке стоит нуль (то есть мы её ещё не посчитали), а её цвет совпадает с цветом исходной клетки (естественно, клетки другого цвета не могут принадлежать области игрока). Если хотя бы одно из этих условий не выполняется, то метод flood также возвращает нуль: # проверяем цвет клетки и число в ней: if (self.bubble[col][row].color != clr1 or self.bubble[col][row].n != 0): return 0 Если вы посмотрите на Рис. 24⬆, то увидите, что слева и сверху от угловой клетки соседей нет вообще, а соседняя клетка справа имеется и в ней даже стоит нуль (значение поля n равно нулю). А вот её цвет (мы все цвета обо78
значили индексами в списке imgFishki) 1 не совпадает с цветом 2 начальной клетки. А клетка снизу выдерживает все проверки, и в методе flood мы перекрашиваем её в новый цвет (при подсчёте клеток, разумеется, цвет не изменится), устанавливаем её свойства, как и у других клеток области игрока, записываем в неё число nStep+1 и возвращаем единицу: # всё нормально - перекрашиваем клетку: self.bubble[col][row].color = clr2 self.bubble[col][row].flood = True # и ставим очередной номер: self.bubble[col][row].n = n + 1 return 1 Так как других клеток с единицей в переменных n больше нет, то после первого просмотра игрового поля ситуация будет такая (Рис. 25). Рис. 25 На втором шаге мы нашли одну новую клетку, поэтому общее число клеток в области увеличивается, и мы переходим к следующему шагу: # добавляем новые клетки к общему результату: nCells += nStepFlood # следующий шаг: nStep += 1 Цикл while продолжается, так как на втором шаге мы нашли новую клетку. 79
Переменная nStep на единицу меньше номера шага, но я поступил так, чтобы упростить проверку условия: if (field[col][row].n == nStep): Поэтому в методе flood мы добавляем единицу. Вы можете сразу увеличить значение nStep на единицу и исправить указанные строчки. Вообще говоря, для наших целей можно и не менять значение переменной nStep, а всегда записывать в клетки области игрока единицу. Правда, никакой волны вы уже не увидите, но алгоритм упростится. # пока удаётся найти хотя бы одну новую клетку: if nStepFlood == 0: break return nCells Третий шаг мы опять начинаем с проверки всех клеток поля, но теперь ищем в них двойку. Так как предыдущий шаг мы прошли успешно, то, по крайней мере, одна такая клетка найдётся (Рис. 26). Теперь уже для неё мы подыскиваем ортогональных соседей. Рисунок справа показывает, что сосед слева отсутствует. Рис. 26 Сосед снизу отличается цветом. Сосед сверху имеет единицу в поле n. И только сосед справа проходит все испытания. Добавляем его к области 80
игрока и записываем тройку. На третьем шаге мы добавили одну новую клетку, поэтому переходим к четвёртому шагу и в цикле находим клетку с тройкой (Рис. 27). Рис. 27 Теперь ни одна соседняя клетка не выдерживает проверки в методе flood, поэтому функция calcFlood работу заканчивает – в области игрока три клетки. При перекраске области метод calcFlood действует аналогично, но дополнительно изменяет цвет клеток области игрока. Если, например, игрок выбрал цвет 0 (синий), то в начале метода calcFlood угловая клетка перекрасится в 0 (Рис. 28). Рис. 28 Но функция flood будет искать соседей с прежним цветом 2: if (bubble[col][row].color != clr1 or bubble[col][row].n != 0): 81
Итог перекраски вы видите на рисунке (Рис. 29). Рис. 29 Таким образом, все клетки области игрока перекрасились в кликнутый цвет 0. При этом область игрока осталась той же самой – из трёх клеток. Поэтому теперь нужно запустить метод flood на подсчёт клеток игрока. Для этого, как вы уже знаете, в вызове метода нужно дважды указать новый цвет clr2: self.filled = self.calcFlood(clr2, clr2) Посмотрите самостоятельно, как теперь распространяется волна из начальной клетки (Рис. 30). Рис. 30 Если вы проследите ход игры и дальше, то волновой процесс запомнится вам навсегда. 82
Что дальше? Игра Закраска очень увлекательна даже в том простом варианте, который мы написали. Следующий шаг в её развитии – научить компьютер закрашивать клетки. Здесь можно попытаться решить такие проблемы. 1. Находить лучшее решение для заданной начальной позиции. Оно должно состоять из наименьшего числа ходов. Если таких решений несколько, то достаточно найти одно и только подсчитать общее число решений. В некоторых версиях игры для возбуждения азарта устанавливается лимит ходов. Это некоторое – не обязательно минимальное – число ходов, за которые игрок должен справиться с заданием. Было бы совсем неплохо внести такое же ограничение и в нашу игру, чтобы игрок не расслаблялся, а в полную силу тренировал свои извилины. Вот тут и пригодилось бы умение программы подсчитывать ходы. Для этого можно попробовать составлять задачи «задом наперёд», то есть начать с конечной позиции, когда все клетки имеют один и тот же цвет, затем добавить возможную позицию на предыдущем ходу. И так далее – до первого хода. В этом случае игроку следует предложить решить задачу за это же число ходов (совсем не обязательно, что оно окажется минимальным). 2. Устроить соревнование между игроком и компьютером на параллельных досках (или последовательно решать одну и ту же задачу). Компьютер может пользоваться одной из стратегий игры, а также просматривать позицию на несколько ходов вперёд, как это обычно делается в шахматах. Здесь компьютер не обязательно должен играть безукоризненно, как в первом случае. Будет вполне достаточно, если он будет играть сильнее большинства «человеческих» игроков. 3. В некоторых реализациях игры захват территории ведётся одновременно двумя игроками, начиная с противоположных углов. Упомянутая ранее трёхмерная игра Filler 3D построена именно на таком принципе. Более привычна «плоская» игра (Рис. 31). В этом варианте игры на двоих позиция на доске зависит не только от ходов игрока, но и от ходов его противника (то есть компьютера). Игра становится жёстче, но азартнее! Но вот стратегия игры здесь должна быть иная… 83
Рис. 31 84
Игра #3. Говорящий алфавит В этом проекте мы снова будем давить мышкой – но не пузыри и не фишки, а немецкие буквы, которые не булькают, а сами называют себя. Так, с помощью мышки, вы сможете выучить целый алфавит! Как Незнайка учил немецкий алфавит - А, бе, а, бе, а, бе, - без устали повторял Незнайка, когда к нему подошёл его верный друг Гунька и спросил: - Что ты заладил «а-бе-а-бе»? А и бе сидели на трубе! - Ничего ты, Гунька, не понимаешь, - отозвался Незнайка, - это я алфавит учу. - Так в алфавите буквы называются а и бэ, а ты – как козлик: бе-бе, бе-бе, возразил Гунька. - Это в русском алфавите буквы называются а и бэ, а в немецком а и бе, пояснил Незнайка. - Всего две буквы? – удивился Гунька. – А как же они разговаривают? А-бебе-бе, что ли? Точно, как козлики. - Нет, - ответил Незнайка, - они разговаривают, как и мы с тобой, только по-немецки. А в немецком алфавите и другие буквы есть, только я их никак запомнить не могу. Наши буквы легко запоминаются: а-бэ-вэ-гэ-дэ. Правда? - Правда, - тут же согласился Гунька. – Я их сразу все запомнил. А в немецком алфавите какая третья буква? Разве не вэ? - То-то и оно, что не вэ, а… сейчас посмотрю, – замялся Незнайка. – У меня тут на листочке записано. Ну да, третья буква – це. - Не может такой буквы быть! – не поверил Гунька. - Есть муха цеце, а буквы це нет! - Ну как же нет, - возмутился Незнайка, - если у меня вот тут написано: це! - Пойдём к Знайке, - предложил Гунька, - он все в мире алфавиты выучил. Послушай, а зачем тебе нужен немецкий алфавит? – недоуменно спросил Гунька. – Завёл переписку с немецкими коротышками? 85
- Да не нужен он мне, - вздохнул Незнайка, - просто я на нём память тренирую. А она что-то плохо тренируется! Только до третьей буквы дошёл… Ладно, пошли к Знайке, - согласился он. Друзья, взявшись за руки, весело запрыгали к Знайке, распевая на ходу песенку: А и бе сидели на цеце. Когда они, радостные и возбуждённые, припрыгали к Знайке, тот колдовал над новым хитроумным прибором для высверливания дырок и червоточин в мировом пространстве. - Хы-хы! – закашляли друзья, чтобы обратить на себя внимание. – Знайка, у нас к тебе такое дело. Мы тут изучаем немцецкий алфавит… Знайка удивлённо посмотрел на них. - Так вот, мы тут изучаем немцецкий алфавит… - Точно немцецкий? – уточнил Знайка. – Я что-то такого не припоминаю. - Ну, не совсем немцецкий, а даже, наоборот, немецкий, - Гунька пихнул Незнайку в бок. – Это нас песенка сбила. - Что за песенка? – поинтересовался Знайка. Друзья весело спели: А и бе сидели на цеце. - Мы уже дошли до буквы це, но сомневаемся, це это или не це, - продолжили друзья. - Це, це, - ответил Знайка, опять уткнувшись в свой прибор. - Ну, что я тебе говорил! – воскликнул Гунька. – Буква называется цеце. Как муха. Значит, учи так: а, бе, цеце… Что там дальше? - А дальше будет вот так, - вдруг сказал Знайка, - мы соединяем эти два проводка и… - Бежим скорее отсюда! – закричал Гунька. – Сейчас как бабахнет! - Что бабахнет? Почему бабахнет? – удивился Незнайка. 86
- А потому, что я как-то раз соединил два проводка, и оно как бабахнуло! – пояснил Гунька. – Жуть! - Не, у Знайки не бабахнет! – заверил друга Незнайка. – Знайка всю физику выучил. Да что там физику, он вообще всё выучил! – разошёлся Незнайка не на шутку. – Знайка, а как нам выучить немецкий алфавит? – обратился с вопросом Незнайка к знаменитому учёному, который уже крепко-накрепко соединил два проводка, но так, вопреки опасениям Гуньки, ничего и не бабахнуло. - Как, как? – Методом тыка – вот как! – шутливо ответил Знайка. - Заработался! – пожалел его Гунька. – Заговариваться стал… - Это шутка! – засмеялся Знайка. – Смекайлик уже давно маленький такой приборчик придумал. Тыкнешь в букву пальцем, а она сама себя и называет. Очень быстро можно весь алфавит выучить. Кстати, там, в конце алфавита есть такие смешные буквы… - заинтриговал друзей Знайка. - Что это за буквы смешные? – удивился Гунька. - Ещё смешнее, чем цеце? Ну, давай уже приборчик! – нетерпеливо воскликнул он. – Я тоже хочу немецкий алфавит учить! Знайка торжественно вручил им чудо-аппарат Смекайлика, и друзья вприпрыжку отправились развивать память, прилежно изучая немецкий алфавит. И уже через полчаса они бродили по Цветочному городу и орали во всё горло: - А-а, бе-бе, це-це, йод, вау, ку-ку! Другие коротышки сильно удивлялись и спрашивали друг у друга: - Что это с ними? «Це-це, ку-ку»? Кто понаходчивее, отвечал так: - Они были у доктора Пилюлькина, а он им голову йодом намазал. Наверно, сильно ударились лбами, раз у них такое ку-ку в голове. Надо было им ещё и касторки дать. Мигом бы присмирели! Вот так Незнайка со своим другом Гунькой учили немецкий алфавит методом тыка. А выучили они его или так и застряли на бе-бе и ку-ку, допод87
линно никто не знает. Да и неинтересно это. А давайте мы лучше посмотрим, какой такой замечательный приборчик изобрёл Смекайлик, чтобы изучать немецкий алфавит. Нам-то он больше пригодится, чем весёлым друзьям. Проект Говорящий алфавит Приборчик у Смекайлика получился хоть и хитроумный, но простой. Вот такой (Рис. 1). Рис. 1 Вы и сами сделаете такой «приборчик» за полчаса. В немецком алфавите ровно 30 букв – 26 латинских и ещё 4 – немецких (их помещают в конец алфавита, хотя в словаре, например, буква Ä следует за буквой A). Не на каждой клавиатуре имеются немецкие буквы, и тогда их заменяют буквосочетаниями: AE, OE, UE, SS. Число 30 красиво делится на 6 и на 5, так что мы можем расставить буквы в прямоугольной табличке 6 х 5 клеток. Размеры таблички сохраним в константах: 88
# размеры поля в клетках: FIELD_WIDTH = 6 FIELD_HEIGHT = 5 Загружать 30 отдельных картинок довольно долго, поэтому я нарисовал игровое поле со всеми буквами и сохранил в файле bukvy.png (Рис. 2). Рис. 2 Размер каждой картинки – 68 х 68 пикселей, что мы и зафиксируем в новых константах: # размеры картинок в пикселях: SPRITE_WIDTH = 68 SPRITE_HEIGHT = 68 Картинки по горизонтали и вертикали разделяет зазор в 4 пикселя. Нам понадобится ещё одна картинка - bukvy2.png – с нажатыми буквами (Рис. 3) Свежекликнутая буква выделяется цветом, поэтому её сразу и хорошо видно. Отступы клеток от границ окна нетрудно вычислить: 89
# отступы клеток от краёв окна: OFFSET_X = SPRITE_WIDTH // 2 + 4 OFFSET_Y = SPRITE_HEIGHT // 2 + 4 Рис. 3 А по ним и размеры окна программы: # размеры окна: WINDOW_WIDTH = 436 WINDOW_HEIGHT = 365 # заголовок окна: WINDOW_TITLE = "Говорящий алфавит" Цвет фона позаимствуем у предыдущего проекта: # ЦВЕТ ФОНА BACKGROUND_COLOR = (127, 179, 179) Игровое поле создаём в методе setup: # ПОДГОТОВКА def setup(self): # массив игрового поля: self.field = [[Bukva() for row in range(FIELD_HEIGHT)] 90
for col in range(FIELD_WIDTH)] # создаём игровое поле: self.createField() Место пузырей и фишек заняли буквы. Все буквы – это экземпляры класса Bukva. Передаём в конструктор размеры и координаты картинки с буквой: # КЛАСС БУКВЫ class Bukva(): def __init__(self): # размеры и координаты буквы: self.w = SPRITE_WIDTH self.h = SPRITE_HEIGHT self.xc = 0 self.yc = 0 Для каждой буквы нам нужна пара картинок: # картинки с ненажатой и # нажатой буквами: self.image = ... self.image1 = ... Буква должна не только уметь рисовать себя на экране, но и называть: # звук буквы: self.sound = ... Нажатая буква выделяется цветом, поэтому мы должны отмечать нажатость буквы в переменной clicked: # буква не нажата: self.clicked = False Игровые программы довольно большие, и даже разбивка кода на отдельные классы не помогает быстро ориентироваться в коде, поэтому я перенёс класс буквы в отдельный файл Bukva.py. Теперь он автономный, и ему тоже необходим импорт библиотеки arcade и список констант: # This Python file uses the following encoding: utf-8 import arcade 91
# размеры поля в клетках: COLS = 6 ROWS = 5 # размеры картинок в пикселях: SPRITE_WIDTH = 68 SPRITE_HEIGHT = 68 # отступы клеток от краёв окна: OFFSET_X = SPRITE_WIDTH // 2 + 4 OFFSET_Y = SPRITE_HEIGHT // 2 + 4 В главном файле программы импортируем класс Bukva из одноимённого файла: # импортируем класс буквы: from Bukva import Bukva Итак, мы создали игровое поле из букв со свойствами по умолчанию. В методе createField мы задаём каждой букве координаты её места в окне приложения: # СОЗДАЁМ ИГРОВОЕ ПОЛЕ def createField(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # координаты спрайта в пикселях # на общей картинке: x = (SPRITE_WIDTH + 4) * col y = (SPRITE_HEIGHT + 4) * row self.field[col][row].xc = x + OFFSET_X self.field[col][row].yc = WINDOW_HEIGHT - y - OFFSET_Y С картинками сложнее, потому что буквы хранятся все вместе в двух файлах. Мы можем передать букве 2 большие картинки, чтобы она сама вырезала себя из общей «фотографии», но лучше сразу поочерёдно вырезать буквы из больших картинок и отправлять их в объект-букву: img = arcade.load_texture("images/bukvy.png", x, y, SPRITE_WIDTH, SPRITE_HEIGHT) self.field[col][row].image = img img = arcade.load_texture("images/bukvy2.png", x, y, 92
SPRITE_WIDTH, SPRITE_HEIGHT) self.field[col][row].image1 = img Чтобы выучить алфавит, нужно знать названия букв. Наши буквы называют себя сами, но не без нашей помощи. В папке sounds находятся все звуковые файлы для нашей программы. В отличие от спрайтов, их трудно собрать в одну «картинку», так что нам предстоит большая работа по загрузке звуковых файлов в программу. Как вы уже знаете, когда чего-то много, это чего-то следует поместить в список. У всех звуковых файлов одинаковые расширения, поэтому достаточно перечислить только их названия: # названия файлов со звуками: soundNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AE', 'OE', 'UE', 'TS'] Есть в папке и «особенный» звуковой файл alphabet.wav, который произносит слово «алфавит» по-немецки. Тоже сгодится для нашей программы, и мы сохраним его в отдельной переменной: # слово ""альфабет: self.soundAlphabet = arcade.load_sound("sounds/alphabet.wav") arcade.play_sound(self.soundAlphabet, 0.2) Остальные звуковые файлы загружаем в каждую букву отдельно: # загружаем звуки: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): ns = soundNames[row * FIELD_WIDTH + col] # путь к файлу: sn = "sounds/" + ns + '.mp3' # загружаем очередной звук: sound = arcade.load_sound(sn) self.field[col][row].sound = sound Заканчиваем подготовительные работы объявлением переменной lastClicked, которая будет хранить ссылку на последнюю нажатую букву: # последняя нажатая буква: self.lastClicked = ... 93
Запускаем программу. Ни одна буква не нажата, поэтому все они покоятся на жёлтом фоне (Рис. 4). Рис. 4 Начинаем учить буквы методом тыка, как посоветовал нам Знайка. Этот метод годится для компьютеров с сенсорным экраном, а на всех остальных нужно нажимать кнопку мышки. Менее удобно, но учёба никогда не была лёгким занятием. Но чем бы вы ни тыкнули в букву, всё равно попадёте в метод on_mouse_press. Узнав от мышки её координаты, мы можем найти кликнутый спрайт, вызывая метод over для каждой буквы: # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # ищем нажаткю букву: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли: b = self.field[col][row] if b.over(x, y): Метод over вам хорошо известен: 94
# проверяем, находится ли заданная точка # на букве: def over(self, px, py): x = self.xc - OFFSET_X y = self.yc - OFFSET_Y return (x <= px <= x + self.w) and (y <= py <= y + self.h) Нажатая буква называет себя: # звук буквы: b.play() Для этого у неё есть простой метод play: # ПРОИЗНОСИМ БУКВУ def play(self): arcade.play_sound(self.sound) Отмечаем нажатую букву: # буква нажата: b.clicked = not b.clicked Буквы обновляются в методе on_draw, который вызывает для этого метод drawBukvy: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() # цвет фона окна: arcade.set_background_color(BACKGROUND_COLOR) self.drawBukvy() Он, в свою очередь, вызывает метод draw для каждой буквы: # РИСУЕМ БУКВЫ def drawBukvy(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): b = self.field[col][row] b.draw() 95
Буквы сами себя рисуют и выбирают картинку в соответствии со значением переменной clicked: # РИСУЕМ БУКВУ def draw(self): if self.clicked: self.image1.draw_scaled(self.xc, self.yc) else: self.image.draw_scaled(self.xc, self.yc) Под нажатой буквой меняется фон, чтобы она выделялась среди своих подружек (Рис. 5). Рис. 5 Если мы снова нажмём эту букву, она произнесёт себя и останется нажатой. Но если мы нажмём другую букву, то получим 2 нажатые буквы. Затем 3, и так далее. В итоге мы перекрасим все буквы, и последняя нажатая буква потеряется среди них. Из этого следует, что выделенной цветом должна быть только последняя нажатая буква, а предыдущую нужно вернуть в исходное состояние. Также мы должны запомнить последнюю нажатую букву: if type(self.lastClicked) == Bukva: if self.lastClicked != b: self.lastClicked.clicked = False else: self.lastClicked.clicked = True 96
# запоминаем последнюю нажатую букву% self.lastClicked = b Так как одновременно можно нажать только 1 букву, то дальнейшие поиски прекращаем: return Задание для самостоятельного решения Переделайте эту программу так, чтобы она говорила по-русски или поанглийски. Но тут уж вам придётся хорошенько поработать, чтобы нарисовать спрайты с буквами, найти или записать звуки. Но зато интересно и полезно: и программировать научитесь, и алфавит заодно выучите! 97
Игра #4. Говорящие слова Теперь вы знаете, как Незнайка и его друг Гунька учили немецкий алфавит с помощью «приборчика». Они на удивление быстро выучили весь алфавит и так увлеклись немецким языком, что написали тренажёр для заучивания немецких слов. Вы, конечно, понимаете, что друзья-товарищи совсем ещё начинающие программисты, поэтому и программа у них получилась очень простая. Да, простая, но зато очень полезная и интересная. Как Незнайка учил немецкие слова В немецком языке слов ровно столько же, сколько и в русском. Оченьочень много – вот сколько. Даже маленькие немецкие дети знают несколько сотен слов. Ещё не очень-очень много, но всё равно слишком много для простой программы. Незнайка с Гунькой отобрали всего 16 слов. Как они это делали, мы не знаем, но это и неважно, ведь вы всегда сможете заменить слова в этой программе. Конечно, картинки здесь необязательны, но с картинками, согласитесь, учиться гораздо веселее. Какой из Незнайки получился художник, вам известно, но здесь он расстарался вовсю и нарисовал очень даже похожие картинки. Но каждый видит предметы на картинках по-своему, поэтому Незнайка с Гунькой ещё и подписали их. Вот такие они сделали картинки-спрайты, а потом ещё правильно и красиво расставили их на сцене (Рис. 1). С такими спрайтами вы живо выучите русские слова. А, вы их уже давно знаете? Тогда для каждого спрайта нужно добавить второй спрайт - уже с немецким словом. Например, по-русски вечер, а по-немецки – der Abend. Вот, значит, что придумали наши полиглоты. Если нажать на спрайт, то он превратится в спрайт с немецким словом (Рис. 2). Здорово и весело! И Незнайке с Гунькой это так понравилось, что они без устали нажимали на спрайтики, читали немецкие слова (вы же помните, что алфавит они выучили назубок!) и от души хохотали на весь Цветочный город. Вволю нахохотавшись, братцы-программисты решили усовершенствовать свой тренажёр так, чтобы он стал говорящим. Известно, что немецкие коротышки свои слова произносят не совсем так, как наши, русские. 98
Рис. 1 → Рис. 2 Но откуда взять «звуковые» слова? Ведь это же проблема, не правда ли? Но тут как раз на их счастье приехали в Цветочный город немецкие туристы. Совсем такие же коротышки, как и наши, только из Баварии. Да, и там тоже есть коротышки! Только калякают они по-немецки. Ну, ещё и по-баварски тоже. Но тогда их никто не понимает, кроме таких же баварских коротышек. Есть ещё и другие немецкие коротышки, которых тоже никто не понимает. А баварские коротышки, они и одеваются по-баварски. Короткие кожаные штанишки с лямочками, шляпа с пером. Рубашки, ботиночки, носочки. Смешные, конечно. А уж если побаварски заговорят, так все со смеху катаются… Но это так баварские коротышки «прикалываются». Когда нужно, они говорят на нормальном, понятном немецком языке. Да! В Баварии живут не только малыши, но и малышки. Они, конечно, одеваются иначе – по-девчачьи. Носят платья, кото- 99
рые смешно называются дирндлами. И что-то там ещё, но нам это совсем неинтересно. А интересно другое. Незнайка с Гунькой как-то нашли к ним подход, и за кружкой свежей яблочной газировки у немецких коротышек развязались языки, и они по предъявленным картинкам наговорили немецких слов. Незнайка аккуратно записал их на магнитофон, а потом немного подредактировал в звуковом редакторе, чтобы в серьёзную программу не попали посторонние хиханьки да хаханьки. В общем, получили Незнайка с Гунькой нужные им звуковые файлы и подгрузили их в предыдущую программу. Вот только функции пришлось немного подправить. Но так, самую малость. Зато теперь все спрайты стали говорящими. И память о баварских коротышках осталась… Потом Незнайка и Гунька выучили таким же макаром и остальные 250 тысяч немецких слов и уже думали, что смогут свободно разговаривать понемецки. Ан нет, оказалось, что для этого нужно знать грамматику и много чего ещё. - Да, - грустно сказал Незнайка, - трудная это работа, иностранные языки учить! - Да! – поддакнул Гунька. – Ещё какая трудная! - Я вообще удивляюсь, - продолжил Незнайка, - как это мы с тобой русскийто язык выучили? - И для меня это тоже загадка, - согласился Гунька. А, испугались! Ничего, потихоньку всё выучите. А для пользы и подмоги вот вам Незнайкины советы Немецкие существительные всегда пишутся с заглавной буквы. Немецкие существительные имеют артикль, который ставится перед словом и указывает его род: • der - мужской • das - средний • die – женский и множественное число Род немецких существительных по виду слова опреде100
лить сложно, поэтому заучивайте существительные вместе с артиклем: der Abend – вечер Некоторые немецкие существительные похожи на русские, но могут отличаться родом или ударением. Многие немецкие слова, как и русские, имеют несколько значений: baden – плавать, купаться, мыться Большинство немецких глаголов заканчивается на –en. Не читайте немецкие слова по-русски. Немцы произносят их совсем иначе! Старайтесь сразу правильно выговаривать немецкие слова. В современном немецком языке очень много английских слов, поэтому не удивляйтесь, если такие вам встретятся. Проект Говорящие слова Думаю, что мы не глупее Незнайки, поэтому быстро переделаем алфавитную программу в словесную. Все спрайты «упакованы» в 2 большие картинки. Одна с русскими словами (Рис. 3). Вторая – с немецкими (Рис. 4). Легко заметить, что в таблице 4 колонки и 4 строчки: # размеры поля в клетках: FIELD_WIDTH = 4 FIELD_HEIGHT = 4 Размеры картинок-спрайтов увеличились: # размеры картинок в пикселях: SPRITE_WIDTH = 119 SPRITE_HEIGHT = 89 Цвет фона должен быть белым, иначе у спрайтов будут выделяться уголки: 101
# ЦВЕТ ФОНА BACKGROUND_COLOR = arcade.color.WHITE Рис. 3 Рис. 4 Конечно, звуковые файлы должны быть в этой программе совсем другие: 102
# названия файлов со звуками: soundNames = ['Abend', 'Auge', 'Apfel', 'Arzt', 'Aprikose', 'Ananas', 'Auto', 'Bach', 'Baby', 'Ampel', 'Anzug', 'Adresse', 'Autobahn', 'Bahnhof', 'Baecker', 'baden'] Вы, конечно, обратили внимание, что некоторые константы нужны нам в разных файлах, поэтому создайте новый файл Settings.py и перенесите туда все определения констант: # This Python file uses the following encoding: utf-8 import arcade # размеры поля в клетках: FIELD_WIDTH = 4 FIELD_HEIGHT = 4 # размеры картинок в пикселях: SPRITE_WIDTH = 119 SPRITE_HEIGHT = 89 # отступы клеток от краёв окна: OFFSET_X = SPRITE_WIDTH // 2 + 4 OFFSET_Y = SPRITE_HEIGHT // 2 + 4 # зазор между картинками: OFFSET = 8 # размеры окна в пикселях: WINDOW_WIDTH = SPRITE_WIDTH * FIELD_WIDTH + 8 WINDOW_HEIGHT = SPRITE_HEIGHT * FIELD_HEIGHT + 8 # заголовок окна: WINDOW_TITLE = "Говорящие слова" # ЦВЕТ ФОНА BACKGROUND_COLOR = arcade.color.WHITE # названия файлов со звуками: soundNames = ['Abend', 'Auge', 'Apfel', 'Arzt', 'Aprikose', 'Ananas', 'Auto', 'Bach', 'Baby', 'Ampel', 'Anzug', 'Adresse', 'Autobahn', 'Bahnhof', 'Baecker', 'baden'] # отступы клеток от краёв окна: OFFSET_X = SPRITE_WIDTH // 2 + 4 OFFSET_Y = SPRITE_HEIGHT // 2 + 4 103
Теперь во всех остальных файлах импортируйте содержимое файла Settings.py: from Settings import * В класс слова нужно только заменить буквы словами, весь код остаётся без изменений: # СПРАЙТ СО СЛОВОМ class Slovo: def __init__(self): # размеры и координаты буквы: self.w = SPRITE_WIDTH self.h = SPRITE_HEIGHT self.xc = 0 self.yc = 0 # картинки с ненажатым и # нажатым словом: self.image = ... self.image1 = ... # слово: self.sound = ... # слово не нажато: self.clicked = False # РИСУЕМ СЛОВО def draw(self): if self.clicked: self.image1.draw_scaled(self.xc, self.yc) else: self.image.draw_scaled(self.xc, self.yc) # ПРОИЗНОСИМ СЛОВО def play(self): arcade.play_sound(self.sound) # проверяем, находится ли заданная точка # на слове: def over(self, px, py): x = self.xc - OFFSET_X y = self.yc - OFFSET_Y return (x <= px <= x + self.w) and (y <= py <= y + self.h) В главном файле программы изменения небольшие. Опять заменяем буквы словами: 104
# Игра "Говорящие слова" from Settings import * # импортируем класс слова: from Slovo import Slovo # ПОДГОТОВКА def setup(self): # массив игрового поля: self.field = [[Slovo() for row in range(FIELD_HEIGHT)] for col in range(FIELD_WIDTH)] # создаём игровое поле: self.createField() # СОЗДАЁМ ИГРОВОЕ ПОЛЕ def createField(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # координаты спрайта в пикселях # на общей картинке: x = SPRITE_WIDTH * col + 2 y = SPRITE_HEIGHT * row + 2 self.field[col][row].xc = x + OFFSET_X self.field[col][row].yc = WINDOW_HEIGHT - y - OFFSET_Y img = arcade.load_texture("images/slova.png", x, y, SPRITE_WIDTH, SPRITE_HEIGHT) self.field[col][row].image = img img = arcade.load_texture("images/slova2.png", x, y, SPRITE_WIDTH, SPRITE_HEIGHT) self.field[col][row].image1 = img # РИСУЕМ СЛОВА def drawSlova(self): for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): b = self.field[col][row] b.draw() # НАЖИМАЕМ КНОПКУ МЫШКИ def on_mouse_press(self, x, y, button, modifiers): # ищем нажатое слово: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): 105
# нашли: s = self.field[col][row] if s.over(x, y): # звук слова: s.play() # слово нажато: s.clicked = not s.clicked if type(self.lastClicked) == Slovo: if self.lastClicked != s: self.lastClicked.clicked = False else: self.lastClicked.clicked = True # запоминаем последнее нажатое слово: self.lastClicked = s return На этом все изменения и приготовления закончились и можно давить спрайты (Рис. 5). Рис. 5 И тут нас ждут странности и неожиданности. Вот, ананас – он и в Германии ананас. Но у нас этот фрукт мужского рода (не скажешь ведь: ананас – это она!), а у немцев женского. Мы говорим ананАс – с ударением на последнем слоге, а немцы Ананас – с ударением на первом слоге. Вот такой ананас получается при изучении странных иностранных языков. 106
Игра #5. Угадай число В этой игре нужно угадать задуманное компьютером число в заданном диапазоне, пользуясь подсказками. Игра имеет долгую историю, но популярна по сей день. Бабка надвое сказала: либо дождик, либо снег, либо будет, либо нет. Фольклорный пример бинарного предсказания По-русски игра так и называется Угадай число, по- английски - Guess the Number. Правила игры Компьютер загадывает число от 1 до 100, а игрок должен угадать его за минимальное число попыток. Если число угадано, то компьютер поздравляет его и загадывает следующее. И так до тех пор, пока игроку не надоест. Если названное игроком число меньше задуманного, то компьютер сообщает ему о недоборе, если больше – о переборе. Стратегия игры Игра очень простая – и стратегия игры тоже не сложнее: нужно каждый раз называть число, находящееся посередине интервала оставшихся чисел. Например, на первом ходу игрок должен назвать число 100 : 2 = 50. Если угадал – сразу молодец! Если перебор, то диапазон уменьшается вдвое – 1..49. При недоборе – аналогично: 51..100. Далее действуем по заранее утверждённому плану, без особых затей. Легко подсчитать, что потребуется не более 8 ходов, чтобы угадать любое число от 1 до 100. Если вам этого мало, загадывайте числа побольше. 107
Уточним. Минимальное загаданное число – 1, максимальное – 100. Разность составляет 100 – 1 = 99. Половина разности равна 99 : 2 = 49.5. Поскольку загадано целое число, то дробное нужно округлить: до меньшего – 49 или до большего – 50. Таким образом, на первом ходу можно называть любое из этих чисел. Дальше действуйте аналогично. Несмотря на кажущуюся простоту, наша стратегия является хорошим примером двоичного (бинарного) поиска. Действительно, с каждым ходом число претендентов уменьшается вдвое: вначале их было 100, после первого хода - 50, затем - 25 – 13 – 7 – 4 – 2 – 1 (Рис. 1). Рис. 1 Обычно этот метод используют для поиска нужного элемента в упорядоченном массиве. Наше применение немного нетрадиционное, но зато очень эффектное! 108
Согласно правилам игры, наибольшее загаданное число равно 100, что мы и фиксируем в константе MAX_NUMBER: # макс. загаданное число: MAX_NUMBER = 100 При желании вы можете увеличить или уменьшить это число, но игра от этого интереснее не станет. Из тех же правил игры следует, что нам нужна переменная для хранения загаданного числа: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # загаданное число: self.number = 0 Переменная для названного числа: # названное число: self.num_guess = 0 И переменная для счёта затраченных попыток: # число попыток: self.popytka = 0 Опыт предыдущих игр подсказывает, что нам не обойтись без флажка isGameOver: self.isGameOver = True И двух кнопок. Правая - для начала новой партии, левая - для проверки хода игрока. В целом интерфейс программы выглядит так (Рис. 2). 109
Рис. 2 Фоновую картинку легко нарисовать в любом графическом редакторе (Рис. 3). Рис. 3 Нижний прямоугольник я закрасил чёрным цветом, чтобы чёрные кнопки не выпадали из коллектива. 110
Если с кнопками всё просто, то ввод числа представляет собой проблему. Мы могли бы создать 100 кнопок или картинку с сотней спрайтов, но это решение громоздкое и сложное. Гораздо удобнее установить ползунок: игрок передвигает ползунок мышкой и выбирает нужное число. В библиотеке Arcade ползунок пока находится в состоянии разработки, поэтому приходится импортировать его в 4 строки: # Игра "Угадай число" import arcade from arcade.experimental.uislider import UISlider from arcade.gui import UIManager, UIAnchorWidget, UILabel from arcade.gui.events import UIOnChangeEvent Заодно мы получили в своё распоряжение метки, которые тоже нам пригодятся. В методе setup создаём все элементы управления и правильно расставляем их в окне программы: # ПОДГОТОВКА def setup(self): # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() Проще всего создать плоские кнопки. В конструктор передаём текст на кнопке и ширину кнопки. При нажатии на кнопку вызывается какой-либо метод: # кнопка: guess_button = arcade.gui.UIFlatButton(text="Отгадываю", width=200) guess_button.on_click = self.on_guess_buttonclick # кнопка: newgame_button = arcade.gui.UIFlatButton(text="Новая игра", width=200) newgame_button.on_click = self.on_newgame_buttonclick Ползунок должен получить от нас диапазон значений, текущее значение, ширину и высоту: # ползунок: self.slider = UISlider(min_value=1, max_value=100, value=50, width=WINDOW_WIDTH - 50, height=30) 111
Метки получают текст, размер и цвет шрифта: # метка: self.label_num = UILabel(text=f"Число = {self.slider.value:2.0f}", font_size=22, bold=True, text_color=arcade.color_from_hex_string('#1659FF')) # метка: self.label_guess = UILabel(text=f"Попытка {self.popytka}", font_size=22, bold=True, text_color=arcade.color_from_hex_string('#0E8F01')) # метка: self.label_message = UILabel(text=self.message, font_size=22, bold=True, width=200, text_color=arcade.color_from_hex_string('#7D1F08')) При перемещении движка на ползунке вызывается метод on_change, который можно определить прямо в методе setup: @self.slider.event() def on_change(event: UIOnChangeEvent): num = int(round(self.slider.value)) print(num) if num in self.numbers: self.label_num.text = f"Число = <{self.slider.value:2.0f}>" else: self.label_num.text = f"Число = >{self.slider.value:2.0f}<" self.label_num.fit_content() self.message = "" Все элементы управления созданы, но их нужно ещё добавить в менеджер. Здесь мы можем задать координаты элементов управления в окне программы: self.uimanager.add(UIAnchorWidget(child=self.slider, align_y= -38)) self.uimanager.add(UIAnchorWidget(child=self.label_num, align_y=60)) self.uimanager.add(UIAnchorWidget(child=self.label_guess, align_x=-260, align_y=60)) self.uimanager.add(UIAnchorWidget(child=self.label_message, align_x=290, align_y=60)) self.uimanager.add(UIAnchorWidget(child=guess_button, align_x=-260, align_y= -147)) В конце метода setup загружаем фоновую картинку и звуки: # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") # загружвем звуки: self.win = arcade.load_sound("sounds/win.wav") 112
self.error = arcade.load_sound("sounds/error.wav") И можно начинать игру: # начинаем игру: self.newGame() В методе newGame обнуляем число попыток и стираем сообщение о результате хода: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): self.popytka = 0 self.message = '' Загадываем случайное число в заданном диапазоне 1..100: # загадываем число: self.number = randint(1, MAX_NUMBER) #print(self.number) В списке numbers мы будем отмечать возможные числа для угадывания. В начале игры в список помещаем все числа от 1 до 100: # заполняем список возможными числами: self.numbers = [n for n in range(1, MAX_NUMBER + 1)] Как мы выяснили, первый ход лучше всего начинать с числа 50, поэтому сразу устанавливаем его на ползунке: # выставляем на ползунке число 50: self.slider.value = 50 В метке label_num печатаем текущее число на ползунке: self.label_num.text = f"Число = {self.slider.value:2.0f}" # игра началась: self.isGameOver = False 113
Итак, число загадано, а игрок должен назвать своё число-угадку. При перемещении движка изменяется текущее число. Мы должны как-то сообщить компьютеру, что число уже выбрано. Опять же самый простой вариант – нажать на кнопку guess_button. Но прежде чем игрок выполнит первую попытку, метод on_draw покажет игровое поле во всей своей красе: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.background_color = BACKGROUND_COLOR xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) self.drawInfo() self.uimanager.draw() Метод drawInfo печатает в метке label_guess число выполненных попыток игрока угадать число, а в метке label_message – сообщение о результате хода: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): self.label_guess.text = f"Попытка {self.popytka}" # и сообщение компьютера: self.label_message.text = self.message Игрок может сразу нажать кнопку Отгадываю или сначала выбрать на ползунке другое число (Рис. 4). Угловые скобки <> подсказывают игроку, что число может быть загадано. Нажимаем кнопку. Компьютер в ответ не ходит, а только комментирует ход игрока. В данном случае игрок получил сообщение о недоборе (Рис. 5). Это значит, что загаданное число больше, чем 33. Если устанавливать ползунок на большие числа, то они попадут в угловые скобки <> (Рис. 6). Для меньших чисел скобки выворачиваются (Рис. 7). 114
Рис. 4 Рис. 5 Вы можете вообще убрать эти подсказки или изменить их. 115
Рис. 6 Рис. 7 Кнопка Отгадываю отсылает нас в метод on_guess_buttonclick, в котором мы сравниваем названное число с задуманным: # УГАДЫВАЕМ ЧИСЛО def on_guess_buttonclick(self, event): # игра уже закончена: 116
if self.isGameOver: return # число игрока: self.num_guess = int(round(self.slider.value)) Если они совпадают, то игрок угадал число: # если оно равно загаданному, # то победа: if (self.num_guess == self.number): self.ugadal() В противном случае он получает соответствующее сообщение: # названное число больше задуманного: elif (self.num_guess > self.number): self.numbers = list(filter(lambda n: n < self.num_guess, self.numbers)) print(self.numbers) self.message = "Перебор!" # названное число меньше задуманного: else: self.numbers = list(filter(lambda n: n > self.num_guess, self.numbers)) print(self.numbers) self.message = " Недобор!" # звук ошибки: self.error.play() # попытка: self.popytka += 1 Одновременно мы удаляем из списка numbers все числа, которые не могут быть задуманными. Загаданное число больше 33. Пусть это будет 75. Это передобор (Рис. 8). Число 53 также даёт перебор (Рис. 9). Круг чисел-претендентов сузился! С числом 43 мы получаем недобор (Рис. 10). Делаем ещё пару ходов. Они приносят нам перебор (Рис. 11) и недобор (Рис. 12). Но осталось всего 2 числа - 46 и 47. Называем число 47 наудачу и вот она, удача (Рис. 13)! 117
Рис. 8 Рис. 9 Как вы помните, эту приятную обязанность мы возложили на метод win: # ИГРОК ПОБЕДИЛ def ugadal(self): # игра закончена: self.isGameOver = True # поздравительное сообщение: 118
self.message = 'Угадал!' # музыка для победителя: self.win.play() Рис. 10 Рис. 11 Если кто не наигрался с первого раза, тот нажимает на кнопку Новая игра и начинает угадывать с самого начала: 119
# НАЖИМАЕМ КНОПКУ "НОВАЯ ИГРА" def on_newgame_buttonclick(self, event): self.newGame() Рис. 12 Рис. 13 120
Игра #6. Игра Баше С милым рай и в шалаше, Если есть игра Баше! Эту игру описал французский поэт и математик Клод Гаспар Баше де Мезириак в книге Занимательные и приятные числовые задачи, которая была издана в 1612 году. Правила игры Два игрока по очереди берут из кучки, состоящей из n объектов (обычно это спички или камешки), от 1 до max объектов за 1 ход. Выигрывает тот, кто берёт последний объект. Из правил игры ясно следует, что в игре принимают участие 2 игрока. Если играть вдвоём с товарищем, то и компьютер не нужен, поэтому в парных компьютерных играх всегда участвует компьютер. Если компьютер будет играть сам с собой, то это будет совсем неинтересно. Поэтому одним игроком должен быть человек, а вторым – компьютер. Мы обозначим их «константами» в конструкторе класса Game: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # игроки: self.PLAYER = 1 self.COMPUTER = 2 Это очень удобно! Например, счёт игры (число выигранных и проигранных партий) можно хранить в списке result, состоящем трёх элементов – число побед игрока и число побед компьютера: # счёт игры: self.result = [0] * 3 Нулевой элемент в игре не участвует, но задаёт правильную индексацию. 121
Пользуясь этими константами, вы легко узнаете, сколько побед одержал каждый игрок: self.result[self.PLAYER] – число побед игрока self.result[self.COMPUTER] – число побед компьютера Кроме игроков, нам нужны и спички. В переменной numMatch мы будем хранить число спичек в исходной кучке, а в переменной maxGet – максимальное число спичек, которые разрешается взять за 1 ход: # число спичек в кучке: self.numMatch = 0 # макс. число спичек, которые можно взять за 1 раз: self.maxGet = 0 В классическом варианте игры Баше в кучке сначала всегда 15 спичек, а взять можно от 1 до 5 спичек за 1 раз. Из этого следует, что эти переменные можно было бы перевести в константы, но вы быстро убедитесь, что классическая игра Баше слишком проста, поэтому её нужно усложнить и разнообразить, задавая всякий раз новые значения указанным переменным. К примеру, число спичек в кучке можно выбирать случайно из такого диапазона: # мин. и макс. число спичек в кучке: self.MIN_MATCH = 15 self.MAX_MATCH = 30 Вы его можете скорректировать, но чересчур большое число спичек только затягивает игру, не делая её более интересной. А когда спичек очень мало, то игра заканчивается слишком быстро. Число спичек, которые дозволяется взять за 1 ход, мы будем выбирать также случайно из диапазона 1..numMatch // 5. И нам, конечно, не обойтись без переменной, в которой хранится оставшееся в кучке число спичек: # осталось спичек в кучке: self.restMatch = 0 122
Действительно, без неё программа не узнает, что игра закончилась, если спичек больше не осталось. Игры с графическим интерфейсом не могут обойтись без окна. Его размеры обычно корректируются по ходу добавления элементов интерфейса, поэтому я привожу окончательные размеры: # размеры окна в пикселях: WINDOW_WIDTH = 860 WINDOW_HEIGHT = 440 # заголовок окна: WINDOW_TITLE = "Игра Баше" Сначала размеры окна следует брать с запасом. В идеале нужно весь интерфейс нарисовать в графическом редакторе, а затем воплотить в программе, но для новой игры этот способ вряд ли годится. Фоновую картинку я нарисовал собственноручно (Рис. 1) с надеждой и верой, что вы сделаете это лучше. Рис. 1 Чтобы вам было легче ориентироваться в коде, сразу привожу готовое изделие (Рис. 2). 123
Рис. 2 Если игроки захотят ещё сгонять партейку, то им понадобится кнопка НОВАЯ ИГРА. Назначение второй кнопки - БЕРУ - не столь очевидно. Давайте подумаем, а как именно Игрок сможет выполнить ход. Понятно, что настоящих спичек у него нет. В игре их заменяют картинки (Рис. 3). Рис. 3 Красная спичка – это обычная спичка, которая лежит в кучке. Фиолетовая спичка – это выбранная Игроком спичка. Размеры картинок хранятся в константах: # размеры картинок в пикселях: MATCH_WIDTH = 11 MATCH_HEIGHT = 101 Прежде чем забрать спички, Игрок щёлкает по ним мышкой, а затем нажимает упомянутую выше кнопку БЕРУ. 124
Оживляют сугубо интеллектуальную игру немногочисленные звуки. Мы готовы загрузить все графические и звуковые файлы в методе setup: # загружаем картинки --> # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") # нормальная спичка: normalMatch = arcade.load_texture("images/red.png") # выбранная спичка: choseMatch = arcade.load_texture("images/violet.png") # загружвем звуки --> # выбираем спичку: self.chose = arcade.load_sound("sounds/chose.wav") # ошибка: self.error = arcade.load_sound("sounds/error.wav") # победа: self.win = arcade.load_sound("sounds/win.wav") # нажимаем на кнопку: self.press = arcade.load_sound("sounds/button-press.mp3") Вот теперь можно создать все элементы управления: # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() # кнопка: button_newgame = arcade.gui.UITextureButton(texture=arcade.load_texture('images/ng_a.png'), texture_hovered=arcade.load_texture('images/ng_b.png'), texture_pressed=arcade.load_texture('images/ng_c.png')) button_newgame.on_click = self.on_button_newgame_click # кнопка: button_move = arcade.gui.UITextureButton(texture=arcade.load_texture('images/na_a.png'), texture_hovered=arcade.load_texture('images/na_b.png'), texture_pressed=arcade.load_texture('images/na_c.png')) button_move.on_click = self.on_button_move_click # метки --> self.label_result = UILabel(text=self.message, font_size=36, bold=True, width=300, text_color=(255, 0, 255, 160)) self.label_message = UILabel(text=self.message, font_size=40, bold=True, width=600, text_color=arcade.color_from_hex_string('#017638')) self.uimanager.add(UIAnchorWidget(child=self.label_result, align_x=-0, align_y=190)) self.uimanager.add(UIAnchorWidget(child=self.label_message, align_x=0, align_y=- 125
40)) self.uimanager.add(UIAnchorWidget(child=button_move, align_x=-290, align_y=190)) self.uimanager.add(UIAnchorWidget(child=button_newgame, align_x=290, align_y=190)) Для каждой кнопки я приготовил по 3 картинки (Рис. 4). Рис. 4 Спичка – вещь простая, но для удобства пользования мы опишем её целым классом Match: # This Python file uses the following encoding: utf-8 import arcade # КЛАСС СПИЧКИ class Match: def __init__(self, x, y, w, h, normalMatch, choseMatch): self.normalMatch = normalMatch self.choseMatch = choseMatch # координаты картинки: self.xc = x self.yc = y # размеры картинки: self.width = w self.height = h # живая спичка: self.live = True # выбранная: self.chose = False # ВОЗВРАЩАЕТ True, # ЕСЛИ ТОЧКА (px, py) # НАХОДИТСЯ ВНУТРИ КАРТИНКИ def over(self, px, py): x = self.xc - self.width/2 y = self.yc - self.height/2 if self.chose: return (x <= px <= x + self.width and y + 10 <= py <= y + 10 + self.height) 126
else: return (x <= px <= x + self.width and y <= py <= y + self.height) # РИСУЕМ СПИЧКУ def draw(self): # этой спички нет: if not self.live: return # выбранная спичка: if self.chose: self.choseMatch.draw_scaled(self.xc, self.yc + 10) # нормальная спичка: else: self.normalMatch.draw_scaled(self.xc, self.yc) Каждая спичка умеет определять, находится ли на ней курсор, а также рисовать картинку согласно своему игровому статусу: невыбранная спичка – красная, выбранная – фиолетовая и слегка приподнятая – чтобы сразу были видны выбранные спички. Валить все спички ни в кучу, ни в кучку мы не будем, а поместим их в список: # список спичек: self.matches = [] for i in range(self.MAX_MATCH): self.matches.append(Match(i * 25 + 20, 180, MATCH_WIDTH, MATCH_HEIGHT, normalMatch, choseMatch)) Достаточно загрузить все спички в методе setup, а затем отмечать «живые» спички, придавая значение True полю live. Число выбранных спичек сохраним в переменной chose_match: # число выбранных спичек: self.chose_match = 0 Как обычно, флажок isGameOver показывает состояние игры: # состояние игры: self.isGameOver = True 127
Но прежде чем приступить к игре, нужно к ней подготовиться, вызвав метод newGame: # начинаем игру: self.newGame() Этот метод загадывает число спичек в кучке: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # загадываем число спичек в кучке: self.numMatch = randint(self.MIN_MATCH, self.MAX_MATCH) Делается это случайным образом, чтобы сбить игроков с толку. А дальше он определяет, сколько спичек за 1 раз игрок может взять в горсть или в охапку. Для классической игры это число всегда известно, но для случайных игр это сделать необходимо: # загадываем число спичек, # которые можно взять за 1 раз: self.maxGet = randint(1, self.numMatch // 5) if (self.maxGet < 3): self.maxGet = 3 Переменная restMatch получает значение numMatch – перед началом новой игры в кучке «осталось» ровно столько спичек, сколько их в полной, исходной кучке: # осталось в кучке: self.restMatch = self.numMatch Оставляем в игре ровно numMatch спичек: for i in range(self.MAX_MATCH): self.matches[i].chose = False if (i < self.numMatch): self.matches[i].live = True else: self.matches[i].live = False И ни одна из них пока не выбрана: 128
# ни одна спичка не выбрана: self.chose_match = 0 Первый ход всегда делает Игрок: # ход Игрока: self.player = self.PLAYER # игра началась: self.isGameOver = False Для случайной игры это послабление не имеет значения, а для классического варианта чрезмерно. Каждая новая игра начинается с вызова метода newGame: # НАЖИМАЕМ КНОПКУ "НОВАЯ ИГРА" def on_button_newgame_click(self, event): self.press.play() self.newGame() Всю эту сказочную красоту показывает на экране метод on_draw: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) self.draw_matches() self.drawInfo() self.uimanager.draw() Впрочем, он не сильно утруждает себя, а вызывает другие методы. Благодаря методу draw, который имеется у всех спичек, нарисовать их не составляет ни малейшего труда: # РИСУЕМ СПИЧКИ def draw_matches(self): # рисуем спички: for m in self.matches: m.draw() 129
Затем мы печатаем все игровые надписи: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # правила игры: message = f"Число спичек в кучке равно {self.numMatch}. За один раз можно взять 1..{self.maxGet}." #text(u"", 20, 120) arcade.draw_text(message, 20, WINDOW_HEIGHT - 105, arcade.color.BLUE, font_size=16, bold=False) message = "Победит тот, кто возьмёт последнюю спичку." arcade.draw_text(message, 20, WINDOW_HEIGHT - 130, arcade.color.BLUE, font_size=16, bold=False) # число оставшихся спичек: if (self.restMatch): message = f" = {self.restMatch}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 275, arcade.color.RED, font_size=22, bold=True) # печатаем текущего игрока: message = 'Ход ' if (self.player == self.PLAYER): message += 'Игрока' else: message += 'Компьютера' arcade.draw_text(message, 20, WINDOW_HEIGHT - 190, (0, 120, 0), font_size=20, bold=False) # счёт игры: strs = f"Счёт {self.result[self.PLAYER]} : {self.result[self.COMPUTER]}" self.label_result.text = strs # и сообщение компьютера: if self.winner == self.COMPUTER: self.label_message.text = 'Победил Компьютер' elif self.winner == self.PLAYER: self.label_message.text = 'Победил Игрок' else: self.label_message.text = '' После каждого хода появляется сообщение: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): self.message = '' # сообщение компьютера: arcade.draw_text(self.message, 20, WINDOW_HEIGHT - 345, arcade.color_from_hex_string('#7D1F08'), font_size=20, bold=False) 130
После подготовки начинается собственно игра. Соперники по очереди забирают из кучки спички – до тех пор, пока один из них не заберёт последнюю спичку. На этом текущая партия заканчивается. Первый ход принадлежит Игроку. Сначала он должен поочерёдно выбрать спички, которые хочет взять: # НАЖИМАЕМ КНОПКУ МЫШКИ # ИГРОК ВЫБИРАЕТ СПИЧКИ def on_mouse_press(self, x, y, button, modifiers): # игра закончена: if self.isGameOver: return if self.player != self.PLAYER: return Перебираем в цикле for все спички и выискиваем среди них «живую», по которой кликнула мышка. Уже выбранную спичку можно снова сделать невыбранной, если щёлкнуть её повторно: # ищем нажатую спичку: for i in range(self.MAX_MATCH): # нашли: if self.matches[i].live and\ self.matches[i].over(x, y): # ранее выбранная спичка # становится невыбранной: if (self.matches[i].chose): self.matches[i].chose = False self.chose_match -= 1 # невыбранная спичка # становится выбранной: else: self.matches[i].chose = True self.chose_match += 1 Общее число выбранных спичек хранится в переменной chose_match. Игрок не имеет права выбрать больше спичек, чем предусматривают правила игры. В случае перебора он получает звуковой сигнал: # Игрок выбрал больше спичек, # чем разрешают правила: if (self.chose_match > self.maxGet): # звук ошибки: self.error.play() # щелчок по спичке: 131
else: self.chose.play() # стираем сообщение: self.message = '' # цикл прекращаем # одним кликом можно # выбрать только 1 спичку: break Каждый игрок на своём ходе обязан взять хотя бы 1 спичку. Пропускать ход не разрешается. Максимальное число спичек изменяется от игры к игре. В данном примере можно взять 1, 2 или 3 спички. Рис. 5 показывает, что Игрок выбрал 3 спички. Обычно спички берут с правого края, но это необязательно. Вы можете выбирать любые спички. Рис. 5 Так как компьютер не может знать заранее, сколько спичек возьмёт Игрок, то он ждёт нажатия на кнопку БЕРУ. Если Игрок, несмотря на предупреждение, выбрал (или не выбрал) недопустимое число спичек, то он снова получит звуковой сигнал, и ход не будет выполнен: # ИГРОК БЕРЁТ СПИЧКИ def on_button_move_click(self, event): self.movePlayer() 132
# ИГРОК БЕРЁТ СПИЧКИ def movePlayer(self): # игра уже закончена: if self.isGameOver: return if (self.player != self.PLAYER): return # Игрок выбрал слишком много спичек: if (self.chose_match > self.maxGet or self.chose_match == 0): # ошибка: self.error.play() return Если Игрок попался внимательный и законопослушный, то на экране появится надлежащее сообщение, выбранные спички удаляются, а ход переходит к Компьютеру: # звук нажатия на кнопку: self.press.play() # печатаем сообщение: self.message = f"Игрок взял: {self.chose_match}" self.drawInfo() # удаляем спички: t1 = Timer(1, self.deleteMatches, args=None, kwargs=None) t1.start() # передаём ход Компьютеру: t2 = Timer(2, self.moveComputer, args=None, kwargs=None) t2.start() Ситуация в игре после первого хода Игрока (Рис. 6 и 7). За удаление спичек из кучки отвечает метод deleteMatches: # УДАЛЯЕМ ВЫБРАННЫЕ СПИЧКИ def deleteMatches(self): # print "deleteMatches()" # осталось спичек: self.restMatch -= self.chose_match # удаляем выбранные спички: for i in range(self.MAX_MATCH): if (self.matches[i].chose): self.matches[i].live = False # выбранных спичек нет: self.chose_match = 0 133
self.isWin() # ход Игрока: if not self.isGameOver: self.player = self.PLAYER Рис. 6 Рис. 7 134
Компьютеру ни мышка, ни кнопка не нужны, потому что он выполняет ход иначе. Если очередь хода дошла до Компьютера, то в кучке ещё остались спички. По крайней мере, одну спичку он может взять всегда. Максимальное число спичек для хода, как вы помните, хранится в переменной maxGet. Но далеко не факт, что в кучке столько спичек – в конце партии их может оказаться и меньше. Поэтому нужно выбрать меньшее из двух чисел – maxGet и restMatch: # КОМПЬЮТЕР БЕРЁТ СПИЧКИ def moveComputer(self): if self.isGameOver: return # ход Компьютера: self.player = self.COMPUTER # можно взять: ns = min(self.maxGet, self.restMatch) В этой игре Компьютер почти лишён разума и ходит совершенно случайно, то есть наобум. Но немного интеллекта мы ему всё-таки добавим. Если Компьютер может победить на своём ходе, то он забирает все оставшиеся спички: # компьютер может забрать все спички # и победить: if (ns == self.restMatch): ns = self.restMatch В противном случае он, не мудрствуя лукаво, выбирает случайное число спичек из дозволенного диапазона: # иначе Компьютер делает # случайный ход: else: ns = randint(1, ns) Компьютер одним махом выбирает все спички, и теперь нужно показать их на экране: # выбор сделан: self.chose_match = ns 135
# Компьютер выбирает спички справа: for i in range(self.MAX_MATCH): n = self.MAX_MATCH - i - 1 if (self.matches[n].live): self.matches[n].chose = True ns -= 1 if (ns == 0): break Компьютер выбрал 2 спички. Игрок получает сообщение о ходе Компьютера (Рис. 8): # печатаем сообщение: self.message = f"Компьютер взял: {self.chose_match}" t = Timer(2, self.deleteMatches, args=None, kwargs=None) t.start() Рис. 8 Через некоторое время спички удаляются, а ход возвращается к Игроку (Рис. 9). 136
Рис. 9 В результате хода любого игрока в кучке останется restMatch спичек. Если значение этой переменной положительное, значит, игра не закончена: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): if (self.isGameOver): return # если спички ещё остались, # то игра продолжается: if (self.restMatch > 0): return Если же значение равно нулю, игра заканчивается, и в ней победил игрок, выполнивший текущий ход: # победитель игры: self.winner = self.player Изменяем счёт игр в пользу победителя и тут же обновляем его на экране: # победитель игры: self.winner = self.player # изменяем счёт: self.result[self.winner] += 1 137
# стираем сообщение: self.message = '' Недолго музыка играет, после чего игра заканчивается: # музыка для победителя: self.win.play() # игра закончена: self.isGameOver = True Если Игрок плохо думает наперёд, то победит Компьютер (Рис. 10). Рис. 10 Чтобы начать новую партию, нажмите кнопку НОВАЯ ИГРА. Если вы не знаете выигрышной стратегии, то постарайтесь просчитать свои действия на несколько ходов вперёд. Пока догадаетесь, как нужно играть, ещё сумеете хорошенько повеселиться. В дебюте и в миттельшпиле над ходами можно особенно не задумываться, а вот в эндшпиле нужно играть очень аккуратно, и тогда победа будет за вами (Рис. 11). 138
Рис. 11 Искусственный интеллект Обычно Компьютер наделяют искусственным интеллектом, проблески которого можно найти и у нашего железного друга. Например, он знает, что нужно брать спички из кучки, а не извлекать из них квадратный корень. Более того, он берёт правильное число спичек, а не любое, поэтому его ходы могут быть даже лучшими в данной позиции. Если вы хотите, то можете пойти дальше и развить его интеллект. Сделать это несложно, поскольку для игры Баше уже давно найдены простые правила, позволяющие побеждать в каждой игре (если разрешено выбирать, кто из игроков начинает игру). Вы можете прочитать об этом в моей книге Программирование на языке C# 5. Комбинаторные игры и головоломки (Рис. 12). Рис. 12 К сожалению, зная секрет игры, вы вряд ли захотите в неё играть. Действительно, когда знаешь всё наперёд, становится неинтересно. 139
Как организовать паузу в игре? В некоторых случаях нужно задержать выполнение программы на небольшое время. Например, мы не сразу передаём ход Компьютеру, а через pause миллисекунд: # задержка хода Компьютера: self.pause = 0.400 Этого времени вполне достаточно, чтобы Игрок успевал следить за изменением ситуации поле. После выбора спичек Компьютером также нужна задержка, чтобы спички не сразу исчезли с экрана. В Питоне есть асинхронные способы задержки выполнения заданной функции, и тогда вся остальная часть программы выполняется во время паузы. Импортируем класс Timer из модуля threading: from threading import Timer В методе movePlayer создаём экземпляр класса Timer. При этом в конструктор передаём время задержки в секундах, а затем имя функции, которая будет вызвана после окончания паузы. Остальные параметры не нужны: # удаляем спички: t1 = Timer(1, self.deleteMatches, args=None, kwargs=None) t1.start() # передаём ход Компьютеру: t2 = Timer(2, self.moveComputer, args=None, kwargs=None) t2.start() Так же мы поступаем и в методе moveComputer: t = Timer(2, self.deleteMatches, args=None, kwargs=None) t.start() Как видите, задержку времени в программах на Питоне выполнить совсем несложно. 140
Игра #7. Ножки вверх! Седьмая игра малоизвестная, но очень интересная. Можно даже сказать, что это не игра, а настоящая головоломка! Чёт и нечет Проверка на чётность – достаточно простой приём, который помогает, решать даже сложные задачи. Например, Евклид (или его предшественники из пифагорейской школы) с его помощью доказал иррациональность квадратного корня из двойки. Она бывает полезна и при нахождении решений перестановочных и топологических задач, а также многих головоломок. Но вернёмся к нашим заботам. Для плавного перехода к теме рассмотрим шуточный фокус с тремя пустыми бокалами (годятся и стаканы, и другая посуда). Поставьте их на стол так, чтобы два бокала стояли ножками (донышками) вверх, а между ними находился один бокал ножкой вниз (Рис. 1). Рис. 1 Необходимо за три «хода», переворачивая каждый раз одновременно два бокала, поставить все бокалы ножками вниз (Рис. 2). Сделать это нетрудно. Рис. 2 1. Переворачиваем первый и второй бокалы (Рис. 3). Рис. 4 141
«Проверкой на чётность» занимаются и девушки, гадающие на ромашке, - «любит-не любит», и сатирикюморист Михаил Задорнов, убеждённый, что женщинам следует дарить исключительно нечётное число цветов, и «менеджеры по продажам», уверенные, что покупатель с большим удовольствием выложит 35 рублей, нежели 32. Вообще, люди с удивительным упорством предпочитают одни числа другим, например тройка и семёрка явно предпочтительнее двойки или восьмёрки. Очень любимы числа, оканчивающие на 0 и 5 (вспомните юбилейные даты). Далее следуют числа, оканчивающиеся на 2, 3, 7, 6, 4, 9, 1. Причина тёплого отношения к «круглым» числам, конечно, заключается в десятичной системе счисления, а вот с остальными всё более загадочно. Впрочем, и природа в целом не одинаково относится к чётным и нечётным числам. Так, большинство животных (исключая иглокожих и головоногих) имеет чётное количество конечностей (следствие симметричности их тела), химические элементы с нечётными порядковыми номерами обычно более редки, чем соседние с ними чётные, а потому и более дороги. Теплота плавления углеводородов, начиная с гептана, имеющих чётное число атомов углерода в цепочке, заметно выше, чем у соседних с ними нечётных углеводородов. И эти примеры можно продолжать и продолжать… 2. Переворачиваем первый и третий бокалы (Рис. 5). Рис. 5 3. Переворачиваем первые два бокала – задача решена (Рис. 6). 142
Рис. 6 Теперь незаметно переверните средний бокал ножкой вверх (Рис. 7) Рис. 7 и предложите кому-нибудь повторить фокус. Если вы сравните последнюю картинку с первой, то легко заметите, что положение бокалов изменилось, и теперь головоломку невозможно решить за любое число ходов. И вот почему. В первом случае в правильном положении (ножкой вниз) находится 1 бокал, а в конечном положении – 3. Оба эти числа нечётные. Во втором случае в правильном положении находятся 2 бокала – чётное число, а в конечном положении те же 3 бокала - нечётное число. Но при одновременном переворачивании двух бокалов чётность «системы» не изменяется. Если мы перевернём из положения (Рис. 8) Рис. 8 два бокала ножками вверх (первый и третий), то в правильном положении окажется 0 бокалов – чётное число (будем считать, что это так). Если перевернуть другую пару бокалов, то ситуация вообще не изменится. Легко проверить, что и следующие ходы не нарушат чётности позиции, а это значит, что она никогда не станет нечётной и фокус повторить не удастся. Пусть в начальной позиции все бокалы стоят правильно – ножками вниз (Рис. 9) 143
Рис. 9 а в конечной – все ножками вверх (Рис. 10) Рис. 10 Первая позиция нечётная, вторая – чётная, так что «фокус не удастся». Но попробуем решить общую задачу (см. журнал Наука и жизнь, №10 за 1982 год). Пусть имеется m бокалов ножками вниз, из которых за один раз можно перевернуть n любых бокалов. Требуется за минимальное число ходов получить позицию, в которой все бокалы находятся ножками вверх. Давайте напишем программу для решения таких головоломок. Проект Ножки вверх! Не правда ли, эта головоломка очень похожа на игру Баше? Только играть в неё нужно одному, а место спичек заняли бокалы. Они могут быть нормальными и выбранными – как спички. Но в отличие от спичек бокалы могут занимать 2 положения – ножкой вниз и ножкой вверх. Значит, нам нужны 4 картинки (Рис. 1). Рис. 11 Окрасим нормальные бокалы в синий цвет, а выбранные – в красный. Высота картинок не изменилась, но бокалы стали заметно «толще»: 144
# размеры картинок в пикселях: MATCH_WIDTH = 61 MATCH_HEIGHT = 101 Импортируемые модули и константы: # Игра "Ножки вверх!" import arcade import arcade.gui from arcade.gui import UIAnchorWidget, UILabel from random import randint from Bokal import Bokal from threading import Timer # размеры окна в пикселях: WINDOW_WIDTH = 860 WINDOW_HEIGHT = 440 # заголовок окна: WINDOW_TITLE = "Ножки вверх!" # цвет фона: BACKGROUND_COLOR = arcade.color_from_hex_string('#96E0E0') Звуки вполне сгодятся из предыдущего проекта, так что нам нужно обновить только половину метода setup: # ПОДГОТОВКА def setup(self): # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() # кнопка: button_newgame = arcade.gui.UITextureButton(texture=arcade.load_texture('images/ng_a.png'), texture_hovered=arcade.load_texture('images/ng_b.png'), texture_pressed=arcade.load_texture('images/ng_c.png')) button_newgame.on_click = self.on_button_newgame_click # кнопка: button_move = arcade.gui.UITextureButton(texture=arcade.load_texture('images/na_a.png'), texture_hovered=arcade.load_texture('images/na_b.png'), texture_pressed=arcade.load_texture('images/na_c.png')) button_move.on_click = self.on_button_move_click 145
# метки --> self.label_result = UILabel(text=self.message, font_size=36, bold=True, width=300, text_color=(255, 0, 255, 160)) self.label_message = UILabel(text=self.message, font_size=40, bold=True, width=600, text_color=arcade.color.RED) self.uimanager.add(UIAnchorWidget(child=self.label_result, align_x=-0, align_y=-190)) self.uimanager.add(UIAnchorWidget(child=self.label_message, align_x=0, align_y=-30)) self.uimanager.add(UIAnchorWidget(child=button_move, align_x=-290, align_y=190)) self.uimanager.add(UIAnchorWidget(child=button_newgame, align_x=290, align_y=-190)) # загружаем картинки --> # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") # нормальный бокал вверх: upBlue = arcade.load_texture("images/upblue.png") # нормальный бокал вниз: downBlue = arcade.load_texture("images/downblue.png") # выбранный бокал вверх: upRed = arcade.load_texture("images/upred.png") # выбранный бокал вниз: downRed = arcade.load_texture("images/downred.png") # список картинок: bokaly_lst = [upBlue, downBlue, upRed, downRed] # создаём все бокалы: for i in range(self.MAX_BOKALY): self.bokaly.append(Bokal(i * 65 + 50, 190, BOKAL_WIDTH, BOKAL_HEIGHT, bokaly_lst)) # загружвем звуки --> # выбираем бокал: self.chose = arcade.load_sound("sounds/chose.wav") # ошибка: self.error = arcade.load_sound("sounds/error.wav") # победа: self.win = arcade.load_sound("sounds/win.wav") # нажимаем на кнопку: self.press = arcade.load_sound("sounds/button-press.mp3") # начинаем игру: self.newGame() Левая кнопка теперь отвечает за переворот выбранных бокалов, поэтому исправляем надпись на ней (Рис. 12). Рис. 12 146
В методе on_draw мы окрашиваем фон – в этой игре нет фоновой картинки: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.background_color = BACKGROUND_COLOR Вместо спичек рисуем бокалы: self.draw_bokaly() self.drawInfo() # РИСУЕМ БОКАЛЫ def draw_bokaly(self): for b in self.bokaly: b.draw() Полупрозрачный прямоугольник появится на экране, когда Игрок перевернёт все бокалы ножками вверх, и напустит туману на бокалы и надписи, чтобы поздравление победителю было наглядно видно: if self.isGameOver: arcade.draw_lrtb_rectangle_filled(0, WINDOW_WIDTH-1, WINDOW_HEIGHT-1, 0, (255,255,255, 160)) self.uimanager.draw() Метод drawInfo печатает правила игры, подсказку Игроку и другую полезную информацию: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # правила игры: message = f"Переверните все бокалы ножками вверх!" arcade.draw_text(message, 15, WINDOW_HEIGHT - 45, arcade.color.RED, font_size=32, bold=False) message = f"Число бокалов равно {self.nBok}. За один раз нужно перевернуть {self.nPerev}." arcade.draw_text(message, 20, WINDOW_HEIGHT - 85, arcade.color.BLUE, font_size=20, bold=False) # печатаем подсказку: 147
self.hint() # печатаем номер хода: message = f"Ход {self.nMove}" arcade.draw_text(message, 20, WINDOW_HEIGHT - 170, (0, 120, 0), font_size=24, bold=False) # число неперевёрнутых бокалов: clr = (0, 120, 0) if (self.restBok): if (self.restBok % self.nPerev != 0): clr = (255, 0, 0) message = f"+{self.restBok}" else: message = '' arcade.draw_text(message, WINDOW_WIDTH - 60, WINDOW_HEIGHT - 250, clr, font_size=24, bold=False) # сообщение компьютера: arcade.draw_text(self.message, 20, WINDOW_HEIGHT - 345, arcade.color_from_hex_string('#7D1F08'), font_size=20, bold=False) # победная надпись: if self.isGameOver: self.label_message.text = 'ВЫ ЭТО СДЕЛАЛИ!' На картинке представлен интерфейс новой программы (Рис. 13). Рис. 13 Число +12 показывает, сколько ещё бокалов нужно перевернуть. Всё остальное понятно без лишних слов. Класс бокала немного сложнее спичечного, но только потому, что бокалы могут занимать 2 положения – ножкой вниз или вверх: 148
# This Python file uses the following encoding: utf-8 import arcade # КЛАСС БОКАЛА class Bokal: def __init__(self, x, y, w, h, bokaly_lst): self.bokaly_lst = bokaly_lst # координаты картинки: self.xc = x self.yc = y # размеры картинки: self.width = w self.height = h # живой бокал: self.live = True # выбранный: self.chose = False # вверх? self.up = True # ВОЗВРАЩАЕТ True, # ЕСЛИ ТОЧКА (px, py) # НАХОДИТСЯ ВНУТРИ КАРТИНКИ def over(self, px, py): x = self.xc - self.width/2 y = self.yc - self.height/2 if self.chose: return (x <= px <= x + self.width and y + 10 <= py <= y + 10 + self.height) else: return (x <= px <= x + self.width and y <= py <= y + self.height) # РИСУЕМ БОКАЛ def draw(self): # этого бокала нет: if (not self.live): return # выбранный бокал: if (self.chose): if (self.up): self.bokaly_lst[2].draw_scaled(self.xc, self.yc + 10) else: self.bokaly_lst[3].draw_scaled( self.xc, self.yc + 10) # нормальный бокал: else: if (self.up): self.bokaly_lst[0].draw_scaled(self.xc, self.yc) 149
else: self.bokaly_lst[1].draw_scaled(self.xc, self.yc) В конструкторе класса Game задаём диапазон числа бокалов и создаём список бокалов: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # мин. макс. число бокалов: self.MIN_BOKALY = 7 self.MAX_BOKALY = 12 # список бокалов: self.bokaly = [] В методе setup создаём все бокалы и отправляем их в список bokaly: # создаём все бокалы: for i in range(self.MAX_BOKALY): self.bokaly.append(Bokal(i * 65 + 50, 190, BOKAL_WIDTH, BOKAL_HEIGHT, bokaly_lst)) Общее число бокалов в данной партии храним в переменной nBok: # число бокалов: self.nBok = 0 Второй по важности параметр игры – число бокалов, которые игрок должен перевернуть за 1 ход, - nPerev: # число бокалов, которые нужно # перевернуть за 1 раз: self.nPerev = 0 Переменная restBok подсчитывает число ещё не перевёрнутых бокалов: # осталось неперевёрнутых бокалов: self.restBok = 0 150
А переменная chose_bokaly – число выбранных бокалов: # число выбранных бокалов: self.chose_bokaly = 0 Ходы считать не обязательно, но интересно, ведь задачу нужно решить за наименьшее число ходов: # число ходов: self.nMove = 0 # сообщение: self.message = ' ' # состояние игры: self.isGameOver = True self.setup() Каждая новая игра начинается с метода newGame. Чтобы разнообразить игру, мы каждый раз выбираем случайное число бокалов из допустимого диапазона: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): self.message = "" self.label_message.text = '' # загадываем число бокалов: self.nBok = randint(self.MIN_BOKALY, self.MAX_BOKALY) Так же случайно мы выбираем и число разом переворачиваемых бокалов: # загадываем число бокалов, # которые нужно перевернуть за 1 раз: self.nPerev = randint(2, 5) Один бокал переворачивать бессмысленно, а больше пяти – утомительно. Тут мы должны учесть, что все задачи должны быть решаемы, поэтому при плохом «раскладе» просто добавляем ещё 1 бокал для переворачивания: 151
# если число бокалов нечётное, а переворачиваем чётное # число бокалов, то задача неразрешима. # Добавляем ещё 1 бокал: if ((self.nBok % 2 == 1) and (self.nPerev % 2 == 0)): self.nPerev += 1 В начале игры осталось перевернуть ровно столько бокалов, сколько их всего и есть: # осталось перевернуть бокалов: self.restBok = self.nBok Вполне вероятно и скорее всего не все бокалы примут участие в текущей игре, поэтому их переменной live присваиваем значение False. Все бокалы смотрят вверх (ножкой вниз – не запутайтесь!) и не выбраны: # ни один бокал не выбран: self.chose_bokaly = 0 # ход 1: self.nMove = 1 for i in range(self.MAX_BOKALY): self.bokaly[i].chose = False self.bokaly[i].up = True if (i < self.nBok): self.bokaly[i].live = True else: self.bokaly[i].live = False # игра началась: self.isGameOver = False Картинку с начальной позицией вы уже видели. Теперь игрок выполняет ход. Для этого он щёлкает по бокалам, чтобы выбрать их. Метод on_mouse_press не сильно изменился по сравнению с игрой в спички. Появилось только сообщение о числе уже выбранных бокалов: # НАЖИМАЕМ КНОПКУ МЫШКИ # ИГРОК ВЫБИРАЕТ СПИЧКИ def on_mouse_press(self, x, y, button: int, modifiers: int): # игра закончена: if self.isGameOver: return # ищем нажатый бокал: 152
for i in range(self.MAX_BOKALY): # нашли: if (self.bokaly[i].live and\ self.bokaly[i].over(x, y)): # ранее выбранный бокал # становится невыбранным: if (self.bokaly[i].chose): self.bokaly[i].chose = False self.chose_bokaly -= 1 # невыбранный бокал # становится выбранным: else: self.bokaly[i].chose = True self.chose_bokaly += 1 # Игрок выбрал больше бокалов, # чем допускают правила: if (self.chose_bokaly > self.nPerev): # звук ошибки: self.error.play() # щелчок по бокалу: else: self.chose.play() # сообщение: self.message = f"Выбрали: {self.chose_bokaly}" # цикл прекращаем # одним кликом можно # выбрать только 1 бокал: break Игрок должен выбрать ровно столько бокалов, сколько ему предписано условиями игры. Например, в этой партии нужно выбрать 4 бокала (Рис. 14). Выбранные бокалы краснеют и немного приподнимаются над остальными. Чтобы не считать их, мы печатаем надпись с числом выбранных бокалов. Все бокалы выбраны, и игрок нажимает кнопку ПЕРЕВОРОТ (Рис. 15). После нажатия на эту кнопку программа попадает в метод-обработчик movePlayer, который проверяет правильность хода игрока: # ИГРОК ПЕРЕВОРАЧИВАЕТ БОКАЛЫ def on_button_move_click(self, event): self.movePlayer() def movePlayer(self): # Игрок выбрал неверное число бокалов: 153
if (self.chose_bokaly != self.nPerev): # ошибка: self.error.play() return Рис. 14 Рис. 15 154
Если игрок крутил и вертел бокалы согласно регламенту, то мы печатаем надлежащее сообщение и переходим в метод turn для переворачивания выбранных бокалов: # печатаем сообщение: self.message = f"Перевернули: {self.chose_bokaly}" # переворачиваем бокалы: self.turn() В методе turn все живые и выбранные бокалы становятся невыбранными и переворачиваются: # ПЕРЕВОРАЧИВАЕМ ВЫБРАННЫЕ БОКАЛЫ def turn(self): # переворачиваем: for i in range(self.MAX_BOKALY): if (not self.bokaly[i].live): continue if (self.bokaly[i].chose): self.bokaly[i].chose = False self.bokaly[i].up = not self.bokaly[i].up Чтобы облегчить подсчёты неперевёрнутых бокалов, мы просто пересчитываем, сколько живых бокалов стоит гордо вверх: # осталось неперевёрнутых бокалов: self.restBok = 0 for b in self.bokaly: if (b.live and b.up): self.restBok += 1 # выбранных бокалов нет: self.chose_bokaly = 0 Каждый ход может стать победным, что мы и проверяем в методе isWin: # игра закончилась? self.isWin() # увеличиваем счётчик ходов: if not self.isGameOver: self.nMove += 1 Определить, закончилась ли игра, совсем несложно. Если все бокалы перевёрнуты, то значение поля restBok нулевое: 155
# ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): if (self.isGameOver): return # если не все бокалы перевёрнуты, # то игра продолжается: if (self.restBok > 0): return Проиграть игрок не может, поэтому рано или поздно он получит красную надпись с поздравлением: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): if (self.isGameOver): return # если не все бокалы перевёрнуты, # то игра продолжается: if (self.restBok > 0): return # стираем сообщение: self.message = "" # музыка для победителя: self.win.play() # игра закончена: self.isGameOver = True Вот эта надпись (Рис. 16). Рис. 16 156
Было бы странно, если бы игрок удовольствовался единственной победой над бокалами, поэтому он нажимает кнопку НОВАЯ ИГРА: # НАЖИМАЕМ КНОПКУ "НОВАЯ ИГРА" def on_button_newgame_click(self, event): self.press.play() self.newGame() Как решить задачу? Вы могли заметить строчку под условием игры. Я намеренно напечатал её бледным цветом, чтобы она не подсказывала игроку раньше времени, как именно нужно переворачивать бокалы (Рис. 17). Рис. 17 Давайте решим задачу, показанную на Рис. 17⬆ (Рис. 18). Рис. 18 Если вы начнёте бессистемно вертеть бокалы, то рано или поздно эту задачу решите (не такая уж она и сложная), да что толку – со следующей 157
вы будете мучиться ничуть не меньше. Лучше сразу всё хорошенько обдумать, а затем играть «со смыслом»! Заметим, что, сколько бы раз мы бокалы ни переворачивали, всё равно общее число перевёрнутых бокалов будет кратно трём (мы ведь переворачиваем по три бокала!). Но семь на три не делится, поэтому просто так их не перевернуть. Легко за два раза перевернуть 6 бокалов, но тогда останется только 1 «неправильный» бокал, и вместе с ним нам придётся перевернуть и 2 правильных. Они станут неправильными и, возвращая их в нужное положение, мы опять прихватим 1 правильный бокал. В общем, играть нужно не так! Первый ход очевиден – мы должны перевернуть ножками вверх любые три бокала (Рис. 19). Рис. 19 Столь же очевиден и последний ход – нам предстоит перевернуть ножками вверх какие-то три бокала (Рис. 20). Рис. 20 Вся хитрость - в промежуточных ходах. Точнее, даже в одном ходе. Попробуем его вычислить. Ясно, что за два хода задачу не решить, так как мы сумеем перевернуть только 6 бокалов. За три хода мы перевернём 9 бокалов, а нужно 7. Но 7 – это те бокалы, которые стояли вначале ножками вниз, а ведь после первого хода появляются и бокалы, стоящие ножками вверх. Если один из них вернуть в исходное положение (ножкой вниз), то по ходу решения мы перевернём уже 8 бокалов. Но мы обязаны перевернуть бокал ещё один раз, иначе он так и будет стоять ножкой вниз! Таким образом, при этих операциях мы добавили к семи переворачиваемым бокалам ещё два. Итого получилось ровно 9, что и требуется! Теперь всё предельно ясно: делая второй ход, мы должны возвратить в исходное положение один из тех бокалов, что был перевёрнут на первом ходу (Рис. 21). 158
Рис. 21 В расчётах мы не ошиблись – осталось ровно три бокала ножками вниз, которые и нужно перевернуть на третьем ходу. Решим более сложную задачу. Пусть теперь имеется 17 бокалов, а переворачивать нужно по 5 бокалов одновременно. В лучшем случае задачу можно решить за 4 хода, перевернув 20 бокалов. То есть к 17 уже имеющимся следует добавить ещё три. А это невозможно: мы показали, что дополнительные бокалы берутся из уже перевёрнутых ножками вверх, при этом любой из них придётся переворачивать два раза. Но три на два не делится, поэтому никакие ухищрения не помогут. Придётся накинуть ещё один ход, тогда мы перевернём 25 бокалов, из которых 25 – 17 = 8 «лишних». Их могут дать 8 : 2 = 4 возвращаемых в исходное положение бокалов. Итак, для решения задачи потребуется 5 ходов. На первом и последнем мы переворачиваем по 5 бокалов, стоящих ножками вниз. А вот во время оставшихся трёх ходов нам и следует вернуть в исходное положение 4 бокала. Причём совершенно безразлично, как мы разделим эти бокалы между ходами – 4+0+0, 3+1+0, 2+2+0, … Стало быть, задача имеет несколько вариантов решения длиной в 5 ходов. Нам осталось изложить открытый нами алгоритм на Питоне: # ПЕЧАТАЕМ ПОДСКАЗКУ def hint(self): if (self.isGameOver): return m = self.nBok n = self.nPerev i = 0 while ((n * i < m) or ((n * i - m) % 2 != 0)): i += 1 # подсказка: message = f"Число ходов = {i}." message += f" Число возвращаемых бокалов = {(n * i - m) // 2}" arcade.draw_text(message, 20, WINDOW_HEIGHT - 120, (255, 255, 255, 45), font_size=20, bold=False) 159
Вычисления проводятся в цикле while: while ((n * i < m) or ((n * i - m) % 2 != 0)): i += 1 Он выполняется до тех пор, пока число, кратное числу переворачиваемых бокалов (n*i), меньше общего числа бокалов или разность (n*i - m) не делится на два. О необходимости (и достаточности) выполнения этих условий мы только что говорили. Теперь вы сможете не только успешно переворачивать бокалы, но и делать это непринуждённо! Конечно, вам уже кажется, что нет таких задач, с которыми вы бы не справились. Тогда попробуйте такую. В ряд расставлены 6 бокалов, причём первые три с вином, последние три пустые (Рис. 22). Рис. 22 Вы можете взять в руку только один бокал. Что нужно сделать, чтобы бокалы с вином и пустые чередовались? Ответ вы найдете ниже. Задания для самостоятельного решения 1. Можно ли решить «бокальную» задачу при условии, что допускается одновременно переворачивать только идущие подряд бокалы? Тривиальные сочетания m и n типа 6 и 3 не учитывайте. 2. Условие то же, что и в предыдущем пункте, но бокалы располагаются кольцом, то есть первый и последний бокалы стоят рядом. 3. Вероятно, задача неразрешима при условиях 1 и 2 (попробуйте доказать или опровергнуть это предположение!), поэтому поищите задачи, в которых начальное положение бокалов отличается от стандартного (все ножками вниз). Очевидно, что существует множество таких задач (их 160
легко получить из конечной позиции, делая ходы тем или иным способом). Вопрос в том, могут ли при этом возникнуть интересные расположения бокалов или необычные последовательности ходов. Ответ неожиданно прост (быстро ли вы до него додумались?) – нужно перелить вино из второго бокала в пятый и вернуть его на место. Оп-ля! 161
Игра #8. Охота на Скалоеда «Компьютер» докомпьютерной эры В компьютерные игры сражались уже тогда, когда даже самые простые компьютеры были недоступны большинству граждан тогдашнего Советского Союза. Их место занимали программируемые калькуляторы (ПМК): БЗ-34, МК-61, МК-52. В журнале Наука и жизнь регулярно публиковались статьи о программировании, а в журнале Техника – молодёжи несколько лет существовал Клуб электронных игр, где читатели делились своими программами (Рис. 1). Рис. 1. Эмблема рубрики В первом номере журнала Техника – молодёжи за 1987 год я нашёл интересную игру Охота на Скалоеда, которую мы вполне можем «портировать» на современные компьютеры. Где-то в глубоком космосе, на неведомой планете жило-было-не-тужило суровое существо – Скалоед. Скалоед непрерывно буровит землю и гранит, выгрызая в них подземные ходы, что роднит его со Студентами, которые тоже постоянно чего-нибудь грызут. По этим ходам Скалоеда преследует отважный Охотник - с вполне естественной целью – уничтожить Скалоеда. 162
Поле битвы, или - по-научному – Лабиринт - можно представить в виде двумерного массива (Рис. 2). Рис. 2. План Лабиринта Нумерация горизонталей противоречит нашим представлениям о верхе и низе, поэтому мы пронумеруем их сверху вниз и начиная с нуля. Естественно, сам чужеземный мир от этого не перевернётся, а продолжит своё бренное существование в полном объёме. В начале охоты Скалоед занимает место в центре Лабиринта. На Рис. 2⬆ его клетка отмечена штриховкой. Охотник затаился с подветренной стороны, в нижнем левом углу, который у нас неминуемо превратится в верхний левый – с координатами (0,0). На рисунке выше Охотник никак не отмечен, но память о нём навсегда останется в наших сердцах, душах и извилинах. Клетки Лабиринта могут быть двух видов: • • Проходимые (свободные ходы) – на рисунке обозначены единицами Непроходимые (скальная порода) – на рисунке обозначены нулями Начальная позиция Охотника должна находиться в проходимой клетке, и двигаться ему позволено только по проходимым клеткам. А вот Скалоед может перемещаться по любым клеткам. При этом он, выгрызая в скальной породе ходы, делает непроходимые клетки проходи- 163
мыми. Но, наткнувшись на проходимую клетку, от заваливает её скальной породой и превращает в непроходимую. Начальная позиция игры может отличаться от той, что показана на Рис. 2⬆, но Охотник обязательно должен занимать проходимую клетку. Охотник и Скалоед выполняют ходы поочерёдно: сначала Охотник, затем – Скалоед. Охотник может переместиться на соседнюю свободную клетку слева, справа, сверху или снизу. Скалоед также может переместиться на соседнюю клетку по этим же направлениям, но он не ограничен в выборе клеток, поскольку для него они все – проходимые. К сожалению, Скалоед ограничен в другом – он сильно обделён интеллектом, поэтому бродит по Лабиринту совершенно случайно, целиком полагаясь на свою планиду, то есть на судьбу. Если в соседней клетке паче чаяния окажется Скалоед, то Охотник торжественно вступает в эту клетку, наносит сопернику удар в темя и отшибает ему оба рога. Журнальная статья умалчивает о драматической развязке истории, когда Скалоед первым оказывается в клетке с незадачливым Охотником и пожирает его глазами. Мы исправим эту оплошность, чтобы усимметрить и угармонизировать взаимоотношения особей и сущностей в природе. Если Скалоед опережает Охотника в борьбе за жизненное пространство, то мы будем вынуждены признать торжество случая над трезвым расчётом Охотника. Проект Охота на Скалоеда Поскольку у нас игра не калькуляторная, а компьютерная, то нам нужно заменить проходимые и непроходимые клетки в Лабиринте картинками. Скалоед грызёт камни, и вполне логично обозначить непроходимые клетки скальной породой коричневого цвета. А для проходимых клеток больше подходит зелёный цвет (Рис. 3). Рис. 3 В папке images есть и более насыщенные картинки. Вы можете использовать их. 164
Герои нашей игры тоже должны выглядеть мужественно и натурально (Рис. 4). Рис. 4 Но это всё картинки в файлах, а нам ещё предстоит загрузить их в программу. Картинки лежат в папке images. Для загрузки в программу нам нужны их названия. Когда чего-то много, то это лучше поместить в списки. В нашем случае в строковый список imageNames: # названия файлов с картинками: imageNames = ["images/skala3.png", "images/hod3.png", "images/ohotnik.png", "images/skaloed.png" ] Настоящей музыки в нашей программе не будет. Мы ограничимся четырьмя звуками, которые хранятся в папке sounds. Вы уже, конечно, догадались, что мы также сохраним их названия в списке: # названия файлов со звуками: soundNames = ["sounds/stepo.mp3", "sounds/wino.wav", "sounds/steps.wav", "sounds/wins.wav" Загрузку всех файлов организуем в методе setup: # загружаем картинки --> imgSkala= arcade.load_texture(imageNames[SKALA]) imgHod = arcade.load_texture(imageNames[HOD]) self.imgOhotnik = arcade.load_texture(imageNames[self.OHOTNIK]) self.imgSkaloed = arcade.load_texture(imageNames[self.SKALOED]) # загружвем звуки --> # ошибка: 165
self.error = arcade.load_sound("sounds/error.wav") # нажимаем на кнопку: self.press = arcade.load_sound("sounds/button-press.mp3") # звук шагов Охотника: self.stepo = arcade.load_sound(soundNames[0]) # победа Охотника: self.wino = arcade.load_sound(soundNames[1]) # звук шагов Скалоеда: self.steps = arcade.load_sound(soundNames[2]) # победа Скалоеда: self.wins = arcade.load_sound(soundNames[3]) Размеры окна сохраним в константах: # размеры окна в пикселях: WINDOW_WIDTH = 820 + 4 WINDOW_HEIGHT = 622 + 2 Из элементов управления нам потребуется 1 кнопка, которая начинает новую игру: # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() # кнопка: button_newgame = arcade.gui.UITextureButton(texture=arcade.load_texture('images/ng_a.png'), texture_hovered=arcade.load_texture('images/ng_b.png'), texture_pressed=arcade.load_texture('images/ng_c.png'), scale=0.8) button_newgame.on_click = self.on_button_newgame_click И 1 метка для оглашения победителя: # метка: self.label_message = UILabel(text=self.message, font_size=40, bold=True, width=600, text_color=arcade.color_from_hex_string('#FF0000')) self.uimanager.add(UIAnchorWidget(child=self.label_message, align_x=-80, align_y=0)) self.uimanager.add(UIAnchorWidget(child=button_newgame, align_x=310, align_y=-280)) 166
В конструкторе игры мы обозначаем все игровые объекты «константами», пользоваться которыми гораздо удобнее, чем числами: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # игроки: self.OHOTNIK = 2 self.SKALOED = 3 # число клеток по горизонтали # и вертикали: self.SIZE = 10 # координаты Скалоеда: self.skaloedCoods = {} # координаты Охотника: self.ohotnikCoords = {} # счёт игры: self.result = [0] * 4 self.result[self.OHOTNIK] = 0 self.result[self.SKALOED] = 0 # победитель: self.winner = None # число ходов: self.nMove = 0 # задержка хода Скалоеда: self.pause = 1.0 # статус игры: self.status = None # флаг окончания игры: self.isGameOver = True self.message = ' self.setup() ' В методе setup мы также загружаем фоновую картинку (Рис. 5) и создаём игровое поле – Лабиринт: # ПОДГОТОВКА def setup(self): # загружаем фоновую картинку: self.back = arcade.load_texture("images/back.png") # массив игрового поля: 167
image_lst = [imgSkala, imgHod] self.labyrinth = [[Cell() for row in range(FIELD_HEIGHT)] for col in range(FIELD_WIDTH)] w = CELL_WIDTH h = CELL_HEIGHT # игровое поле: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): xc = col * w + w / 2 + 2 yc = WINDOW_HEIGHT - row * h - h / 2 - 2 self.labyrinth[col][row].xc = xc self.labyrinth[col][row].yc = yc self.labyrinth[col][row].width = w self.labyrinth[col][row].height= h self.labyrinth[col][row].image_lst = image_lst Рис. 5 Последнее действие в методе setup – запуск игры: # начинаем игру: self.newGame() 168
Лабиринт, который представляет собой квадратный двумерный список, пока пустой. В нём нет ни окон, ни дверей, и не полна горница людей. Поэтому в методе newGame мы придаём ему надлежащий вид. А начинаем мы с того, что сначала все клетки Лабиринта делаем проходимыми: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # сначала все клетки проходимые: for row in range(self.SIZE): for col in range(self.SIZE): self.labyrinth[col][row].status = HOD Тоже неплохой вариант, но он нарушает условия игры (Рис. 6). Рис. 6 Поэтому часть клеток мы засыпаем и делаем непроходимыми для Охотника. Если вы посмотрите на картинку из журнала, то увидите, что непроходимые клетки образуют горизонтальные ряды. Самый простой способ 169
заваливания проходов состоит в том, чтобы в каждой горизонтали поместить 1 сплошной блок непроходимых клеток, начиная с клетки begin и заканчивая клеткой endе: # скальная порода: for j in range(1, self.SIZE): # начало: begin = randint(0, self.SIZE) # конец: #ende = randint(begin, self.SIZE) ende = randint(begin, begin + self.SIZE//4*2) if (ende >= self.SIZE): ende = self.SIZE - 1 for i in range(begin, ende + 1): self.labyrinth[i][j].status = SKALA Вы можете попробовать разные способы строительства горизонтальных рядов, чтобы получить более интересные Лабиринты. Обратите внимание, что верхняя горизонталь полностью свободна от камней, чтобы Охотника не завалило трофеями раньше времени. Должны ли мы проявлять гуманизм в этом случае? – В игре это вполне допустимо. Каждую игру мы будем получать новый Лабиринт, что немного разнообразит игру (Рис. 7). Лабиринт у нас получился хороший и вполне удобный для житья, бытья и охоты, поэтому пора его заселять! А для этого нам нужно просто знать координаты наших героев. Скалоед, как вы помните, начинает игру в центральной клетке: # координаты Скалоеда: x = self.SIZE // 2 y = self.SIZE // 2 self.skaloedCoods = { "col" : x, "row" : y } Охотник – в верхней левой. Причём стоит он в проходимой клетке, потому что грызть камни не умеет: # Охотник стоит в левом верхнем углу: x = 0 170
y = 0 self.ohotnikCoords = { "col" : x, "row" : y } # в проходимой клетке: self.labyrinth[x][y].status = HOD Рис. 7 Как-то так (Рис. 8). Поскольку игра идёт не на жизнь, а на смерть, то нужно считать, кто сколько жизней отдал в неравной схватке. Благо, в компьютерных играх жизней может быть бесконечно много. Прописываем начальный счёт в конструкторе игры, а не в методе newGame, потому что счёт побед и поражений будет накапливаться с каждой игрой, как шпроты в банке: # счёт игры: self.result = [0] * 4 self.result[self.OHOTNIK] = 0 self.result[self.SKALOED] = 0 171
Рис. 8 Ничьи в нашей игре не предусмотрены, так что кто-нибудь да победит и запишет своё имя в переменную winner: # победитель: self.winner = None Из чистого любопытства и пристрастия к статистике мы подсчитываем число ходов – как в шахматах, на которые наша игра издалека очень похожа: # число ходов: self.nMove = 0 И наконец, не обойтись нам без статуса игры, который у нас будет определять игрока, который делает ход: 172
# статус игры: self.status = None И без флажка isGameOver, сигнализирующего об окончании баталии: # флаг окончания игры: self.isGameOver = True Опять возвращаемся в метод newGame и завершаем приготовления к игре: # обнуляем число ходов: self.nMove = 0 # победитель: self.winner = None # ход Охотника: self.status = self.OHOTNIK # сообщение: self.message = ' ' # игра началась: self.isGameOver = False Как и предписано правилами игры и жизни, первый ход за нашим славным Охотником! Но сначала и прежде Лабиринт, Охотник и Скалоед должны в полном объёме и в полный рост предстать перед нами в методе on_draw. Он вызывает подшефные методы draw_labyrinth и drawInfo: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) self.draw_labyrinth() self.drawInfo() self.uimanager.draw() # проверяем, не закончилась ли игра: self.isWin() 173
Метод draw_labyrinth, конечно, важнее, поэтому начнём с него. В нём мы представляем содержимое двумерного списка labyrinth и протагонистов в виде красочных спрайтов. Их размеры нам нужны для вычисления координат спрайтов на экране: # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): # размеры спрайтов: w = CELL_WIDTH h = CELL_HEIGHT Мы должны учесть, что наши герои стоят на клетках Лабиринта. Охотник – всегда на проходимой (зелёной), а Скалоед – на любой. Также необходимо учесть очерёдность печати спрайтов Охотника и Скалоеда, чтобы в случае победы в клетке оказался только победитель: # печатаем спрайты во всех клетках # Лабиринта: for row in range(self.SIZE): for col in range(self.SIZE): # клетка Лабиринта: cell = self.labyrinth[col][row] cell.draw() xc = col * w + w / 2 + 2 yc = WINDOW_HEIGHT - row * h - h / 2 - 2 # Охотник: if col == self.ohotnikCoords["col"] and \ row == self.ohotnikCoords["row"] and \ not (self.winner == self.SKALOED): # на ней Охотник: self.imgOhotnik.draw_scaled(xc, yc) # Скалоед: elif col == self.skaloedCoods["col"] and \ row == self.skaloedCoods["row"]: # на ней Скалоед: self.imgSkaloed.draw_scaled(xc, yc) Метод drawInfo печатает текущий счёт и число ходов, сделанных в игре: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # число ходов: message = f"{self.nMove}" arcade.draw_text(message, WINDOW_WIDTH - 90, 174
WINDOW_HEIGHT - 275, arcade.color.DARK_GREEN, font_size=32, bold=True) # счёт игры: message = f"{self.result[self.OHOTNIK]}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 120, arcade.color_from_hex_string('#764601'), font_size=32, bold=True) message = f"{self.result[self.SKALOED]}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 197, arcade.color_from_hex_string('#FF0000'), font_size=32, bold=True) if self.isGameOver: arcade.draw_lrtb_rectangle_filled(0, WINDOW_WIDTH-1, WINDOW_HEIGHT-1, 0, (255,255,255,120)) self.label_message.text = self.message else: self.label_message.text = '' Охота начинается! Наш Охотник не ходит сам по себе, а ждёт указаний свыше, то есть от мыши. А нажать её кнопку должен игрок. При этом вызывается метод on_mouse_press: # НАЖИМАЕМ КНОПКУ МЫШКИ # ХОД ОХОТНИКА def on_mouse_press(self, x: float, y: float, button: int, modifiers: int): # игра закончена: if self.isGameOver: return Здесь мы узнаём координаты мышки в пикселях x и y. Известным вам способом находим клетку, на которой кликнута мышка: # ищем нажатую клетку: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): 175
# нашли: if self.labyrinth[col][row].over(x, y): Если у игрока такая же верная рука, как у друга индейцев, то координаты кликнутой клетки передаются в метод moveOhotnik для дальнейшего рассмотрения и принятия надлежащих мер: # ход Охотника: self.moveOhotnik(col, row) break Как вы твёрдо помните и крепко знаете, Охотник и Скалоед ходят по очереди. Причём Охотник может думать сколько угодно. Скалоед будет покорно и послушно ожидать в своей клетке, когда кликнет мышка. Скалоеду думать нечем, поэтому он очертя голову бросается куда попало, и очень трудно проследить за его ходами. Мы даём Скалоеду время на размышление и обдумывание своего хода и поступков, которое ему не нужно, зато мы успеваем проследить его перемещения по Лабиринту. Чтобы сделать паузу в программе, необходим класс Timer: from threading import Timer Он придерживает дальнейшее выполнение программы на заданное число секунд. Путём естественного подбора я остановился на 1 секунде. Если вы предпочитаете скорострельные и скороспелые ходы, то уменьшите значение переменной pause: # задержка хода Скалоеда: self.pause = 1.0 А можете и увеличить, если никуда шибко не торопитесь. Во имя справедливости и правопорядка мы обязательно и тщательно проверяем, а чей нынче ход. Если Скалоед безропотно топчется в своей клетке, то Охотник должен и обязан соблюсти очередь и приличия: # ХОД ОХОТНИКА def moveOhotnik(self, colm, rowm): # colm, rowm - координаты кликнутой клетки # сейчас ход Скалоеда: if (self.status != self.OHOTNIK): 176
self.error.play() return А уже потом мы рассчитываем расстояние от клетки Охотника до кликнутой клетки: # текущая клетка с Охотником: colo = self.ohotnikCoords["col"] rowo = self.ohotnikCoords["row"] # расстояние от кликнутой клетки: dcol = abs(colo - colm) drow = abs(rowo - rowm) Практика охоты показывает, что Охотник должен иметь возможность пропустить ход. Так он сможет подкараулить Скалоеда, спрятавшись за соседним камешком. С другой стороны, подумайте, как наказывать Охотника за такое бездействие на протяжении нескольких ходов! Для этого следует кликнуть самого Охотника. Тогда расстояния dcol и drow будут нулевыми, а ход мирно перейдёт к Скалоеду: # пропускаем ход, если кликнута # клетка с Охотником: if (dcol == 0 and drow == 0): self.moveSkaloed() self.press.play() # увеличиваем счётчик ходов: self.nMove += 1 return В этом случае задержка хода не нужна, поскольку Охотник остаётся на месте и может в оба глаза не выпускать из вида своего противника. Если кликнута не клетка с Охотником, а какая-то другая, то мы должны убедиться, что она находится рядом по горизонтали или по вертикали с клеткой Охотника: # клетка должна быть рядом с Охотником: if (dcol + drow != 1): 177
self.error.play() return Выбрана правильная клетка и верное направление – Охотник может перейти в соседнюю клетку. Она может быть проходимой и непроходимой. В проходимую клетку Охотнику идти позволено, а в непроходимую – только если в ней находится Скалоед, который уже вовсю грызёт её, готовя себе неминуемую гибель: # клетка со Скалоедом: cols = self.skaloedCoods["col"] rows = self.skaloedCoods["row"] # нельзя пойти в непроходимую клетку, # если в ней нет Скалоеда: skaloed = (colm == cols) and (rowm == rows) if (self.labyrinth[colm][rowm].status == SKALA and not skaloed): self.error.play() return И вот Охотник, переступая с ноги на ногу, занимает новую клетку: # Охотник переходит в новую клетку: self.ohotnikCoords["col"] = colm self.ohotnikCoords["row"] = rowm # увеличиваем счётчик ходов: self.nMove += 1 Что или даже кто ждёт его там? – Ход игрока может быть тактическим, если Охотник просто перемещается на новое место в Лабиринте, но может стать и победным – если Охотник попадает в клетку со Скалоедом, который тут же отбрасывает рога и копыта от неожиданности: # Охотник победил: if skaloed: self.wino.play() # звук: self.winner = self.OHOTNIK В этом месте должны были бы зазвучать фанфары и литавры, но в нашей малобюджетной игре вы услышите, как Скалоед только крякнул в последний раз. Такова его лебединая песня. Но с первого хода Скалоеда за горло не возьмёшь, так что ход по-честному переходит к нему: 178
# ход переходит к Скалоеду: else: self.stepo.play() self.status = self.SKALOED # небольшая задержка хода: t1 = Timer(self.pause, self.moveSkaloed, args=None, kwargs=None) t1.start() Ход Скалоеда И здесь, прямо тут Скалоед наносит ответный удар, то есть делает ход конём. Прежде чем перейти в соседнюю клетку, Скалоед портит окружающую среду, то есть прогрызает скалу, на которой стоит, или, наоборот, засыпает проход камнями, камушками и прочим песком: # ХОД СКАЛОЕДА def moveSkaloed(self): # игра закончена: if self.isGameOver: return # клетка Скалоеда: col = self.skaloedCoods["col"] row = self.skaloedCoods["row"] # если под ним скала: if self.labyrinth[col][row].status == SKALA: # он делает проход: self.labyrinth[col][row].status = HOD else: # иначе засыпает проход: self.labyrinth[col][row].status = SKALA В отличие от Охотника, который может передвигаться только по свободным ходам, Скалоед выгрызает ходы в скальной породе и, наоборот, засыпает свободные проходы, то есть состояние клетки, из которой он уходит, изменяется на противоположное: непроходимая клетка становится проходимой, а проходимая – непроходимой. Поскольку у Скалоеда ума нет, то ходит он наудачу. А это значит, что мы должны составить для него список возможных ходов, из которых он милостиво выберет один. Для этого мы напишем метод testSkaloed, в кото- 179
ром учтём, что Скалоед может перейти в любую соседнюю клетку – в пределах Лабиринта, конечно: # возможные ходы Скалоеда: smoves = '' if self.testSkaloed('L'): smoves += "L" if self.testSkaloed('R'): smoves += "R" if self.testSkaloed('U'): smoves += "U" if self.testSkaloed('D'): smoves += "D" Для удобства обозначим направления ходов Скалоеда латинскими буквами: L - налево R - направо U - вверх D - вниз Буквы латинские, а слова – английские: Left, Right, Up, Down. Зная их, вы никогда не заблудитесь в Лабиринте. Если метод testSkaloed для очередного направления вернёт True, то туда пойти можно, и мы добавляем соответствующую букву к строке smoves: # ПРОВЕРЯЕМ ХОД СКАЛОЕДА def testSkaloed(self, dir): x = 0 y = 0 # клетка Скалоеда: col = self.skaloedCoods["col"] row = self.skaloedCoods["row"] # ход влево: if (dir == 'L'): # новые координаты Скалоеда: x = col - 1 y = row # ход вправо: if (dir == 'R'): x = col + 1 y = row # ход вверх: 180
if (dir == 'U'): x = col y = row - 1 # ход вниз: if (dir == 'D'): x = col y = row + 1 # выход за граница лабиринта: if (x < 0 or x >= FIELD_WIDTH or \ y < 0 or y >= FIELD_HEIGHT): return False return True Направление хода Скалоед выбирает случайно, извлекая букву из строки smoves: # выбираем случайный ход: n = randint(0, len(smoves)-1) # Скалоед делает ход: dir = smoves[n] if dir == 'L': self.skaloedCoods["col"] if dir == 'R': self.skaloedCoods["col"] if dir == 'U': self.skaloedCoods["row"] if dir == 'D': self.skaloedCoods["row"] self.steps.play() -= 1 += 1 -= 1 += 1 Так как все ходы проверены и утверждены, то Скалоед с полным правом может занять новую клетку в Лабиринте. И в этой клетке совершенно случайно или невзначай может оказаться наш незадачливый Охотник, и тогда Скалоед одерживает победу, огорошивая Охотника до Скалоедов своим стремительным вторжением в его клетку: # новая клетка со Скалоедом: cols = self.skaloedCoods["col"] rows = self.skaloedCoods["row"] # клетка с Охотником: colo = self.ohotnikCoords["col"] rowo = self.ohotnikCoords["row"] # Скалоед победил: 181
if (cols == colo and rows == rowo): self.wins.play() self.winner = self.SKALOED self.status = self.OHOTNIK Охотник от радости не крякнет, а завизжит и проклянёт тот день, когда он на последние деньги купил себе ржавое ружьё. Но до этой печальной развязки удалой истории ещё далеко, так что ход благополучно возвращается к Охотнику. Мало-помалу Охотник и Скалоед сближаются, чтобы до конца выяснить свои неприязненные отношения (Рис. 9). Рис. 9 И так они гоняют друг друга по Лабиринту, пока ход одного из (со)участников обоюдоострой охоты не окажется результативным. 182
Метод on_draw тщательно и внимательно следит за перипетиями игры и каждый раз вызывает метод isWin на предмет осведомления о текущем состоянии игры: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): . . . # проверяем, не закончилась ли игра: self.isWin() # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): if (self.isGameOver): return Если один из бойцов отличился доблестью в бою, то переменная winner хранит имя героя. Если это не так, значит, борьба ещё в полном пылу и в разгаре: # игра продолжается, # победитель не выявлен: if not self.winner: return Но сколько Лабиринту ни виться, а конец одному из славных героев наступит печальный: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): . . . self.message = "ПОБЕДИЛ СКАЛОЕД" if (self.winner == self.OHOTNIK): self.message = "ПОБЕДИЛ ОХОТНИК" self.result[self.OHOTNIK] += 1 else: self.result[self.SKALOED] += 1 # игра закончена: self.isGameOver = True t1 = Timer(interval=1, function=self.play_win, args=None, kwargs=None) t1.start() def play_win(self): # музыка для победителя: self.win.play() 183
А дальше мы красиво печатаем имя победителя сей зверской баталии: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): . . . if self.isGameOver: arcade.draw_lrtb_rectangle_filled(0, WINDOW_WIDTH-1, WINDOW_HEIGHT-1, 0, (255,255,255,120)) self.label_message.text = self.message else: self.label_message.text = '' Победить может и Охотник (Рис. 10). И Скалоед (Рис. 11). Рис. 10 184
Рис. 11 Если у Охотника столько же мозгов, сколько и у Скалоеда, то шансы на победу одинаковы. А если больше, и Охотник умеет ими пользоваться, то практически всегда он и побеждает. Класс клетки поля Лабиринт представляет собой двумерный массив объектов класса Cell. Этот класс описывает клетку поля. Как и в предыдущих проектах, клетка поля умеет правильно рисовать себя на экране в зависимости от статуса, а также определять щелчки мышки по ней: # This Python file uses the following encoding: utf-8 import arcade # непроходимые и прoходимые клетки: SKALA = 0 HOD = 1 185
# КЛАСС КЛЕТКИ ПОЛЯ class Cell: def __init__(self, x=0, y=0, w=0, h=0, image_lst=None): self.image_lst = image_lst # координаты картинки: self.xc = x self.yc = y # размеры картинки: self.width = w self.height = h # проходимая/непроходимая: self.status = SKALA # ВОЗВРАЩАЕТ True, # ЕСЛИ ТОЧКА (px, py) # НАХОДИТСЯ ВНУТРИ КАРТИНКИ def over(self, px, py): x = self.xc - self.width/2 y = self.yc - self.height/2 return (x <= px <= x + self.width and y <= py <= y + self.height) # РИСУЕМ КЛЕТКУ def draw(self): self.image_lst[self.status].draw_scaled(self.xc, self.yc) Несмотря на незамысловатый сюжет, игра получилась довольно увлекательной, но всётаки надо, надо добавить зверушке интеллекта, чтобы Охотник не дремал! Тут, конечно, труба зовёт нас сразиться с монстром подземного мира. Начинайте игру! 186
Игра #9. Охота на Скалоедов На ловца и звепрь бежит! Жертвой нашего очередного прогресса в программировании падёт игра Охота на Скалоеда. Всем она хороша, да вот только персонажей маловато. Давайте углубим и усугубим нашу игру, добавив новых Скалоедов! Проект Охота на Скалоедов Например, увеличим число Скалоедов до 12: # макс. число Скалоедов: MAX_SKALOEDY = 12 К сожалению, объявление этой константы никак не отразится на нашей программе, которую нам придётся существенно и основательно передоработать! Вместо одного Скалоеда на игровом поле будет пастись целое стадо монстров: # число Скалоедов в текущей игре: self.nSkaloedy = MAX_SKALOEDY Мы могли бы предоставить пользователю самому выбирать число врагов, но кроме усложнения интерфейса это ничего не даст. Нам понадобится ещё одна константа, которая показывает, что в клетке поля нет ни одной живой души: 187
# пустая клетка: EMPTY = -1 Чтобы Скалоеды соображали быстрее, сократим им время на раздумья и размышления: # задержка хода Скалоеда: self.pause = 0.7 Число живых Скалоедов считаем в переменной ostSkaloedy: # осталось Скалоедов: self.ostSkaloedy = self.nSkaloedy В методе newGame мы по старому рецепту обновляем Лабиринт и выставляем Охотника в его верхний угол. Однако массив labyrinth должен стать более сложным. Действительно, координаты одного Охотника и одного Скалоеда легко проверить, но для 12 Скалоедов эта процедура окажется слишком длинной, поэтому лучше данные для каждой клетки Лабиринта хранить в экземплярах класса Cell: import arcade # непроходимые и прoходимые клетки: SKALA = 0 HOD = 1 EMPTY = -1 # КЛАСС КЛЕТКИ ПОЛЯ class Cell: def __init__(self, x=0, y=0, w=0, h=0, image_lst=None): self.image_lst = image_lst # координаты картинки: self.xc = x self.yc = y # размеры картинки: self.width = w self.height = h # проходимая/непроходимая: self.status = SKALA # кто знанимает клетку: self.who = EMPTY 188
Так мы сразу определим, сможет ли Охотник перейти на какую-либо клетку Лабиринта. Переменная who принимает значения: # игроки: OHOTNIK = 2 SKALOED = 3 # пустая клетка: EMPTY = -1 и показывает, находится ли в клетке Охотник, или Скалоед, или она свободна (EMPTY). При обновлении Лабиринта мы указываем, что все клетки проходимы и пусты: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # сначала все клетки проходимые: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): self.labyrinth[col][row].status = HOD self.labyrinth[col][row].who = EMPTY Затем засыпаем часть свободных проходов скальной породой, присваивая переменной status значение SKALA: # скальная порода: for j in range(1, FIELD_HEIGHT): # начало: begin = randint(0, FIELD_WIDTH) # конец: ende = randint(begin, begin + FIELD_WIDTH//4*2) if (ende >= FIELD_WIDTH): ende = FIELD_WIDTH - 1 for i in range(begin, ende + 1): self.labyrinth[i][j].status = SKALA Охотник занимает верхнюю левую клетку, которую мы объявляем свободной (она уже свободна, но порядок важнее!): # Охотник стоит в левом верхнем углу: col = 0 row = 0 189
self.ohotnikCoords = { "col" : col, "row" : row } # в проходимой клетке: self.labyrinth[col][row].status = HOD self.labyrinth[col][row].who = OHOTNIK Генерация Скалоедов требует осторожности и аккуратности! Мы не можем весь скоп Скалоедов засадить в одну центральную клетку поля – их следует беспорядочно разбросать по просторам Лабиринта. Это было бы несложно сделать, ведь Скалоед прекрасно себя чувствует и в проходимой, и в непроходимой клетке. Но было бы неэтично подселить новорождённого Скалоеда в клетку с Охотником или другим Скалоедом. Проблема решается просто: если в случайно выбранной клетке пусто, то в неё можно прописать Скалоеда. Сущность этого действа заключается в присваивании переменной who соответствующей клетки Лабиринта значения SKALOED: # число Скалоедов в текущей игре: self.nSkaloedy = MAX_SKALOEDY # координаты Скалоедов: nsk = 0 col = 0 row = 0 while (nsk < self.nSkaloedy): while (self.labyrinth[col][row].who != EMPTY): col = randint(0, FIELD_WIDTH - 1) row = randint(0, FIELD_HEIGHT - 1) self.labyrinth[col][row].who = SKALOED nsk += 1 # осталось Скалоедов: self.ostSkaloedy = self.nSkaloedy # обнуляем число ходов: self.nMove = 0 # победитель: self.winner = None # ход Охотника: self.status = OHOTNIK # сообщение: self.message = ' ' # игра началась: self.isGameOver = False На этом ритуальные обряды по подготовке к игре обрываются, и мы можем во всей красе напечатать текущую (а для нас она - исходная) позицию. Метод draw_labyrinth благодаря переменным who и status получился простым и изящным: 190
# ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) #self.background_color = BACKGROUND_COLOR # рисуем Лабиринт: self.draw_labyrinth() # обновляем информацию: self.drawInfo() # рисуем ЭУ: self.uimanager.draw() # проверяем, не закончилась ли игра: self.isWin() # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): # размеры спрайтов: w = CELL_WIDTH h = CELL_HEIGHT # печатаем спрайты во всех клетках # Лабиринта: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # клетка Лабиринта: cell = self.labyrinth[col][row] cell.draw() xc = col * w + w / 2 + 2 yc = WINDOW_HEIGHT - row * h - h / 2 - 2 # Охотник: if self.labyrinth[col][row].who == OHOTNIK and \ not (self.winner == SKALOED): # на ней Охотник: self.imgOhotnik.draw_scaled(xc, yc) # Скалоед: elif self.labyrinth[col][row].who == SKALOED: # на ней Скалоед: self.imgSkaloed.draw_scaled(xc, yc) Необходимо обновить и метод drawInfo. Дополнительно БОЛЬШИМИ красными цифрами печатаем число здравствующих Скалоедов: 191
# ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): # число ходов: message = f"{self.nMove}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 275, arcade.color.DARK_GREEN, font_size=32, bold=True) # счёт игры: message = f"{self.result[OHOTNIK]}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 120, arcade.color_from_hex_string('#764601'), font_size=32, bold=True) message = f"{self.result[SKALOED]}" arcade.draw_text(message, WINDOW_WIDTH - 90, WINDOW_HEIGHT - 197, arcade.color_from_hex_string('#FF0000'), font_size=32, bold=True) # число оставшихся Скалоедов: message = f"{self.ostSkaloedy}" arcade.draw_text(message, WINDOW_WIDTH - 140, 160, arcade.color_from_hex_string('#FF0000'), font_size=64, bold=True) if self.isGameOver: arcade.draw_lrtb_rectangle_filled(0, 624-1, WINDOW_HEIGHT-1, 0, (255,255,255,120)) self.label_message.text = self.message else: self.label_message.text = '' Запускаем программу. Теперь не только завалы, но и Скалоеды располагаются в Лабиринте случайным образом (Рис. 1). К счастью, игру начинает Охотник, поэтому уже первым ходом вправо он может снести голову одному Скалоеду… Начало метода moveOhotnik дословно цитирует нашу предыдущую программу, а вот потом появляются новоделы: # НАЖИМАЕМ КНОПКУ МЫШКИ # ХОД ОХОТНИКА 192
def on_mouse_press(self, x, y, button: int, modifiers: int): # игра закончена: if self.isGameOver: return # ищем нажатую клетку: for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): # нашли: if self.labyrinth[col][row].over(x, y): # ход Охотника: self.moveOhotnik(col, row) break Рис. 1 # ХОД ОХОТНИКА def moveOhotnik(self, colm, rowm): # colm, rowm - координаты кликнутой клетки # сейчас ход Скалоеда: if (self.status != OHOTNIK): self.error.play() 193
return # текущая клетка с Охотником: colo = self.ohotnikCoords["col"] rowo = self.ohotnikCoords["row"] # расстояние от кликнутой клетки: dcol = abs(colo - colm) drow = abs(rowo - rowm) # пропускаем ход, если кликнута # клетка с Охотником: if (dcol == 0 and drow == 0): self.press.play() # увеличиваем счётчик ходов: self.nMove += 1 self.moveSkaloedy() return # клетка должна быть рядом с Охотником: if (dcol + drow != 1): self.error.play() return # нельзя пойти в непроходимую клетку, # если в ней нет Скалоеда: if (self.labyrinth[colm][rowm].status == SKALA and \ self.labyrinth[colm][rowm].who != SKALOED): self.error.play() return Охотника при переселении в новую клетку может ожидать приятный сюрприз в виде жертвенного Скалоеда, присутствие и наличие (или намордие) которого легко определить по значению переменной who новой клетки: # клетка со Скалоедом: if (self.labyrinth[colm][rowm].who == SKALOED): self.ostSkaloedy -= 1 self.wino.play() По закону жизни Охотник и Скалоед не могут мирно существовать на одной клетке – побеждает тот, кто застиг соперника врасплох. В данной ситуации подфартило Охотнику, а неудачнику-Скалоеду пришла фиаска, и его следует вычеркнуть из списка жильцов Лабиринта. Это делается автоматически, когда Охотник занимает клетку со Скалоедом. Но тут важно не забыть уменьшить число оставшихся на поле Скалоедов. 194
Поскольку Охотник отмечен в переменной who занимаемой им клетки, то, прежде чем двинуться в соседнюю клетку, он должен выписаться из текущей: # Охотник переходит в новую клетку: self.ohotnikCoords["col"] = colm self.ohotnikCoords["row"] = rowm self.labyrinth[colo][rowo].who = EMPTY Покончив с формальностями, переносим Охотника в новую клетку: self.labyrinth[colm][rowm].who = OHOTNIK self.stepo.play() # увеличиваем счётчик ходов: self.nMove += 1 После каждого хода Охотника следует проверить, не извёл ли он уже всех своих врагов. Как вы помните, число живых Скалоедов хранится в переменной ostSkaloedy, так что мы легко определим, остались ли в Лабиринте здравствующие твари или вся популяция загублена отважным, но безрассудным Охотником. Если Охотник добился полной виктории, то игре приходит полный геймовер: # Охотник победил: if (self.ostSkaloedy == 0): self.winner = OHOTNIK Ну а если на естественный вопрос: - Есть кто живой? - мы получим в ответ мат и рык, значит, игра находится в самом разгаре: # ход переходит к Скалоедам: else: self.stepo.play() self.status = SKALOED # небольшая задержка хода: t1 = Timer(self.pause, self.moveSkaloedy, args=None, kwargs=None) t1.start() По справедливости, после хода игрока наступает черёд Скалоедов, которые пытаются пойти куда ни попадя: 195
# ХОД СКАЛОЕДОВ def moveSkaloedy(self): # игра закончена: if self.isGameOver: return Чтобы не обременять себя заботами о хранении координат всех Скалоедов, мы просто пройдёмся по Лабиринту и сохраним координаты всех живых Скалоедов в списке skaloedy: # список Скалоедов: skaloedy = [] for row in range(FIELD_HEIGHT): for col in range(FIELD_WIDTH): if (self.labyrinth[col][row].who != SKALOED): continue # клетка Скалоеда (col, row) skaloedy.append({ "col": col, "row" : row }) Каждый здравствующий Скалоед ходит как попало в одном из четырёх дозволенных ему направлений: for sk in skaloedy: # клетка Скалоеда (col, row): col = sk["col"] row = sk["row"] # возможные ходы Скалоеда: smoves = '' if (self.testSkaloed(col, row, 'L')): smoves += "L" if (self.testSkaloed(col, row, 'R')): smoves += "R" if (self.testSkaloed(col, row, 'U')): smoves += "U" if (self.testSkaloed(col, row, 'D')): smoves += "D" Чтобы очередного Скалоеда не вынесло прежде времени за пределы среды обитания, мы вызываем метод testSkaloed, который получает теперь и координаты клетки Скалоеда: 196
# ПРОВЕРЯЕМ ХОД СКАЛОЕДА def testSkaloed(self, col, row, dir): x = 0 y = 0 # ход влево: if (dir == 'L'): # новые координаты Скалоеда: x = col - 1 y = row # ход вправо: if (dir == 'R'): x = col + 1 y = row # ход вверх: if (dir == 'U'): x = col y = row - 1 # ход вниз: if (dir == 'D'): x = col y = row + 1 # выход за граница лабиринта: if (x < 0 or x >= FIELD_WIDTH or \ y < 0 or y >= FIELD_HEIGHT): return False Дополнительно следует проверить, не проживает ли в заданной клетке другой Скалоед: # клетка с другим Скалоедом: if (self.labyrinth[x][y].who == SKALOED): return False return True Как видите, Скалоеду категорически запрещено покидать Лабиринт и ущемлять жилищные права своих мордастых собратьев. Если Скалоеду верного пути нет (а такая ситуация может возникнуть, если Скалоед находится в углу поля и окружён другими Скалоедами), то он остаётся прозябать в старой клетке: # ходов у Скалоеда нет: if (not smoves): continue 197
Если же горизонт чист, то Скалоед случайно выбирает одну из доступных клеток: # выбираем случайный ход: n = randint(0, len(smoves) - 1) cols = col rows = row # Скалоед делает ход: dir = smoves[n] if (dir == 'L'): cols -= 1 if (dir == 'R'): cols += 1 if (dir == 'U'): rows -= 1 if (dir == 'D'): rows += 1 Скалоед освобождает свою клетку: self.labyrinth[col][row].who = EMPTY И переходит в соседнюю, не забывая своих обязанностей по рытью и засыпанию проходов: # если под ним скала: if (self.labyrinth[col][row].status == SKALA): # он делает проход: self.labyrinth[col][row].status = HOD else: # иначе засыпает проход: self.labyrinth[col][row].status = SKALA # новая клетка со Скалоедом: self.labyrinth[cols][rows].who = SKALOED # клетка с Охотником: colo = self.ohotnikCoords["col"] rowo = self.ohotnikCoords["row"] Если в новой клетке ему подвернётся под горячую лапу Охотник, то Скалоед выказывает ему своё отрицательное отношение к охоте: 198
# Скалоеды победили: if (cols == colo and rows == rowo): self.wins.play() self.winner = SKALOED return Ну а если Охотник уберёгся на этот раз от Скалоедов, то он торжественно получает право пойти хоть налево, хоть направо: self.steps.play() self.status = OHOTNIK В методе isWin мы совершаем традиционные обрядовые действия, уже хорошо вам известные по прошлогоднему сезону охоты: # ПРОВЕРЯЕМ, НЕ ЗАКОНЧИЛАСЬ ЛИ ИГРА def isWin(self): if (self.isGameOver): return # игра продолжается, # победитель не выявлен: if not self.winner: return self.message = "ПОБЕДИЛ СКАЛОЕД" if (self.winner == OHOTNIK): self.message = "ПОБЕДИЛ ОХОТНИК" self.result[OHOTNIK] += 1 else: self.result[SKALOED] += 1 # игра закончена: self.isGameOver = True t1 = Timer(interval=1, function=self.play_win, args=None, kwargs=None) t1.start() def play_win(self): # музыка для победителя: self.win.play() Процесс стратегической игры стал ещё увлекательнее (Рис. 2), особенно если Охотник не прячется в кустиках, а смело бросается навстречу судьбе (Рис. 3)! 199
Рис. 2 Рис. 3 200
Задания для самостоятельного решения 1. Добавьте немного смекалки Скалоедам! Если Охотник затаился в соседней клетке, то Скалоед должен непременно его учуять и облапить. 2. Увеличьте число Скалоедов, например, до 32. Тогда игра пойдёт ещё веселее, а Охотнику придётся держать не только порох сухим, но и ушки на макушке: Переведите дух после охоты на привале, а потом крепко, обеими руками беритесь за космическую головоломку! 201
Головоломка Космический охотник Помогите охотнику (он похож на героя компьютерных игр Пакмана) поймать всех толстолапых монстров с планеты Пармезан. Космический охотник может двигаться только по коридорам лабиринта, отмеченным точками. Его путь не должен самопересекаться (то есть вы не можете дважды пройти по одной и той же точке), а закончиться он должен в центре управления, обозначенном стрелкой и звёздочками. Удачной охоты! 202
Игра #10. Как построить Лабиринт? Существует немало алгоритмов, более или менее успешно решающих эту строительную задачу, но мы выберем самый простой и понятный из них, который в сокращённом варианте называется DFS. Аббревиатура расшифровывается так: Depth-First Search, что в переводе на русский язык значит поиск в глубину. Его обычное назначение – обход вершин графа, но мы используем этот алгоритм для построения Лабиринта. Проект Как построить Лабиринт? Алгоритм DFS требует, чтобы строк и столбцов в Лабиринте было нечётное число, иначе он будет работать неверно. Поэтому мы задаём правильные размеры Лабиринта: # ширина поля в клетках: COLS = 49 # высота поля в клетках: ROWS = 35 В книге Тотальный тренинг по Си-шарпу. Начальный уровень вы найдёте этот проект, в котором Лабиринт строился средствами Си-шарпа. Причём размеры Лабиринта можно было выбирать, чего в нашем проекте не будет, поскольку у нас другие цели. Для архитектурных расчётов необходимо знать размеры строительных ячеек, их которых будет построен Лабиринт. Для нашего простого Лабиринта вполне годятся квадратные клетки. Ширина и высота клеток – по 20 пикселей: # размеры клеток в пикселях: CELL_WIDTH = 20 CELL_HEIGHT = 20 203
Окно программы мы предусмотрительно выберем достаточно большим, чтобы нашему будущему герою было где развернуться во всю ширь своей души: # размеры окна в пикселях: WINDOW_WIDTH = 1000 WINDOW_HEIGHT = 760 Чтобы Лабиринт гармонично располагался в окне приложения, мы отмеряем нужные расстояния от его краёв. Зазор между клетками отсутствует: # отступы: OFFSET_X = 0 OFFSET_Y = 60 ZAZOR = 0 Как и большинство лабиринтовых алгоритмов, наш алгоритм начинает строительство с возведения всех возможных стен, которые окружают каждую пустую клетку с четырёх сторон (Рис. 1). В функции setup создаём элементы управления и массив игрового поля: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) self.setup() # ПОДГОТОВКА def setup(self): # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() # кнопка: button_newgame = arcade.gui.UIFlatButton(text="Новый Лабиринт", width=980) button_newgame.on_click = self.on_button_newgame_click # метка: self.label_message = UILabel(text="", font_size=40, bold=True, width=600, text_color=arcade.color_from_hex_string('#FF0000')) self.uimanager.add(UIAnchorWidget(child=self.label_message, align_x=-80, align_y=0)) self.uimanager.add(UIAnchorWidget(child=button_newgame, align_x=0, align_y=356)) 204
# массив игрового поля: self.labyrinth = [[WALL for row in range(ROWS)] for col in range(COLS)] Рис. 1. Непроходимый Лабиринт На Рис. 1⬆ хорошо видно, что над Лабиринтом возвышается кнопка Новый Лабиринт. Игра начинается с вызова метода newGame: # начинаем игру: self.newGame() # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # создаём Лабиринт: self.makeLabyrinth() # игра началась: self.isGameOver = False 205
Рис. 1⬆ показывает, что по периметру Лабиринт окружён сплошной стеной, внутри которой пустые клетки и стены чередуются как по горизонтали, так и по вертикали. Существует элементарное правило для определения назначения клетки: если произведение номера строки и столбца – нечётное число, то это - пустая клетка, в противном случае – стена. Понятно, что если число столбцов и/или строк будет чётным, то одну или две внешние стены построить не удастся. Все изначально пустые клетки образуют проходы, или коридоры в Лабиринте, и нам нужно знать их общее число emptyCells. В нашем Лабиринте 408 пустых клеток: # СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): # всего пустых клеток в Лабиринте: emptyCells = 0 for r in range(ROWS): for c in range(COLS): self.labyrinth[c][r] = WALL if (c * r % 2 == 1): self.labyrinth[c][r] = EMPTY emptyCells += 1 Назначение клеток для удобства обозначаем константами: # состояние EMPTY = 0 # FREE = 1 # WALL = 15 # клетки: - пустая - свободная клетка (проход, коридор) - стена Результат работы этой части метода makeLabyrinth вы видели на Рис. 1⬆. Это пустые клетки жёлтого цвета, окружённые со всех сторон стенами коричневого цвета. Цвета клеток могут быть любыми, это дело вкуса. Мой вкус выразился в константах: # цвета клеток: EMPTY_COLOR = arcade.color.YELLOW FREE_COLOR = arcade.color.WHITE WALL_COLOR = (137, 46, 0) Второй этап построения Лабиринта заключается в выборе первой свободной клетки, которая принадлежит коридору: 206
# алгоритм построения лабиринта DFS # ---------------------------------# старт - в верхнем левом углу: col = 1 row = 1 # старт - в случайной клетке: while True: col = randint(1, COLS - 1) row = randint(1, ROWS - 1) print(col * row % 2) if (col * row % 2 == 1): break self.labyrinth[col][row] = FREE В результате работы алгоритма DFS все пустые клетки перейдут в разряд свободных. Коридоры свяжут все изначально пустые клетки (они жёлтого цвета), поэтому из любой свободной клетки можно перейти в любую другую свободную клетку. Из этого следует, что начальной клеткой может быть любая пустая клетка в массиве labyrinth. Если вы закомментируете цикл while, то рубка проходов начнётся из верхней левой пустой клетки, иначе – из случайной. Эта клетка обозначена в массиве labyrinth как FREE, поэтому, если вы сейчас нарисуете Лабиринт, то увидите её в образе белого квадратика (Рис. 2). Координаты всех свободных клеток (коридоров) запоминаем в двух целочисленных списках. Они имеют заведомо достаточные размеры для хранения всех координат. Позицию (индекс) текущей клетки в этих массивах мы храним в переменной ptrCurPosition, которая сейчас равна единице: # номер текущей клетки в Лабиринте: ptrCurPosition = 1 # число клеток в Лабиринте: nCell = COLS * ROWS # текущие координаты в Лабиринте: xCurrent = [0] * nCell yCurrent = [0] * nCell # запоминаем её координаты: xCurrent[ptrCurPosition] = col yCurrent[ptrCurPosition] = row Точно так же, как и переменная nDone: 207
# одна клетка Лабиринта найдена: nDone = 1 Рис. 2. Первая клетка Лабиринта Когда значение этой переменной сравняется с emptyCells, мы сможем рапортовать о завершении строительства в ударные сроки! И тут начинаются тяжёлые пробивные работы! Первая клетка со всех сторон окружена стенами, после которых может быть пустая клетка, которая ещё не принадлежит коридору. 208
В зависимости от расположения текущей клетки в Лабиринте, таких клеток может быть от двух до четырёх (Рис. 3). Если коридоры частично построены, то таких клеток может и вовсе не оказаться! Рис. 3. Пустые соседи Поскольку все пустые клетки должны превратиться в свободные, которые связаны между собой разветвлённым, но единым коридором, то из текущей свободной (белой) клетки должен быть проход в одну из соседних пустых (жёлтых) клеток. Сейчас их разделяет стена, которую нам предстоит снести. Итак, для текущей свободной клетки, уже принадлежащей коридору, мы считаем соседей (или стены между ними) в переменной nWall. Если стена обнаружится, то её направление по отношению к свободной клетке мы запомним в списке direction: # направления из текущей клетки: direction = [0] * 5 Направления обозначаем константами (Рис. 4): 209
# направления NORTH = 0 # EAST = 1 # SOUTH = 2 # WEST = 3 # - из клетки: север(вверх) восток(вправо) юг(вниз) запад(влево) Рис. 4. Направления на соседей Вся геометрия Лабиринта хорошо видна на Рис. 3⬆, поэтому вы без труда разберётесь в механизме подсчёта соседних стен: # пробиваем проходы между пустыми клетками: while True: # считаем стены возле текущей клетки, # после которых есть ещё не посещённая клетка --> # число стен: nWall = 0 # сверху: col = xCurrent[ptrCurPosition] row = yCurrent[ptrCurPosition] - 1 if (row > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col][(row - 1)] == EMPTY): nWall += 1 direction[nWall] = NORTH # справа: col = xCurrent[ptrCurPosition] + 1 row = yCurrent[ptrCurPosition] if (col < COLS - 2 and self.labyrinth[col][row] == WALL and self.labyrinth[col + 1][row] == EMPTY): nWall += 1 direction[nWall] = EAST # снизу: col = xCurrent[ptrCurPosition] row = yCurrent[ptrCurPosition] + 1 if (row < ROWS - 2 and 210
self.labyrinth[col][row] == WALL and self.labyrinth[col][row + 1] == EMPTY): nWall += 1 direction[nWall] = SOUTH # слева: col = xCurrent[ptrCurPosition] - 1 row = yCurrent[ptrCurPosition] if (col > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col - 1][row] == EMPTY): nWall += 1 direction[nWall] = WEST У первой клетки всегда найдутся соседи. Это значит, что число стен будет больше нуля, а точнее – не меньше двух. Так как наша задача – построить случайный Лабиринт (иначе все они будут на одно лицо, и играть будет неинтересно), то нам нужно случайно выбрать одну из стен для её последующего полного разрушения: # есть стены: if (nWall > 0): nDone += 1 # выбираем случайное направление из возможных: rndDir = randint(1, nWall) Зная число стен, сделать это нетрудно. Значение переменной rndDir равно индексу направления на случайную стену в списке direction, так что мы легко найдём эту стену. И стену, и следующую за ней пустую клетку мы добавляем к проходу в Лабиринте, присваивая им значение FREE. Сразу же запоминаем координаты последней свободной клетки (она до этого была пустой) в списках xCurrent и yCurrent: # идём наверх: if dir == NORTH: #print("NORTH") col = xCurrent[ptrCurPosition] row = yCurrent[ptrCurPosition] - 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row - 1] = FREE ptrCurPosition += 1 xCurrent[ptrCurPosition] = xCurrent[ptrCurPosition - 1] yCurrent[ptrCurPosition] = yCurrent[ptrCurPosition - 1] - 2 # идём направо: elif dir == EAST: 211
#print("EAST") col = xCurrent[ptrCurPosition] + 1 row = yCurrent[ptrCurPosition] self.labyrinth[col][row] = FREE self.labyrinth[col + 1][row] = FREE ptrCurPosition += 1 xCurrent[ptrCurPosition] = xCurrent[ptrCurPosition - 1] + 2 yCurrent[ptrCurPosition] = yCurrent[ptrCurPosition - 1] # идём вниз: elif dir == SOUTH: #print("SOUTH") col = xCurrent[ptrCurPosition] row = yCurrent[ptrCurPosition] + 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row + 1] = FREE ptrCurPosition += 1 xCurrent[ptrCurPosition] = xCurrent[ptrCurPosition - 1] yCurrent[ptrCurPosition] = yCurrent[ptrCurPosition - 1] + 2 # идём влево: elif dir == WEST: #print("WEST") col = xCurrent[ptrCurPosition] - 1 row = yCurrent[ptrCurPosition] self.labyrinth[col][row] = FREE self.labyrinth[col - 1][row] = FREE ptrCurPosition += 1 xCurrent[ptrCurPosition] = xCurrent[ptrCurPosition - 1] - 2 yCurrent[ptrCurPosition] = yCurrent[ptrCurPosition - 1] Перекрасив жёлтую клетку в белый цвет, мы увеличиваем на 1 число свободных клеток nDone, и, когда оно сравняется с числом пустых клеток, которые мы насчитали в начале метода, мы печатаем в Консольном окне сообщение и заканчиваем строительный цикл – все коридоры проложены и все лишние стены разрушены: # Лабиринт построен: if (nDone == emptyCells): print("Лабиринт готов!!") # выходим из цикла: break Если же это не так, то мы возвращаемся в начало цикла while, чтобы продолжить ломку стен. Обратите внимание, что теперь текущей становится последняя свободная клетка, и мы уже для неё подсчитываем соседей: 212
# нет стен: else: #print("Тупик!") ptrCurPosition -= 1 На Рис. 5 и 6 хорошо виден процесс последовательного перехода из последней клетки коридора в соседнюю. Непрерывное рытьё коридоров может продолжаться довольно долго, и с каждым шагом проход всё дальше уходит от начальной клетки (по коридору, а не чисто геометрически). Именно эта особенность алгоритма и отмечена в его названии: поиск пути как бы уходит в глубину – он продолжается, пока это возможно, из самой удалённой от начала клетки (Рис. 7). Алгоритм DFS не наделён большим интеллектом, поэтому рано или поздно он заведёт коридор в тупик, то есть в такую клетку, у которой нет пустых соседей (в свободную клетку идти нельзя, иначе возникнут замкнутые участки прохода). Такую ситуацию вы можете наблюдать на Рис. 8. Рис. 5 Так как Лабиринт ещё не достроен, то нужно вернуться на шаг назад, в предыдущую запомненную клетку, а для этого достаточно уменьшить на единицу индекс ptrCurPosition. Заметьте, что уже найденные клетки коридора мы не возвращаем в прежнее, пустое состояние – они так и остаются свободными, то есть при возвращении назад общее число свободных клеток не уменьшается. В нашем Лабиринте из предыдущей клетки имеется продолжение пути (Рис. 9). 213
Рис. 6. Лабиринтостроительство А если бы и предыдущая клетка оказалась тупиковой, то пришлось бы вернуться ещё на шаг назад – и так далее, пока не найдётся клетка с продолжением (Рис. 10). И такая клетка непременно найдётся, если Лабиринт ещё не построен окончательно. Рис. 7. Удаляемся 214
Рис. 8. Тупичок! Рис. 9. Вернулись назад, чтобы идти вперёд 215
Рис. 10. Иногда приходится отступать Сколько бы проход ни вился, а конец строительству Лабиринта будет! На Рис. 11 хорошо видно, что: • все свободные клетки (бывшие пустые) соединяются проходами • из любой клетки можно попасть в любую другую (причём единственным путём) • коридоры не образуют замкнутых циклов Такие лабиринты называются совершенными. Чтобы создать новый Лабиринт, нажмите чёрную кнопку в верхней части окна программы. 216
Рис. 11. Перфект! Проект Лабиринт со стеком Стеки из тарелок 217
При построении Лабиринта мы использовали три операции с координатами свободных клеток: • • • Добавляли в список curPosition по индексу ptrCurPosition Извлекали из списка curPosition по индексу ptrCurPosition «Удаляли» из списка curPosition по индексу ptrCurPosition То же самое можно сделать проще, если вместо списка координат использовать стек, с элементами которого можно выполнять те же самые действия: • • • Добавить - push Извлечь без удаления - peek Удалить с извлечением - pop Вместо целочисленных массивов и указателя добавьте к нашей программе стек: # стек для хранения координат клеток: stack = Stack() В Питоне нет такой структуры данных, как стек. Стек легко имитировать списком, но при этом названия методов списка отличаются от общепринятых названий методов стека, а метода, соответствующего методу peek в стеке, список вообще не имеет. Поскольку стеки часто используются в программах, то лучше написать небольшой класс стека: # Класс стека class Stack: def __init__(self): self.items = [] # добавляем элемент на вершину стека: def push(self, item): self.items.append(item) # удаляем и возвращаем верхний элемент: def pop(self): return self.items.pop() # возвращаем верхний элемент: def peek(self): 218
return self.items[-1] # возвращаем размер стека # (число элементов) def size(self): return len(self.items) # стек пуст? def isEmpty(self): return self.items == [] В главном файле импортируем класс из файла Stack.py: from Stack import Stack В стек можно последовательно записывать обе координаты клетки, но мы обойдёмся единственным числом, если упакуем координаты: stack  x*1000 + y Извлечь их из упаковки также очень легко: x =  stack // 1000 y =  stack % 1000 В методе makeLabyrinth мы сначала создаём пустой стек, а затем отправляем туда упакованные координаты первой клетки: # стек для хранения координат клеток: stack = Stack() # добавляем в него первую клетку: stack.push(col * 1000 + row) Условие выполнения цикла while можно изменить, поскольку мы точно знаем, что Лабиринт можно построить единственным способом: # пробиваем проходы между пустыми клетками: while nDone < emptyCells: 219
Перед началом подсчёта стен мы извлекаем упакованные координаты colrow последней клетки пути (они остаются в стеке!) и распаковываем в переменные col и row: # считаем стены возле текущей клетки, # после которых есть ещё не посещённая клетка --> colrow = stack.peek() # число стен: nWall = 0 Все остальные вычисления остались без изменений: # сверху: col = colrow // 1000 row = colrow % 1000 - 1 if (row > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col][(row - 1)] == EMPTY): nWall += 1 direction[nWall] = NORTH # справа: col = colrow // 1000 + 1 row = colrow % 1000 if (col < COLS - 2 and self.labyrinth[col][row] == WALL and self.labyrinth[col + 1][row] == EMPTY): nWall += 1 direction[nWall] = EAST # снизу: col = colrow // 1000 row = colrow % 1000 + 1 if (row < ROWS - 2 and self.labyrinth[col][row] == WALL and self.labyrinth[col][row + 1] == EMPTY): nWall += 1 direction[nWall] = SOUTH # слева: col = colrow // 1000 - 1 row = colrow % 1000 if (col > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col - 1][row] == EMPTY): nWall += 1 direction[nWall] = WEST Когда мы добавляем новую свободную клетку к коридорам, её координаты помещаем в стек: # есть стены: if (nWall > 0): nDone += 1 220
# выбираем случайное направление из возможных: rndDir = randint(1, nWall) dir = direction[rndDir] # идём наверх: if dir == NORTH: col = colrow // 1000 row = colrow % 1000 - 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row - 1] = FREE stack.push(col * 1000 + row - 1) # идём направо: elif dir == EAST: col = colrow // 1000 + 1 row = colrow % 1000 self.labyrinth[col][row] = FREE self.labyrinth[col + 1][row] = FREE stack.push((col + 1) * 1000 + row) # идём вниз: elif dir == SOUTH: col = colrow // 1000 row = colrow % 1000 + 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row + 1] = FREE stack.push(col * 1000 + row + 1) # идём влево: elif dir == WEST: col = colrow // 1000 - 1 row = colrow % 1000 self.labyrinth[col][row] = FREE self.labyrinth[col - 1][row] = FREE stack.push((col - 1) * 1000 + row) При попадании в тупик удаляем из стека координаты последней свободной клетки, и на вершине стека оказываются координаты предыдущей свободной клетки: # нет стен: else: stack.pop() #ptrCurPosition -= 1 Когда цикл while закончит свою работу, в Консольном окне появится сообщение (Рис. 1): # Лабиринт построен: if (nDone == emptyCells): print("Лабиринт готов!") 221
Этот проект показывает, что правильный выбор структур данных облегчает программирование и делает программу более понятной. Рис. 1. С окончанием строительства! Проект Лабиринт со стеком 2 В предыдущих проектах мы сразу получали на экране уже полностью готовый Лабиринт, а нам, конечно, хотелось бы понаблюдать за ходом строительства. В этом проекте мы так изменим предыдущий проект, чтобы увидеть всю подноготную возведения этого колоссального сооружения. Для этого нам нужно немного усложнить нашу программу. В методе makeLabyrinth мы проводим только неизменяемые действия. В методу updateLabyrinth нам понадобятся переменные, которые описаны в методе makeLabyrinth, поэтому мы делаем их переменными экземпляра класса: 222
# СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): # всего пустых клеток в Лабиринте: self.emptyCells = 0 for r in range(ROWS): for c in range(COLS): self.labyrinth[c][r] = WALL if (c * r % 2 == 1): self.labyrinth[c][r] = EMPTY self.emptyCells += 1 # алгоритм построения лабиринта DFS # ---------------------------------# старт - в случайной клетке: while True: col = randint(1, COLS - 1) row = randint(1, ROWS - 1) if (col * row % 2 == 1): break self.labyrinth[col][row] = FREE # направления из текущей клетки: self.direction = [0] * 5 # одна клетка Лабиринта найдена: self.nDone = 1 # стек для хранения координат клеток: self.stack = Stack() # добавляем в него первую клетку: self.stack.push(col * 1000 + row) Все остальные проходы мы последовательно добавляем в методе updateLabyrinth. При каждом обновлении сцены вызываем метод updateLabyrinth, пока Лабиринт не будет полностью построен: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.background_color = BACKGROUND_COLOR if not self.isGameOver: self.updateLabyrinth() self.draw_labyrinth() self.uimanager.draw() 223
Метод updateLabyrinth добавляет 1 новую клетку к проходам, если это возможно. Здесь ничего для вас нового нет, кроме проверки на окончание строительства Лабиринта: # ДОБАВЛЯЕМ ПРОХОДЫ def updateLabyrinth(self): if self.nDone == self.emptyCells: # Лабиринт построен: print("Лабиринт готов!") self.isGameOver = True return # считаем стены возле текущей клетки, # после которых есть ещё не посещённая клетка --> colrow = self.stack.peek() # число стен: nWall = 0 # сверху: col = colrow // 1000 row = colrow % 1000 - 1 if (row > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col][(row - 1)] == EMPTY): nWall += 1 self.direction[nWall] = NORTH # справа: col = colrow // 1000 + 1 row = colrow % 1000 if (col < COLS - 2 and self.labyrinth[col][row] == WALL and self.labyrinth[col + 1][row] == EMPTY): nWall += 1 self.direction[nWall] = EAST # снизу: col = colrow // 1000 row = colrow % 1000 + 1 if (row < ROWS - 2 and self.labyrinth[col][row] == WALL and self.labyrinth[col][row + 1] == EMPTY): nWall += 1 self.direction[nWall] = SOUTH # слева: col = colrow // 1000 - 1 row = colrow % 1000 if (col > 1 and self.labyrinth[col][row] == WALL and self.labyrinth[col - 1][row] == EMPTY): nWall += 1 self.direction[nWall] = WEST 224
# есть стены: if (nWall > 0): self.nDone += 1 # выбираем случайное направление из возможных: rndDir = randint(1, nWall) dir = self.direction[rndDir] # идём наверх: if dir == NORTH: col = colrow // 1000 row = colrow % 1000 - 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row - 1] = FREE self.stack.push(col * 1000 + row - 1) # идём направо: elif dir == EAST: col = colrow // 1000 + 1 row = colrow % 1000 self.labyrinth[col][row] = FREE self.labyrinth[col + 1][row] = FREE self.stack.push((col + 1) * 1000 + row) # идём вниз: elif dir == SOUTH: col = colrow // 1000 row = colrow % 1000 + 1 self.labyrinth[col][row] = FREE self.labyrinth[col][row + 1] = FREE self.stack.push(col * 1000 + row + 1) # идём влево: elif dir == WEST: col = colrow // 1000 - 1 row = colrow % 1000 self.labyrinth[col][row] = FREE self.labyrinth[col - 1][row] = FREE self.stack.push((col - 1) * 1000 + row) # нет стен: else: self.stack.pop() Теперь вы можете наблюдать за строительством Лабиринта в режиме реального времени (Рис. 1)! Этот процесс не только интересный, но и познавательный. Наблюдая за работой алгоритма DFS, вы лучше поймёте его работу. Как говорится, лучше один раз увидеть на экране, чем 100 раз в коде. 225
Рис. 1. Ход строительства 226
Игра #11. Как выбраться из Лабиринта Чтобы найти самый короткий путь в Лабиринте (или в графе), обычно используют алгоритм BFS (BreadthFirst Search, поиск в ширину). Но мы-то наверняка знаем, что путь в нашем Лабиринте единственный, поэтому он и есть самый короткий. Это значит, что мы можем прокладывать дорогу в Лабиринте с помощью всё того же алгоритма DFS – как и при его строительстве. Прежде чем выбираться из Лабиринта, его нужно создать, для чего мы воспользуемся нашим предыдущим проектом. Проект Как выбраться из Лабиринта Для понуждения программы к поиску пути мы установим ещё одну кнопку. А 2 метки проинформируют нас о найденном пути в Лабиринте: # ПОДГОТОВКА def setup(self): # элементы управления: self.uimanager = arcade.gui.UIManager() self.uimanager.enable() # кнопка: button_createLab = arcade.gui.UIFlatButton(text="Создать", width=120) button_createLab.on_click = self.on_button_createLab_click button_findPath = arcade.gui.UIFlatButton(text="Найти путь", width=140) button_findPath.on_click = self.on_button_findPath_click # метка: self.label_lenPath = UILabel(text=" ", font_size=16, bold=True, width=200, text_color=(0,255,0)) self.label_lenTupik = UILabel(text=" ", font_size=16, bold=True, width=220, text_color=(255,0, 0)) self.uimanager.add(UIAnchorWidget(child=self.label_lenPath, align_x=-180, align_y=352)) self.uimanager.add(UIAnchorWidget(child=self.label_lenTupik, align_x=160, align_y=352)) self.uimanager.add(UIAnchorWidget(child=button_createLab, align_x=-430, align_y=356)) self.uimanager.add(UIAnchorWidget(child=button_findPath, 227
align_x=420, align_y=356)) # массив игрового поля: self.labyrinth = [[WALL for row in range(ROWS)] for col in range(COLS)] # начинаем игру: self.createLab() В готовом виде новая программа выглядит так (Рис. 1). Рис. 1 Длина пути нам нужна только для сравнения «длиннот» разных Лабиринтов между собой – в каждом отдельном Лабиринте найденный путь всегда и самый короткий, и самый длинный, поскольку единственный. Создайте переменную lenPath для хранения длины пути. Наш алгоритм не приводит сразу к цели, он много раз заведёт нас в тупиковые коридоры Лабиринта. Для интереса посчитаем и тупиковые клетки. Флажок isReady сигнализирует об окончании поиска пути, чтобы пользователь не искал путь в уже пройденном Лабиринте: 228
# СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def createLab(self): # создаём Лабиринт: self.makeLabyrinth() # длина пути: self.lenPath = 0 # длина тупиков: self.lenTupik = 0 # путь не найден: self.isReady = False Клетки пути отметим светло-зелёным цветом на экране и константой PATH в массиве labyrinth. Тупиковые клетки обозначим своей константой и светло-красным цветом: # состояние клетки: EMPTY = 0 # - пустая FREE = 1 # - свободная клетка (проход, коридор) PATH = 2 # - клетка, по которой проходит путь NOPATH = 4 # - клетка, по которой нет пути WALL = 15 # - стена # цвета клеток --> # пустая клетка: EMPTY_COLOR = arcade.color.YELLOW # свободная клетка: FREE_COLOR = arcade.color.WHITE # стена: WALL_COLOR = (137, 46, 0) # цвет клеток пути: PATH_COLOR = (144, 238, 144) # цвет тупиковых клеток: NOPATH_COLOR = (255, 105, 105, 255) Метод draw_labyrinth дополняем двумя ветками в операторе if-elif, чтобы показать истинный путь и наши заблуждения: # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT for row in range(ROWS): for col in range(COLS): # клетка Лабиринта: xc = col * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - row * h - h / 2 - OFFSET_Y # состояние: 229
sost = self.labyrinth[col][row] if sost == EMPTY: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc EMPTY_COLOR) elif sost == WALL: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc WALL_COLOR) elif sost == FREE: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc FREE_COLOR) elif sost == PATH: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc PATH_COLOR) elif sost == NOPATH: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc NOPATH_COLOR) + h, yc, + h, yc, + h, yc, + h, yc, + h, yc, Программа начинается в методе setup с вызова метода createLab: # начинаем игру: self.createLab() Это значит, что сразу после запуска программы на экране появится готовый Лабиринт (Рис. 2). В методе createLab мы создаём Лабиринт и готовим программу к поиску пути: # СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def createLab(self): # создаём Лабиринт: self.makeLabyrinth() # длина пути: self.lenPath = 0 # длина тупиков: self.lenTupik = 0 # путь не найден: self.isReady = False Метод makeLabyrinth скопируйте из нашего предыдущего проекта. Он не требует изменений. В методе on_draw мы обновляем Лабиринт, элементы управления и информацию на экране: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.background_color = BACKGROUND_COLOR 230
# рисуем Лабиринт: self.draw_labyrinth() # обновляем информацию: self.drawInfo() self.uimanager.draw() Рис. 2 Имея готовые метки, мы легко справимся с информированием заинтересованных лиц. Но, если путь ещё не найден, то информация не нужна: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): if self.lenPath == 0: return self.label_lenPath.text = f"Длина пути = {self.lenPath}" self.label_lenTupik.text = f"Длина тупиков = {self.lenTupik}" После нажатия на кнопку Найти путь начинает работу метод findPath: 231
# НАЖИМАЕМ КНОПКУ "НАЙТИ ПУТЬ" def on_button_findPath_click(self, event): self.findPath() Начальную клетку пути отмечаем красным цветом, а конечную, которая находится в правом нижнем углу Лабиринта – зелёным: # координаты финишной клетки: END_COL = COLS - 2 END_ROW = ROWS – 2 # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): . . . # начальная клетка: xc = 1 * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - 1 * h - h / 2 - OFFSET_Y arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (255, 0, 0)) # конечная клетка: xc = END_COL * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - END_ROW * h - h / 2 - OFFSET_Y arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (0, 255,0)) В методе findPath запоминаем координаты стартовой клетки в стеке: # НАЧИНАЕМ ПОИСКИ def findPath(self): if self.isReady: return # создаём стек: stack = Stack() # сбрасываем флажок: flg = False # путей пока нет: self.lenPath = 0 self.lenTupik = 0 # старт в верхнем левом углу: col = 1 row = 1 # клетка пройдена: self.labyrinth[col][row] = PATH # запоминаем координаты входа на стеке: stack.push(1000 * col + row) self.lenPath += 1 232
Часто путь в Лабиринте ищут рекурсивным способом, но нерекурсивный способ со стеком более прост для понимания. Вы можете искать путь между любой парой клеток, но чаще в этой роли выступают левая верхняя клетка (начало) и правая нижняя (конец). Запускаем программу – концы пути (вход и выход) отмечены. С учётом стартовой клетки длина пути равна единице (Рис. 3). Рис. 3 Из начальной клетки пути (как изо всех последующих) можно пойти в четырёх направлениях (из угловых и «сторонних клеток», направлений, конечно, меньше) – как и при строительстве Лабиринта. 233
В массиве labyrinth стартовая клетка, как и все остальные клетки пути, имеет числовое значение PATH. Так мы легко определим, что находится в клетке – стена, проход или путь. Путь в Лабиринте прокладываем в бесконечном цикле while, который прервём, когда окажемся в конечной клетке: while True: # координаты текущей клетки пути: colrow = stack.peek() col = colrow // 1000 row = colrow % 1000 if ((col == END_COL) and (row == END_ROW)): print("Длина пути =", self.lenPath) self.isReady = True break Координаты последней клетки пути находятся на вершине стека. Метод peek извлекает их оттуда, но не уничтожает. Распаковываем координаты в переменные col и row. Если это координаты выхода из Лабиринта, то прерываем бесконечный цикл while. Так как алгоритм построения Лабиринта DFS совсем не заботится о длине пути, то он может оказаться и очень коротким, и очень длинным. Закончив поиск пути, вы узнаете его длину, поэтому можете поискать очень длинные, запутанные пути, создавая новые Лабиринты. Поиск пути проходит по тому же сценарию, что и строительство Лабиринта, но теперь нам не нужно крушить и ломать стены – мы просто переходим в одну из свободных клеток, примыкающих к текущей. Так как в наших Лабиринтах путь всегда единственный, то совершенно безразлично, какое из возможных направлений выбирать первым. Например, мы можем попытаться пойти вверх: flg = False # ВВВЕРХ: if (row > 1 and self.labyrinth[col][row - 1] == FREE): self.labyrinth[col][row - 1] = PATH stack.push(col * 1000 + row - 1) flg = True Но, прежде всего, мы должны убедиться, что сверху есть клетка и она свободна. Если это так, то мы смело идём вверх. Теперь эта клетка продолжа- 234
ет путь. Мы отмечаем клетку числом PATH и цветом, запоминаем её координаты на стеке и устанавливаем флажок. Если не удастся пойти вверх, то мы последовательно пробуем другие направления: # ВПРАВО: elif (col < COLS - 1 and self.labyrinth[col + 1][row] == FREE): self.labyrinth[col + 1][row] = PATH stack.push((col + 1) * 1000 + row) flg = True # ВНИЗ: elif (row < ROWS - 1 and self.labyrinth[col][row + 1] == FREE): self.labyrinth[col][row + 1] = PATH stack.push(col * 1000 + row + 1) flg = True # ВЛЕВО: elif (col > 0 and self.labyrinth[col - 1][row] == FREE): self.labyrinth[col - 1][row] = PATH stack.push((col - 1) * 1000 + row) flg = True Если флажок flg поднят, значит, мы нашли продолжение пути. Удлиняем путь: # нашли продолжение пути: if (flg): # клетка пройдена: self.lenPath += 1 Если же флажок так и не поднялся во весь рост, то последняя клетка пути оказалась тупиковой. Тогда мы должны отойти назад – на предыдущую клетку пути. А текущую клетку мы отмечаем как непроходимую и удаляем её из стека: else: # возвращаемся на 1 клетку назад: self.lenPath -= 1 self.lenTupik += 1 colrow = stack.pop() col = colrow // 1000 row = colrow % 1000 self.labyrinth[col][row] = NOPATH 235
Прежде чем добраться до выхода, алгоритм DFS немало поплутает в застенках Лабиринта и не раз попадёт в тупиковые ситуации. На Рис. 4 видно, что из запутанного Лабиринта выбраться совсем непросто! Рис. 4. (С)Ложные пути Но кто ищет свой путь в Лабиринте, тот всегда найдёт выход из него (Рис. 5)! Наблюдать за поисковыми потугами искусственного интеллекта очень интересно, поэтому нажимаем кнопку Создать, а затем Найти путь и с наслаждением отдыхаем, пока искусственный интеллект работает во всю силу (Рис. 6). 236
Рис. 5. Путь проложен! Рис. 6 237
Проект Как выбраться из Лабиринта 2 Как и при построении Лабиринта нам хотелось бы не сразу получать путь в Лабиринте в готовом виде, а лично присутствовать при нахождении пути. В методе setup объявляем игру законченной: self.isGameOver = True В методе on_draw вызываем метод findPath2, если путь в Лабиринте ещё не построен: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() self.background_color = BACKGROUND_COLOR if not self.isGameOver and not self.isReady: self.findPath2() # рисуем Лабиринт: self.draw_labyrinth() # обновляем информацию: self.drawInfo() self.uimanager.draw() После каждого нажатия на кнопку Создать переводим переменную isGameOver в состояние True: # НАЖИМАЕМ КНОПКУ "СОЗДАТЬ" def on_button_createLab_click(self, event): self.isGameOver = True self.label_lenPath.text = " " self.label_lenTupik.text = " " self.createLab() Метод findPath обрываем на самом интересном месте, как детективный сериал: # НАЧИНАЕМ ПОИСКИ def findPath(self): if self.isReady: return 238
# создаём стек: self.stack = Stack() # сбрасываем флажок: #flg = False # путей пока нет: self.lenPath = 0 self.lenTupik = 0 # старт в верхнем левом углу: col = 1 row = 1 # клетка пройдена: self.labyrinth[col][row] = PATH # запоминаем координаты входа на стеке: self.stack.push(1000 * col + row) self.lenPath += 1 self.isGameOver = False В этой части метода findPath мы проводим все подготовительные работы и помещаем начальную клетку пути в стек. Во второй части, или серии метода findPath мы ищем продолжение пути на 1 клетку вперёд или назад – как повезёт: def findPath2(self): # координаты текущей клетки пути: colrow = self.stack.peek() col = colrow // 1000 row = colrow % 1000 if ((col == END_COL) and (row == END_ROW)): print("Длина пути =", self.lenPath) self.isReady = True return flg = False # ВВВЕРХ: if (row > 1 and self.labyrinth[col][row - 1] == FREE): self.labyrinth[col][row - 1] = PATH self.stack.push(col * 1000 + row - 1) flg = True # ВПРАВО: elif (col < COLS - 1 and self.labyrinth[col + 1][row] == FREE): self.labyrinth[col + 1][row] = PATH self.stack.push((col + 1) * 1000 + row) flg = True # ВНИЗ: elif (row < ROWS - 1 and self.labyrinth[col][row + 1] == FREE): self.labyrinth[col][row + 1] = PATH self.stack.push(col * 1000 + row + 1) 239
flg = True # ВЛЕВО: elif (col > 0 and self.labyrinth[col - 1][row] == FREE): self.labyrinth[col - 1][row] = PATH self.stack.push((col - 1) * 1000 + row) flg = True # нашли продолжение пути: if (flg): # клетка пройдена: self.lenPath += 1 else: # возвращаемся на 1 клетку назад: self.lenPath -= 1 self.lenTupik += 1 colrow = self.stack.pop() col = colrow // 1000 row = colrow % 1000 self.labyrinth[col][row] = NOPATH Запускаем программу, нажимаем кнопку Найти путь и наблюдаем за увлекательным процессом нахождения пути в Лабиринте. Как обычно, путь к успеху долгий и запутанный (Рис. 7). Рис. 7 240
На наш не шибко умный, но настойчивый и трудолюбивый алгоритм мы вполне можем положиться – он всегда приведёт нас к цели (Рис. 8)! Рис. 8 241
Игра #12. Космический охотник До сей поры по запутанным коридорам бродил только наш алгоритм DFS, но не для того мы Лабиринт городили! Пора отправить туда странствующего Охотника. Охотник до Скалоедов слишком велик и неуклюж для наших лабиринтовых экспериментов, поэтому я нашёл ему подходящую замену - это космический червяк (Рис. 1)! Рис. 1 Как известно из фантастических фильмов, в космическом пространстве водится всякая нечисть, в том числе и злобные черви (Рис. 2). 242
Рис. 2 Нам такой ни к чему и не нужен. Наш червячок милый, добрый и покладистый. В файле worm.png очень много картинок с этим космическим героем, так что вы можете выбрать любого из них и даже анимировать его ползучие перемещения. Я выбрал вторую картинку, уменьшил её в размерах и отразил по горизонтали, чтобы червяк смотрел в оба (Рис. 3). Рис. 3 Так как клетки Лабиринта весьма миниатюрны, то и Охотник мелковат. Если сделать клетки крупнее, то Лабиринт получится слишком простым. В следующих проектах мы преодолеем это противоречие. Проект Космический охотник Фоновая картинка (Рис. 1) имеет размеры 1000 х 760 пикселей, поэтому размеры окна программы должны быть такими же: # размеры окна в пикселях: WINDOW_WIDTH = 1000 WINDOW_HEIGHT = 760 243
Рис. 1 В верхней части фоновой картинки уже стоит готовое название программы, поэтому Лабиринт необходимо опустить и слегка ужать по высоте: # отступы: OFFSET_X = 0 OFFSET_Y = 100 ZAZOR = 0 # ширина поля в клетках: COLS = 49 # высота поля в клетках: ROWS = 33 Для красочного чествования победителя-выходца из Лабиринта я изготовил поздравительную открытку (Рис. 2). Лабиринты строить мы умеем, но теперь Лабиринт – это часть большой программы, поэтому код нужно доработать. 244
Рис. 2 В методе setup мы загружаем все картинки и звуки. Это разовая операция, а собственно игра начинается в методе newGame: # ПОДГОТОВКА def setup(self): # загружаем фоновую картинку: self.back = arcade.load_texture("images/фон космический охотник.jpg") # победная табличка: self.victory = arcade.load_texture("images/imgVictory.jpg") # загружаем Охотника: self.worm_r = arcade.load_texture("images/worm_r.png") self.worm_l = arcade.load_texture("images/worm_l.png") # смотрит вправо: self.worm_dir = EAST # загружвем звуки --> # ошибка: self.error = arcade.load_sound("sounds/error.wav") # шаг Охотника: self.stepo = arcade.load_sound("sounds/stepo.mp3") # победа: self.win = arcade.load_sound("sounds/win.wav") # массив игрового поля: self.labyrinth = [[WALL for row in range(ROWS)] for col in range(COLS)] self.newGame() Процесс строительства Лабиринта не изменился: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # создаём Лабиринт: self.createLab() Охотник должен знать, где выход из Лабиринта, иначе он будет вечно скитаться по тесным коридорам этого монументального сооружения. Как и прежде, будем считать, что выход из Лабиринта находится в его правом нижнем углу: 245
# координаты финишной клетки: END_COL = COLS - 2 END_ROW = ROWS – 2 Отмечаем конечную клетку долгого пути Охотника зелёным цветом: # конечная клетка: xc = END_COL * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - END_ROW * h - h / 2 - OFFSET_Y arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (0, 255,0)) Охотник занимает исходную позицию. Традиционно вход в Лабиринт находится в левом верхнем углу. Координаты Охотника в Лабиринте нам ещё пригодятся, поэтому запоминаем их в переменных: # Охотник занимает стартовую позицию: self.playerCol = 1 self.playerRow = 1 Клетки Лабиринта, по которым прошёлся Охотник, отмечаем в массиве labyrinth константой PLAYER_PATH: PLAYER_PATH = 3 # - путь Охотника self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH Метод draw_labyrinth легко опознает путь Охотника и закрасит их полупрозрачным жёлтым цветом: # цвет пути Охотника: PLAYER_PATH_COLOR = (255, 244, 0, 100) elif sost == PLAYER_PATH: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, PLAYER_PATH_COLOR) Поскольку фон окна теперь не однотонный, а картинный, то его жалко заслонять стенами и коридорами Лабиринта. Но и без них нельзя! Идём на компромисс и делаем проходы совершенно прозрачными: 246
# свободная клетка: FREE_COLOR = (0,0,0,0) #arcade.color.WHITE В методе draw_labyrinth рисуем нашего червивого Охотника с учётом направления его взгляда на жизнь: # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): . . . elif sost == PLAYER_PATH: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, PLAYER_PATH_COLOR) # начальная клетка: xc = 1 * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - 1 * h - h / 2 - OFFSET_Y arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (255, 0, 0)) # конечная клетка: xc = END_COL * w + w / 2 + OFFSET_X yc = WINDOW_HEIGHT - END_ROW * h - h / 2 - OFFSET_Y arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (0, 255,0)) # рисуем Охотника: xc = self.playerCol * w + w + OFFSET_X yc = WINDOW_HEIGHT - self.playerRow * h + h * 0 - OFFSET_Y if self.worm_dir == EAST: self.worm_r.draw_scaled(xc, yc) else: self.worm_l.draw_scaled(xc, yc) Метод newGame заканчивается, а игра только начинается: # игра не закончена: self.isGameOver = False Если вы уже сейчас запустите программу, то увидите такую картину (Рис. 3). Пора в путь-дорогу! К сожалению, Охотник не наделён интеллектом, поэтому двигать его по Лабиринту – это наша задача. Для простоты управления будем нажимать клавиши со стрелками (Рис. 4). При любом нажатии на клавиатурную клавишу вызывается метод on_key_press, в котором мы посылаем Охотника подальше – в тот метод, который закреплён за нажатой клавишей: # НАЖИМАЕМ КЛАВИШИ СО СТРЕЛКАМИ def on_key_press(self, key, modifiers): 247
if key == arcade.key.UP: self.stepUp() elif key == arcade.key.DOWN: self.stepDown() elif key == arcade.key.LEFT: self.stepLeft() elif key == arcade.key.RIGHT: self.stepRight() Рис. 3. Жутко, но красиво Рис. 4 248
Все поступательные движения героя мы перенесли в отдельные методы, а здесь мы только реагируем на нажатие клавиш со стрелками. Например, если нажата клавиша СТРЕЛКА ВПРАВО, то управление переходит к методу stepRight. Здесь наш герой должен переместиться на 1 клетку вправо. Переменная playerCol хранит текущую горизонтальную координату клетки Охотника. К ней нужно добавить 1 клетку. Но Охотник может перешагнуть только на свободную клетку. Передаём в метод isCellFree координаты Охотника в Лабиринте, и получаем от неё ясный ответ: если в списке labyrinth клетка Охотника имеет статус FREE, то переход возможен, в противном случае – запрещён законами игрового мира: # свободная клетка? def isCellFree(self, col, row): if self.labyrinth[col][row] == WALL: self.error.play(0.5) return False else: self.stepo.play(0.2) return True Дополнительно этот метод звучно комментирует своё решение. Если путь свободен, Охотник идёт по нему и прописывается в новой клетке: # Шаг вправо def stepRight(self): if self.isCellFree(self.playerCol + 1, self.playerRow): self.playerCol += 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH Дополнительно мы разворачиваем Охотника в сторону его горизонтального движения: self.worm_dir = EAST Шаг влево выполняется аналогично, но Охотник разворачивается на запад, который у нас слева: # Шаг влево def stepLeft(self): 249
if self.isCellFree(self.playerCol - 1, self.playerRow): self.playerCol -= 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.worm_dir = WEST Вертикальные шаги ещё проще, потому что Охотник всегда внимательно смотрит под ноги, и его не нужно вертеть вверх-вниз: # Шаг вверх def stepUp(self): if self.isCellFree(self.playerCol, self.playerRow - 1): self.playerRow -= 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH # Шаг вниз def stepDown(self): if self.isCellFree(self.playerCol, self.playerRow + 1): self.playerRow += 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH Поскольку у нас весь Лабиринт перед глазами, то мы уверенно ведём Охотника к цели (Рис. 5). Рис. 5 250
Каждый шаг приближает Охотника к цели (не каждого, но нашего - точно), поэтому в конце метода on_draw мы вызываем метод is_game_over для проверки наших утверждений: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): . . . if self.is_game_over(): self.victory.draw_scaled(xc, yc, alpha=160) Проверка в методе is_game_over очень простая: если Охотник вступил на выходную клетку Лабиринта, он получает музыкальное приветствие, а игра заканчивается: # ИГРА ЗАКОНЧЕНА def is_game_over(self): if self.isGameOver: return True # игра заканчивается, если игрок находится на # выходной клетке Лабиринта: if self.playerCol == END_COL and self.playerRow == END_ROW: # играем победную музыку: self.win.play() self.isGameOver = True return True else: return False И тогда метод on_draw вслед за музыкой предъявляет Охотнику визуальное поздравление (Рис. 6). # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): . . . if self.is_game_over(): self.victory.draw_scaled(xc, yc, alpha=160) На этом большинство игроков угомонится, а кто неугомонный, тот просто щёлкает мышкой по любому месту в окне программы, чтобы начать новые п(р)охождения в Лабиринте: # НАЧИНАЕМ НОВУЮ ИГРУ def on_mouse_press(self, x, y, button, modifiers): self.newGame() 251
Рис. 6. Пришлось изрядно потоптаться! Поскольку в программе нет кнопок, то такой способ почина игры самый естественный. С этой программой вы можете создавать бесчисленные Лабиринты и блуждать в них, не покладая рук и ног! Проект Большой космический охотник Скопируйте предыдущий проект в папку Большой космический охотник. Наш мягкотелый Охотник оказался мелковат для нашего грандиозного сооружения. В этом проекте мы увеличим его вдвое. Лучшего космического Охотника, чем ловкач-Марио, я не нашёл, поэтому пусть он побродит в нашем Лабиринте. В папке images вы найдёте Марио во всех позах и во всей красе (Рис. 1). 252
Рис. 1. Что ни Марио, то супер! И тут мы должны прибегнуть к художественному вырезанию, чтобы вызволить всех Марио из общей картинки. Нам потребуются только 2, 3 и 4 Марио, причём последнего Марио нужно ещё отразить по горизонтали, чтобы он смотрел и направо тоже (Рис. 2). Рис. 2 Фоновую картинку мы заменим, потому что надпись сверху теперь не нужна (Рис. 3). Размеры фона и окна программы 1024 х 768 пикселей 253
Рис. 3 Создаём переменную nMoves, которая послужит нам счётчиком ходов: # счётчик ходов: self.nMoves = 0 В методе isCellFree мы увеличиваем счётчик шагов при каждом переходе Марио на новую клетку: # свободная клетка? def isCellFree(self, col, row): if self.labyrinth[col][row] == WALL: self.error.play(0.5) return False else: self.stepo.play(0.2) self.nMoves += 1 return True Для показа информации на экране создаём метку: 254
# метка: self.label_moves = UILabel(text=" ", font_size=16, bold=False, width=200, text_color=(144, 238, 144)) self.uimanager.add(UIAnchorWidget(child=self.label_moves, align_x=-390, align_y=360)) В конце метода on_draw обновляем эту метку: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) # рисуем Лабиринт: self.draw_labyrinth() # обновляем информацию: self.drawInfo() if self.is_game_over(): self.victory.draw_scaled(xc, yc, alpha=160) self.uimanager.draw() В методе setup загружаем картинки с Марио: # ПОДГОТОВКА def setup(self): # загружаем фоновую картинку: self.back = arcade.load_texture("images/фон большой космический охотник.jpg") # победная табличка: self.victory = arcade.load_texture("images/imgVictory.jpg") # загружаем Марио: self.mario_right = arcade.load_texture("images/Mario_right.png") self.mario_left = arcade.load_texture("images/Mario_left.png") self.mario_up = arcade.load_texture("images/Mario_up.png") self.mario_down = arcade.load_texture("images/Mario_down.png") # смотрит вправо: self.mario_dir = EAST Клетки Лабиринта увеличились вдвое, а размеры самого Лабиринта вернулись к прежним значениям: 255
# ширина поля в клетках: COLS = 49 # высота поля в клетках: ROWS = 35 # размеры клеток в пикселях: CELL_WIDTH = 40 CELL_HEIGHT = 40 Теперь во всех шаговых функциях мы поворачиваем Марио в сторону движения: # Шаг вправо def stepRight(self): if self.isCellFree(self.playerCol + 1, self.playerRow): self.playerCol += 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.mario_dir = EAST # Шаг влево def stepLeft(self): if self.isCellFree(self.playerCol - 1, self.playerRow): self.playerCol -= 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.mario_dir = WEST # Шаг вверх def stepUp(self): if self.isCellFree(self.playerCol, self.playerRow - 1): self.playerRow -= 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.mario_dir = NORTH # Шаг вниз def stepDown(self): if self.isCellFree(self.playerCol, self.playerRow + 1): self.playerRow += 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.mario_dir = SOUTH Наш Марио прекрасно смотрится на фоне космического пространства и даже может свободно ходить по клеткам, но Лабиринт, построенный из больших клеток, не поместится даже в бесконечной вселенной (Рис. 4). В компьютерных играх для перемещения по большим игровым полям используют камеры, которые показывают на экране только часть большой сцены. Камера перемещается вслед за игроком, поэтому Марио никогда не исчезнет из поля нашего внимания. Если мы применим единственную камеру, то элементы управления и фоновая картинка останутся на своих сценических местах и при перемещении камеры исчезнут за границами 256
кадра. В этом случае добавляют вторую камеру, которая жёстко закреплена в начальной позиции. Через эту камеру мы будем показывать элементы управления и фоновую картинку, и тогда они всегда останутся на своих экранных местах. При рисовании нового кадра мы переключаемся между камерами, чтобы отдельно показать элементы управления, фоновую картинку и Лабиринт с нашим героическим Марио. Рис. 4 В методе setup создаём 2 камеры с размером кадра во всё окно программы: # ПОДГОТОВКА def setup(self): . . . # создаём камеры: self.camera_main = arcade.Camera(WINDOW_WIDTH, WINDOW_HEIGHT) self.camera_gui = arcade.Camera(WINDOW_WIDTH, WINDOW_HEIGHT) 257
В методе newGame мы задаём начальные границы кадра – нижнюю и верхнюю. При нулевых значениях мы увидим на экране ту самую часть сцены, что и раньше: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): . . . # границы кадра: self.view_bottom = 0 self.view_left = 0 В методе on_draw мы включаем нужную камеру, когда перерисовываем элементы управления, фоновую картинку и Лабиринт вместе с Марио: def on_draw(self): self.clear() # включаем неподвижную камеру: self.camera_gui.use() # рисуем фоновую картинку: xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.back.draw_scaled(xc, yc) # включаем мобильную камеру: self.camera_main.use() # рисуем Лабиринт: self.draw_labyrinth() # включаем неподвижную камеру: self.camera_gui.use() # обновляем информацию: self.drawInfo() if self.is_game_over(): self.victory.draw_scaled(xc, yc, alpha=160) # рисуем ЭУ: self.uimanager.draw() При статичной сцене мы обновляли позиции игроков в собственных методах, но никогда не использовали встроенный метод on_update, который автоматически вызывается при каждом изменении координат игровых объектов. Здесь мы изменяем границы текущего кадра для мобильной камеры так, чтобы Марио всегда оставался в кадре. Это сделать несложно, но мы должны также следить, чтобы границы Лабиринта не отходили от границ кад258
ра, иначе по краям образуются пустые места, то есть часть Лабиринта будет просто отсутствовать на экране. Мы избежим пустот, если для каждой границы кадра вычислим отступ margin в клетках Лабиринта, чтобы по границам было видно не менее 10 клеток (это значение вы можете изменить, чтобы увидеть, как действует этот механизм). Остальная геометрия проста и понятна: def on_update(self, delta_time): # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT xc = self.playerCol * w + w + OFFSET_X yc = WINDOW_HEIGHT - self.playerRow * h + h * 0 - OFFSET_Y margin = min(10, self.playerCol) # сдвигаем Лабиринт влево: left_boundary = self.view_left + margin * w if (xc - w/2) < left_boundary: self.view_left -= left_boundary - (xc - w/2) # сдвигаем Лабиринт вправо: margin = min(10, COLS - self.playerCol - 1) right_boundary = self.view_left + self.width - margin * w if (xc + w/2) > right_boundary: self.view_left += (xc + w/2) - right_boundary # сдвигаем Лабиринт вверх: margin = min(10, self.playerRow) top_boundary = self.view_bottom + self.height - margin * h if (yc + h/2) > top_boundary: self.view_bottom += (yc + h/2) - top_boundary # сдвигаем Лабиринт вниз: margin = min(10, ROWS - self.playerRow - 1) bottom_boundary = self.view_bottom + margin * h if (yc - h/2) < bottom_boundary: self.view_bottom -= bottom_boundary - (yc - h/2) Вычислив новые границы кадра, мы перемещаем камеру в новую позицию. Для этого служит метод камеры move_to. Он получает новые координаты камеры position, а также скорость перемещения камеры CAMERA_SPEED: # скорость перемещения камеры 0.1..1.0 CAMERA_SPEED = 0.1 # перемещаем главную камеру за Марио: 259
position = Vec2(self.view_left, self.view_bottom) self.camera_main.move_to(position, CAMERA_SPEED) Если CAMERA_SPEED = 1, то камера уже в следующем кадре перепрыгнет в новую позицию. Для плавного перемещения камеры нужно задавать значения CAMERA_SPEED меньше единицы. Для информирования игрока о длине пути я добавил в метод drawInfo значение переменной lenPath: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): self.label_moves.text = f"Ходов: {self.nMoves}/{self.lenPath}" Совершенно невероятно, чтобы Марио выбрался из Лабиринта за минимальное число ходов, но зато игрок сразу сможет представить себе, как именно пролегает путь в Лабиринте. А стремление к высоким достижениям – похвальная черта всех героев. Как вы помните, длину пути находит метод findPath. Вызываем его после создания нового Лабиринта: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): # создаём Лабиринт: self.createLab() self.findPath() Одновременно метод findPath обозначит в массиве labyrinth клетки пути и тупиков. Чтобы они не слишком ярко выделялись на экране, сделаем цвета этих клеток почти прозрачными: # цвет клеток пути: PATH_COLOR = (255, 0, 255, 60) # цвет тупиковых клеток: NOPATH_COLOR = (255, 105, 105, 100) Поскольку типики нас сейчас вообще не интересуют, то либо сделайте цвет тупиковых клеток полностью прозрачным, либо закомментируйте рисование тупиковых клеток: #elif sost == NOPATH: # arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, NOPATH_COLOR) 260
Для отладки программы я оставил выделенными клетки пути (Рис. 5). Рис. 5 Шагая по ним, Марио быстро доберётся до финиша. После отладки приложения уберите клетки пути так же, как тупиковые клетки, иначе задача игрока будет излишне простой. Как вы видите на Рис. 5⬆, вход в Лабиринт и Марио видны великолепно, а вот большая часть Лабиринта и выход из него скрылись далеко за горизонтом. По мере продвижения Марио по пути прогресса мы увидим ранее потаённые коридоры, но цельная картина ристалища нам недоступна (Рис. 6). Долгосрочные прогнозы и планы в условиях недостаточной информации и видимости невозможны, и Марио придётся немало потоптаться по космической пыли, прежде чем он выберется наружу. Странствия и мытарства Марио закончатся в методе is_game_over, когда он торжественно вступит на конечную клетку долгого пути в Лабиринте. И тогда Марио получит заслуженные визуальные и музыкальные поздравления (Рис. 7). 261
Рис. 6 Рис. 7 262
Проект Деньги любят счёт Скопируйте предыдущий проект в папку Деньги любят счёт. На этом безмятежную, сытую жизнь нашего героя в самую пору закончить, поэтому мы напрячем в закоулках Лабиринта некие артефакты, которые поспособствуют охотнику Марио в поисках выхода из оттуда. Это могут быть эликсиры, самурайские мечи, палицы, пиастры и прочий хлам, которым щедро напичканы компьютерные ролевые игры (Рис. 1). И непременно Марио должен в результате тяжких испытаний и скитаний найти ключ от квартиры, где деньги лежат… Мы убережём и себя, и нашего шустрого друга Марио от невзгод и мучений, если ограничимся золотыми монетками, случайно разбросанными по закоулкам и тупикам Лабиринта. Для увеселения Марио мы закрутим и завращаем монетки с помощью простейшей анимации. В папку sprites я скопировал замечательную монетку в разных поворотах и положениях (Рис. 2). Рис. 1. Игровые артефакты Рис. 2 263
Из одной картинки анимацию не сделаешь, поэтому её нужно ужать и порезать на отдельные кадры (Рис. 3). Рис. 3 Так мы получим ровным счётом 10 одиночных монеток размерами 64 х 64 пикселя. Точно такие же размеры получат и клетки самого Лабиринта: # размеры клеток в пикселях: CELL_WIDTH = 64 CELL_HEIGHT = 64 Чтобы не унижать достоинство Марио, мы увеличим и его до подобающих размеров (Рис. 4). Рис. 4 Монетки не только украшают игру, но и увеличивают азарт Марио, который должен собрать все монетки в свои кармашки. Интерфейс программы я предельно упростил. Космический фон нам больше не нужен, гораздо лучше подходит одноцветный тёмный фон, на котором хорошо видны стены, Марио и монетки. Готовый Лабиринт выглядит совсем неплохо (Рис. 5), но не помещается целиком на экране. В методе drawInfo 2 надписи сообщают игроку число шагов Марио в Лабиринте, минимальную длину пути (Марио должен ещё собрать монеты, поэтому минимальный путь недостижим), а также число найденных и спрятанных монеток: # ОБНОВЛЯЕМ ИНФОРМАЦИЮ def drawInfo(self): text = f"{self.nMoves}/{self.lenPath}" arcade.draw_text(text, 90, 725, (57, 252, 0), font_size=24, bold=True ) text = f"{self.foundCoins}/{self.totalCoins}" arcade.draw_text(text, 290, 725, (239, 177, 0), font_size=24, bold=True ) 264
Если про длину пути вы уже всё знаете, то новые переменные totalCoins и foundCoins хранят общее число монет, спрятанных в Лабиринте, и число монет, которые Марио уже отыскал. Рис. 5 Назначение надписей показывают картинки со следами и монеткой (Рис. 6). Рис. 6 Ключ красного цвета показывает, что Марио не сможет выйти из Лабиринта, пока не соберёт все монетки. Вместе с последней монеткой Марио получит зелёный ключ, который распахнёт перед ним дверь из Лабиринта (Рис. 7). Рис. 7 265
Я заменил одноцветные клетки Лабиринта спрайтами box64.png и wall33.png. Из боксов мы выстроим стены Лабиринта, а из спрайтов с каменной кладкой – проходы (Рис. 6). Рис. 6 Каменный спрайт полупрозрачный, поэтому через него виден фон сцены. Размеры Лабиринта в клетках я уменьшил, иначе игроку придётся долго водить Марио по коридорам, которые только частично видны на экране: # ширина поля в клетках: COLS = 19 # высота поля в клетках: ROWS = 15 Поскольку мы теперь рисуем Лабиринт не геометрическими примитивами-квадратиками, а полноценными спрайтами, то скорость работы программы сильно падает. В этом случае спрайты помещают в списки. Мы создадим 2 списка – отдельно для стен и монет: # КЛАСС ИГРЫ class Game(arcade.Window): # КОНСТРУКТОР: def __init__(self, width, height, title): super().__init__(width, height, title, center_window=True) # список стен: self.wall_list = None # список монет: self.coin_list = None self.setup() Подготовка к игре в методе setup стала более основательной. Здесь мы загружаем новые спрайты и обновляем Марио: # ПОДГОТОВКА def setup(self): # победная табличка: self.victory = arcade.load_texture("images/imgVictory.jpg") # следы: self.moves = arcade.load_texture("sprites/шаги80.png") # монета: 266
self.coin = arcade.load_texture("sprites/coin64.png") # ключи: self.key_green = arcade.load_texture("sprites/ключ_зел.png") self.key_red = arcade.load_texture("sprites/ключ60_красн.png") # стена: self.wall_list = arcade.SpriteList() # загружаем Марио: self.mario_right = arcade.load_texture("sprites/Mario_right.png") self.mario_left = arcade.load_texture("sprites/Mario_left.png") self.mario_up = arcade.load_texture("sprites/Mario_up.png") self.mario_down = arcade.load_texture("sprites/Mario_down.png") # смотрит вправо: self.mario_dir = EAST Добавляем звук dzin, который сопровождает перемещение монетки из Лабиринта в кармашек Марио: # загружвем звуки --> # ошибка: self.error = arcade.load_sound("sounds/error.wav") # шаг Охотника: self.stepo = arcade.load_sound("sounds/stepo.mp3") # победа: self.win = arcade.load_sound("sounds/win.wav") # монета: self.dzin = arcade.load_sound("sounds/дзинь.mp3") Элементы управления нам напрочь не нужны, а остальная часть метода setup вам известна по предыдущему проекту: # массив игрового поля: self.labyrinth = [[WALL for row in range(ROWS)] for col in range(COLS)] # создаём камеры: self.camera_main = arcade.Camera(WINDOW_WIDTH, WINDOW_HEIGHT) self.camera_gui = arcade.Camera(WINDOW_WIDTH, WINDOW_HEIGHT) # начинаем новую игру: self.newGame() Новая игра начинается, как и прежде, но с небольшими изменениями и дополнениями. Начало метода newGame осталось неизменным: # НАЧИНАЕМ НОВУЮ ИГРУ def newGame(self): 267
# создаём Лабиринт: self.createLab() self.findPath() # Охотник занимает стартовую позицию: self.playerCol = 1 self.playerRow = 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH # счётчик ходов: self.nMoves = 1 В этой программе Марио не просто слоняется по Лабиринту, но по ходу своего броуновского движения ещё и собирает монетки. Общее число монет мы выбираем случайно из разумного диапазона, а число найденных монет обнуляем: # выбираем число монет: self.totalCoins = randint(5, 10) self.foundCoins = 0 Теперь монетки нужно создать и умело разбросать по коридорам Лабиринта, что мы и делаем в новом методе hidingCoins: # прячем монеты в Лабиринте: self.hidingCoins() Марио получает ключ от Лабиринта только тогда, когда соберёт все монетки. Пока это не так, переменная isKey равна False: # не все монетки найдены: self.isKey = False Во многих играх нужно найти именно ключ, чтобы открыть дверь. В нашей игре нет никакого смысла искать ключ, потому что игрок всё равно должен собрать все монетки, чтобы выйти из Лабиринта. Когда он соберёт все монетки, ключ он получит автоматически. Действительно, поиск ключа ничем не отличается от поиска монет, поэтому вполне достаточно собрать монетки и получить за это приз – ключ от Лабиринта. Метод newGame также заканчивается знакомыми вам строками: # границы кадра: self.view_bottom = 0 268
self.view_left = 0 # игра не закончена: self.isGameOver = False Теперь рассмотрим функцию hidingCoins, которая создаёт и прячет монетки. Монеты это не просто картинки, а игровые объекты, поэтому нужно создать класс, который описывает свойства и поведение монеток. Класс Coin наследует классу arcade.Sprite и получает от него все свойства спрайтов. Дополнительно мы добавляем переменные, которых нет у обычных спрайтов: # КЛАСС МОНЕТЫ class Coin(arcade.Sprite): # КОНСТРУКТОР def __init__(self): super().__init__() # координаты клетки Лабиринта с монетой: self.col = 0 self.row = 0 # анимация монеты: self.frame = 0 self.num_tex = 0 Для анимированного вращения монет мы приготовили 10 картинок с названиями coin_0.png..coin_9.png. Они отличаются только цифрой, поэтому все спрайты мы легко загрузим в список текстур в обычном цикле: # список спрайтов с монетами: self.textures = [] for i in range(0, 9+1): path=(f"sprites/coin_{i}.png") texture = arcade.load_texture(path) self.textures.append(texture) Анимация обеспечивается циклическим изменением текущей текстуры в свойстве texture спрайта. Текстуры мы получаем из списка textures по текущему индексу num_tex. Если менять спрайты при каждой перерисовке сцены, то монета вращается слишком быстро. Мы уменьшим скорость вращения в 3 раза, если будем изменять спрайт только каждый третий кадр: 269
# АНИМИРУЕМ ВРАЩЕНИЕ МОНЕТЫ def update_animation(self): # меняем спрайты только каждый третий кадр: if self.frame % 3 == 0: self.texture = self.textures[self.num_tex] # следующая текстура: self.num_tex = (self.num_tex + 1) % 10 # считаем кадры: self.frame += 1 Начинаем прятать монеты в методе hidingCoins. Как вы помните, все монеты должны попасть в список coin_list, который пока пуст: # Прячем монеты в Лабиринте def hidingCoins(self): self.coin_list = arcade.SpriteList() В переменной nm считаем уже спрятанные монеты: # ни одна монета не спрятана: nm = 0 Размеры клеток Лабиринта нужны нам для вычисления координат монет на сцене: # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT В цикле while мы создаём и прячем монетки, пока значение переменной nm меньше общего числа монет totalCoins: # пока не все монеты спрятаны: while (nm < self.totalCoins): Выбираем для очередной монетки случайную клетку в Лабиринте. Естественно, эту клетку не может занимать Марио, и монетка не должна вертеться на самом выходе из Лабиринта: # случайные координаты клетки col, row: col = randint(2, COLS-1) row = randint(2, ROWS-1) 270
# монетка не должна быть в конечной клетке: if (col == END_COL and row == END_ROW): continue Также мы не можем спрятать монету в стене или в клетке, в которой уже крутится-вертится другая монета: # пустая клетка: if (self.labyrinth[col][row] != WALL and self.labyrinth[col][row] != COIN): В списке labyrinth отмечаем клетку с монетой константой COIN: COIN = 20 # - монета Если для монетки нашлась пустая клетка, то помещаем её в список labyrinth: # отмечаем её в списке labyrinth: self.labyrinth[col][row] = COIN Теперь эта клетка занята, и другая монетка в неё уже не попадёт. Но пока монета находится ещё в неопределённой позиции. Чтобы визуализовать её, нам нужны её пиксельные координаты на сцене: x = col * w + w / 2 y = WINDOW_HEIGHT - row * h - h / 2 Создаём экземпляр анимированной монетки и добавляем её как игровой объект в список coins_list: coin = Coin() coin.center_x = x coin.center_y = y coin.col = col coin.row = row self.coin_list.append(coin) Новая монетка появится на сцене в выбранной клетке. 271
Считаем созданные монетки, иначе цикл while никогда не закончится: # ещё 1 монета спрятана: nm += 1 На этом статические приготовления к игре закончены, но мы должны ещё научить Марио отыскивать монеты. Пока он не соберёт все монеты, переменная isKey имеет значение False, и игра не закончится, даже если Марио живым доберётся до выхода из Лабиринта: # ИГРА ЗАКОНЧЕНА def is_game_over(self): if self.isGameOver: return True # игра заканчивается, если игрок находится на # выходной клетке Лабиринта: if self.playerCol == END_COL and \ self.playerRow == END_ROW and \ self.isKey: Марио с ключом наперевес открывает двери к победе и получает «тушевную» музыку в награду: # играем победную музыку: self.win.play() self.isGameOver = True return True else: return False Размеры клеток Лабиринта увеличились, поэтому я замедлил скорость перемещения камеры, чтобы сделать её переходы более плавными: # скорость перемещения камеры 0.1..1.0 CAMERA_SPEED = 0.05 Все перемещения Марио остались без изменений, но мы должны учесть, клеточные размеры Лабиринта уменьшились, поэтому более жёстко ограничиваем перемещения камеры по вертикали. В методе on_update мы также вызываем метод update_animation для всех живых монет, чтобы выполнить их анимацию: def on_update(self, delta_time): for coin in self.coin_list: 272
coin.update_animation() # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT xc = self.playerCol * w + w/2 yc = WINDOW_HEIGHT - self.playerRow * h - h/2 margin = min(10, self.playerCol) # сдвигаем Лабиринт влево: left_boundary = self.view_left + margin * w if (xc - w/2) < left_boundary: self.view_left -= left_boundary - (xc - w/2) # сдвигаем Лабиринт вправо: margin = min(10, COLS - self.playerCol - 1) right_boundary = self.view_left + self.width - margin * w if (xc + w/2) > right_boundary: self.view_left += (xc + w/2) - right_boundary # сдвигаем Лабиринт вверх: margin = min(6, self.playerRow) top_boundary = self.view_bottom + self.height - margin * h if (yc + h/2) > top_boundary: self.view_bottom += (yc + h/2) - top_boundary # сдвигаем Лабиринт вниз: margin = min(6, ROWS - self.playerRow - 1) bottom_boundary = self.view_bottom + margin * h if (yc - h/2) < bottom_boundary: self.view_bottom -= bottom_boundary - (yc - h/2) # перемещаем главную камеру за Марио: position = Vec2(self.view_left, self.view_bottom) self.camera_main.move_to(position, CAMERA_SPEED) В методе on_draw все клетки со стенами и монетками рисуем очень просто – вызываем метод draw для всех спрайтов из списков wall_list и coin_list: # ОБНОВЛЯЕМ СЦЕНУ def on_draw(self): self.clear() # включаем неподвижную камеру: self.camera_gui.use() # фон: self.background_color = BACKGROUND_COLOR # включаем мобильную камеру: self.camera_main.use() # рисуем Лабиринт: self.wall_list.draw() self.draw_labyrinth() 273
# рисуем монеты: self.coin_list.draw() И заканчиваем метод on_draw рисованием картинок с шагами, монеткой и ключом: # включаем неподвижную камеру: self.camera_gui.use() # обновляем информацию: self.drawInfo() if self.is_game_over(): xc = WINDOW_WIDTH / 2 yc = WINDOW_HEIGHT / 2 self.victory.draw_scaled(xc, yc, alpha=160) # рисуем ЭУ: self.moves.draw_scaled(40,730, 0.8) self.coin.draw_scaled(240,735) if self.isKey: self.key_green.draw_scaled(440, 735) else: self.key_red.draw_scaled(440, 735) Метод draw_labyrinth упростился, потому что нам не нужно каждый раз возводить стены. Мы отмечаем прозрачным цветом только путь Марио: # РИСУЕМ ЛАБИРИНТ def draw_labyrinth(self): # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT for row in range(ROWS): for col in range(COLS): # клетка Лабиринта: xc = col * w yc = WINDOW_HEIGHT - row * h - h # состояние: sost = self.labyrinth[col][row] if sost == PLAYER_PATH: arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, PLAYER_PATH_COLOR) А также начальную и конечную клетки его долгого и запутанного пути: # начальная клетка: xc = 1 * w yc = WINDOW_HEIGHT - 1 * h - h arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (255, 0, 0, 120)) # конечная клетка: xc = END_COL * w 274
yc = WINDOW_HEIGHT - END_ROW * h - h arcade.draw_lrtb_rectangle_filled(xc, xc + w, yc + h, yc, (0, 255,0, 120)) Рисование самого Марио ничуть не изменилось со времени его последнего путешествия по Лабиринту: # рисуем Марио: xc = self.playerCol * w + w/2 yc = WINDOW_HEIGHT - self.playerRow * h - h/2 if self.mario_dir == EAST: self.mario_right.draw_scaled(xc, yc) elif self.mario_dir == WEST: self.mario_left.draw_scaled(xc, yc) elif self.mario_dir == NORTH: self.mario_up.draw_scaled(xc, yc) else: self.mario_down.draw_scaled(xc, yc) Пока наш Марио только умело ходит по Лабиринту, но не подхватит ни одной монетки. Каждая монетка целиком занимает клетку Лабиринта и отмечена в списке labyrinth константой COIN. Так как Марио может вступить только в свободную от стен клетку, то в методе isCellFree мы можем проверить, а не ждёт ли его там приятный монетный сюрприз: # свободная клетка? def isCellFree(self, col, row): if self.labyrinth[col][row] == WALL: self.error.play(0.5) return False else: # звук шагов: self.stepo.play(0.2) # добавляем 1 шаг: а self.nMoves += 1 # нашёл монету: if self.labyrinth[col][row] == COIN: Найденную монетку удаляем из списка coin_list, а для этого нужно найти в этом списке ту самую монетку, что и Марио: # ищем найденную монету: for coin in self.coin_list: # монета найдена: if coin.col == col and coin.row == row: 275
Монетка издаёт прощальный звук и навсегда покидает список coin_list. Не забываем стирать следы монеток из массива labyrinth, иначе Марио найдёт их ещё много-много раз: self.dzin.play() self.labyrinth[col][row] = PLAYER_PATH coin.remove_from_sprite_lists() Пополняем запасы монет у нашего героя: self.foundCoins += 1 Если все монеты собраны, то вручаем Марио ключ от выхода: # все монеты найдены: if self.foundCoins == self.totalCoins: self.isKey = True break return True Запускаем программу. Марио обиженно стоит в красном углу и жадно смотрит на вертикрутящиеся монетки. Ему предстоит по пути к выходу из Лабиринта собрать в свои карманные закрома 9 монет (Рис. 7). Дзинь-дзинь – и вот уже первая четвёрка монет нашла своего хозяина. Хорошо видны ещё 4 монеты и путь к ним (Рис. 8). Отправляем Марио на сбор урожая монет. Марио беспрепятственно минует выходную клетку – ведь ключа у него нет! – и сгребает в свои безразмерные штанишки с кармашками оставшиеся 4 монетки (Рис. 9). Вот тут бы Марио, да и нам тоже пригодились бы хорошая память и внимательность, поскольку мы не запомнили местонахождение последней монетки. Ведём Марио наверх и видим, что в правом верхнем углу Лабиринтп пусто(та) (Рис. 10). Пятимся вместе с Марио назад, до самой стартовой клетки, но и там нет радости для нас (Рис. 11). 276
Рис. 7 Рис. 8 277
Рис. 9 Рис. 10 278
Рис. 11 Ненароком вспоминается подходящая строка из Давида Самойлова: Куда пойти? Куда податься? Мозжечок и Марио подсказывают, что нам пора спускать с небес на землю, то есть податься вниз. Полезный совет всегда кстати! Идём-шагаем именно туда – и да, конечно! монетка ждёт нас в левом нижнем углу Лабиринта (Рис. 12). Подхватываем её и несёмся окрылённые успехом к финишу нашего короткого, но яркого квеста – и побеждаем (Рис. 13)! Это не только победа Марио, но и ура нам за творческие достижения в программировании игр. 279
Рис. 12 Рис. 13 280
Проект Деньги любят счёт 2 Скопируйте предыдущий проект в папку Деньги любят счёт 2. Мы научились анимировано перемещать камеру вслед за Марио и крутить монетки в Лабиринте, но сам Марио прыгает из одной клетки в другую, как кенгуру. В этом проекте мы добавим ему плавности движения. Для этого мы воспользуемся механизмом перемещения камеры. Марио должен быть примерно в 3 раза шустрее, чем камера, чтобы не задерживать игру: # скорость перемещения Марио: PLAYER_SPEED = 0.15 Как и камера, Марио плавно, в течение нескольких кадров перемещается из одной позиции на сцене в другую. Мы не можем измерять перемещения в клетках, иначе Марио так и будет скакать и прыгать по Лабиринту. Нам нужны координаты Марио в пикселях, потому что мы рисуем картинки на экране соответственно пиксельным координатам спрайта. А Марио перемещается из текущей позиции в целевую. Пока он стоит на месте, они равны. В методе newGame мы получаем пиксельную позицию Марио и запоминаем её в двух переменных: # запоминаем текущую и целевую позицию Марио: self.player_current_pos = self.get_player_cur_pos() self.player_target_pos = self.player_current_pos Вычислять позицию Марио в пикселях мы уже умеем. Просто для удобства переносим все вычисления в отдельный метод: # ВОЗВРАЩАЕМ ТЕКУЩИЕ КООРДИНАТЫ МАРИО В ПИКСЕЛЯХ def get_player_cur_pos(self): # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT xc = self.playerCol * w + w/2 yc = WINDOW_HEIGHT - self.playerRow * h - h/2 return (xc, yc) 281
Этот метод возвращает координаты Марио в пикселях для вполне определённых клеток Лабиринта. Промежуточные пиксельные позиции он не вычисляет! Итак, в начале игры нам известны текущая и целевая позиции Марио. Они совпадают, поэтому Марио даже на 1 пиксель с места не сдвинется. Но затем мы нажимаем клавишу со стрелкой, чтобы повести Марио в соседнюю клетку. Для примера рассмотрим шаг вправо. Все остальные пошаговые методы выполняются точно так же. При перемещении Марио в соседнюю клетку справа, его горизонтальная клеточная позиция увеличивается на 1. По новым клеточным координатам Марио мы получаем от метода get_player_cur_pos его целевую пиксельную позицию: # Шаг вправо def stepRight(self): if self.isCellFree(self.playerCol + 1, self.playerRow): self.playerCol += 1 self.labyrinth[self.playerCol][self.playerRow] = PLAYER_PATH self.mario_dir = EAST self.player_target_pos = self.get_player_cur_pos() То есть после нажатия на клавишу ВПРАВО Марио не прыгает сломя голову в новую клетку, а только получает конечные пиксельные координаты, которые запоминает переменная player_target_pos. Мы рисуем Марио в методе draw_labyrinth, поэтому всю остальную элементарную математику переносим туда. Метод lerp возвращает промежуточную позицию Марио между текущей и конечной с учётом заданной скорости. Чем выше скорость, тем быстрее текущая позиция приближается к конечной: # вычисляем новую позицию Марио: xc = arcade.lerp(self.player_current_pos[0], self.player_target_pos[0], PLAYER_SPEED) yc = arcade.lerp(self.player_current_pos[1], self.player_target_pos[1], PLAYER_SPEED) Если величина очередного перемещения Марио очень мала, то мы полагаем, что он уже достиг конечной позиции. Чтобы избавиться от неточности вещественных вычислений, текущую позицию переносим точно в конечную: 282
# Марио уже на месте: dx = abs(self.player_current_pos[0] - self.player_target_pos[0]) dy = abs(self.player_current_pos[1] - self.player_target_pos[1]) if dx < 1 and dy < 1: self.player_current_pos = self.player_target_pos Если же Марио ещё далёк от конечной позиции, переносим текущую позицию, как это предписывает метод lerp: else: # обновляем текущую позицию игрока: self.player_current_pos = (xc, yc) Обратите внимание, что текущая позиция Марио изменяется со временем! Нам осталось нарисовать Марио в новой позиции, а это мы уже умеем делать: # перемещаем Марио в новую текущую позицию: if self.mario_dir == EAST: self.mario_right.draw_scaled(xc, yc) elif self.mario_dir == WEST: self.mario_left.draw_scaled(xc, yc) elif self.mario_dir == NORTH: self.mario_up.draw_scaled(xc, yc) else: self.mario_down.draw_scaled(xc, yc) Полную анимацию в книге не покажешь, но на Рис. 1 видно, что Марио не перепрыгнул в клетку справа, а степенно переходит в неё. На экране мы всегда видим Марио в текущей позиции, которая с каждым обновлением сцены приближается к конечной. Так как расстояние между текущей и конечной позициями постоянно уменьшается, то вначале Марио двигается быстрее, а по мере приближения к конечной позиции скорость замедляется. Это результат действия линейной интерполяции в методе lerp. При желании вы можете применить другие методы интерполяции, но при небольших перемещениях разница будет незаметна. 283
Рис. 1 Проект Рекурсивный лабиринт На официальном сайте движка Arcade, на странице https://api.arcade.academy/en/latest/examples/maze_recursive.html#mazerecursive вы найдёте исходный код программы Creating a Recursive Maze, которая строит лабиринт (Рис. 1). Использованный в программе алгоритм называется Recursive Division Method, он описан в Википедии (Maze generation algorithm): https://en.wikipedia.org/wiki/Maze_generation_algorithm 284
Рис. 1 В этом проекте мы сравним наш способ (стековый) построения Лабиринта с рекурсивным. Рекурсивный алгоритм также требует, чтобы клеточный размеры Лабиринта были нечётными, поэтому мы оставим размеры нашего Лабиринта без изменений: # ширина поля в клетках: COLS = 19 # высота поля в клетках: ROWS = 15 Как и в нашем алгоритме, сначала мы создаём пустой список списков (двумерную сетку): # массив игрового поля: self.labyrinth = [[WALL for row in range(ROWS)] for col in range(COLS)] 285
# СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): for r in range(ROWS): for c in range(COLS): self.labyrinth[c][r] = EMPTY Построение Лабиринта начинается с возведения стен по его границам: # СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): for r in range(ROWS): for c in range(COLS): self.labyrinth[c][r] = EMPTY # возводим левую и правую стену: for row in range(ROWS): self.labyrinth[0][row] = WALL self.labyrinth[COLS - 1][row] = WALL # возводим верхнюю и нижнюю стену: for col in range(1, COLS - 1): self.labyrinth[col][0] = WALL self.labyrinth[col][ROWS-1] = WALL Чтобы ещё не готовый Лабиринт появился на экране, принимаем его с недоделками: # Лабиринт построен: self.wall_list = arcade.SpriteList() # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT for r in range(ROWS): for c in range(COLS): if self.labyrinth[c][r] == WALL: wall = arcade.Sprite("sprites/box64.png") else: wall = arcade.Sprite("sprites/wall33.png") x = c * w + w / 2 y = WINDOW_HEIGHT - r * h - h / 2 wall.center_x = x wall.center_y = y self.wall_list.append(wall) Марио свободно перемещается по всему Лабиринту, но не может его покинуть (Рис. 2). 286
Рис. 2 287
Остальные стены добавляем к Лабиринту в рекурсивном методе recursive_call, которому передаём клеточные координаты внутренней области Лабиринта: # СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): for r in range(ROWS): for c in range(COLS): self.labyrinth[c][r] = EMPTY # возводим левую и правую стену: for row in range(ROWS): self.labyrinth[0][row] = WALL self.labyrinth[COLS - 1][row] = WALL # возводим верхнюю и нижнюю стену: for col in range(1, COLS - 1): self.labyrinth[col][0] = WALL self.labyrinth[col][ROWS-1] = WALL # продолжаем строить Лабиринт: self.recursive_call(ROWS-1, 0, 0, COLS-1) # Лабиринт построен: self.wall_list = arcade.SpriteList() # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT for r in range(ROWS): for c in range(COLS): if self.labyrinth[c][r] == WALL: wall = arcade.Sprite("sprites/box64.png") else: wall = arcade.Sprite("sprites/wall33.png") x = c * w + w / 2 y = WINDOW_HEIGHT - r * h - h / 2 wall.center_x = x wall.center_y = y self.wall_list.append(wall) В методе recursive_call мы случайным образом делим заданную часть Лабиринта горизонтальными и вертикальными стенами, пока это возможно: # РЕКУРСИВНОЕ ПОСТРОЕНИЕ ЛАБИРИНТА def recursive_call(self, top, bottom, left, right): # определяем, где стена разделит Лабиринт по горизонтали: start_range = bottom + 2 end_range = top - 1 y = randrange(start_range, end_range, 2) 288
# выполняем деление: for column in range(left + 1, right): self.labyrinth[column][y] = WALL # определяем, где стена разделит Лабиринт по вертикали: start_range = left + 2 end_range = right - 1 x = randrange(start_range, end_range, 2) # выполняем деление: for row in range(bottom + 1, top): self.labyrinth[x][row] = WALL # определяем расстояние между стенами: wall = randrange(4) if wall != 0: gap = randrange(left + 1, x, 2) self.labyrinth[gap][y] = EMPTY if wall != 1: gap = randrange(x + 1, right, 2) self.labyrinth[gap][y] = EMPTY if wall != 2: gap = randrange(bottom + 1, y, 2) self.labyrinth[x][gap] = EMPTY if wall != 3: gap = randrange(y + 1, top, 2) self.labyrinth[x][gap] = EMPTY # если ещё есть возможность, # то продолжаем строить стены: if top > y + 3 and x > left + 3: self.recursive_call(top, y, left, x) if top > y + 3 and x + 3 < right: self.recursive_call(top, y, x, right) if bottom + 3 < y and x + 3 < right: self.recursive_call(y, bottom, x, right) if bottom + 3 < y and x > left + 3: self.recursive_call(y, bottom, left, x) Запускаем программу. Лабиринт построен, но хорошо видно, что большинство коридоров очень длинные (Рис. 3). Заселяем Лабиринт золотыми монетами и отправляем Марио на сбор урожая (Рис. 4). 289
Рис. 3 Рис. 4 290
Рекурсивные Лабиринты очень простые, поэтому их смогут осилить даже маленькие дети. Проект Дальнобойный лабиринт На официальном сайте движка Arcade, на странице https://api.arcade.academy/en/latest/examples/maze_depth_first.html#mazedepth-first есть также исходный код программы Creating a Depth First Maze (Рис. 1). Рис. 1 Здесь для построения Лабиринта используется алгоритм DFS, с которым вам уже знакомы, но его реализация отличается от нашей, поэтому для полноты картины и расширения кругозора мы изучим его в нашей новой программе. Сначала строим все стены (Рис. 2): 291
# СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): for row in range(ROWS): for col in range(COLS): if col % 2 == 1 and row % 2 == 1: self.labyrinth[col][row] = EMPTY elif col == 0 or row == 0 or col == COLS - 1 or row == ROWS - 1: self.labyrinth[col][row] = WALL else: self.labyrinth[col][row] = WALL Рис. 2 Затем в методе makeLabyrinth вызываем метод dfs для удаления части стен: # СОЗДАЁМ НОВЫЙ ЛАБИРИНТ def makeLabyrinth(self): for row in range(ROWS): for col in range(COLS): if col % 2 == 1 and row % 2 == 1: self.labyrinth[col][row] = EMPTY elif col == 0 or row == 0 or col == COLS - 1 or row == ROWS - 1: 292
self.labyrinth[col][row] = WALL else: self.labyrinth[col][row] = WALL # сносим стены: self.dfs() # Лабиринт построен: self.wall_list = arcade.SpriteList() # размеры клеток: w = CELL_WIDTH h = CELL_HEIGHT for r in range(ROWS): for c in range(COLS): if self.labyrinth[c][r] == WALL: wall = arcade.Sprite("sprites/box64.png") else: wall = arcade.Sprite("sprites/wall33.png") x = c * w + w / 2 y = WINDOW_HEIGHT - r * h - h / 2 wall.center_x = x wall.center_y = y self.wall_list.append(wall) Алгоритм построения Лабиринта описан в статье: https://www.algosome.com/articles/maze-generation-depth-first.html Нам нужно только аккуратно переписать его на питоний язык: def dfs(self): w = (COLS - 1) // 2 h = (ROWS - 1) // 2 vis = [[0] * w + [1] for _ in range(h)] + [[1] * (w + 1)] def walk(x: int, y: int): vis[y][x] = 1 d = [(x - 1, y), (x, y + 1), (x + 1, y), (x, y - 1)] shuffle(d) for (xx, yy) in d: if vis[yy][xx]: continue if xx == x: self.labyrinth[x * 2 + 1][max(y, yy) * 2] = EMPTY if yy == y: self.labyrinth[max(x, xx) * 2][y * 2 + 1] = EMPTY walk(xx, yy) walk(randrange(w), randrange(h)) 293
Запускаем программу и убеждаемся, что перевод удался нам на славу (Рис. 3)! Рис. 3 Задания для самостоятельного решения 1. Анимация у нас получилась на загляденье, вот только Марио совсем не шевелит ножками, а просто скользит в другую клетку. Для развития игры постарайтесь анимировать ножные движения Марио. Это точно такой же процесс, как и вращение монеты, но дополнительно нужно учитывать направление перемещения Марио. 2. Идя ещё дальше по пути игрового прогресса, спрячьте в Лабиринте новые артефакты, вредные предметы и безжалостных монстров. Но тогда вам придётся вооружить Марио базуками и пищалями, а также снабжать его водой, едой, амулетами и целебными снадобьями. 294
Как создать исполняемый файл .exe В некоторых случаях (например, при распространении своих программ) нужно иметь исполняемую программу, которая запускается двойным щелчком из файлового менеджера. Если имеется только исходный код на Питоне с расширением .py, то его можно запустить только из среды разработки или из командной строки. В любом случае на компьютере должен быть установлен Питон, который пользователям, возможно, не нужен. Откройте страницу: https://pypi.org/project/auto-py-to-exe/ Нажмите кнопку (Рис. 1), чтобы скопировать строку в буфер обмена. Рис. 1 Откройте в среде разработки папку с проектами, а затем Терминал и вставьте из буфера обмена скопированную ранее строку (Рис. 2). Рис. 2 Нажмите клавишу ВВОД и ждите окончания установки библиотеки Auto Py to Exe. Запустите библиотеку из Терминала командой auto-py-to-exe (Рис. 3). Рис. 3 295
Через некоторое время появится диалоговое окно с настройками. Выберите из списка русский язык (Рис. 4). Рис. 4 Нажмите кнопку Расположение и выберите главный файл вашей программы на Питоне (Рис. 5). Оставьте нажатой кнопку Одна Папка. Конечно, было бы гораздо лучше создать единственный исполняемый файл, но мне это не удалось. 296
Рис. 5 Нажмите кнопку Оконное Приложение (скрыть консоль) (Рис. 6), иначе при запуске скомпилированной программы появится не только Графическое окно вашей программы, но и совершенно ненужное Консольное окно. Рис. 6 297
Если, кроме главного файла, в программе есть и другие файлы и ресурсы (а в играх без них нельзя), то раскройте окно Дополнительный файлы (Рис. 7). Рис. 7 Если нужно, добавьте остальные файлы программы (Рис. 8) и папки с ресурсами (Рис. 9). Рис. 8 Рис. 9 Нажмите кнопку КОНВЕРТИРОВАТЬ .PY B .EXE (Рис. 10). 298
Рис. 10 Когда программа скомпилируется, нажмите кнопку ОТКРЫТЬ ПАПКУ ВЫВОДА (Рис. 11). Или найдите в папке с проектами папку output, а в ней – папку skaloedy и файл skaloedy.exe (Рис. 12). Запустите программу двойным щелчком мышки. Все Скалоеды на месте (Рис. 13)! 299
Рис. 11 Рис. 12 Для надёжности я скопировал файлы проекта в папку skaloedy, потому что предыдущая версия библиотеки Auto Py to Exe не находила папку, в названии которой были пробелы. Сейчас всё работает, поэтому вы можете компилировать программу из родной папки Охота на Скалоедов. Вы можете добавить иконку к выполняемому файлу. Для этого раскройте список Иконка, нажмите кнопку Расположение и выберите на диске файл с расширением .ico (Рис. 14). 300
Рис. 13 Рис. 14 301
Иконка появится в файловом менеджере и в строке состояния – когда программа запущена (Рис. 15). Рис. 15 Но в окне программы иконка не изменится (Рис. 16). Рис. 16 302
Литература Cерия Программирование для детей [Питон] Рубанцев Валерий Развивающее программирование Практикум по решению задач на языке Питон 3. Базовый уровень. - 500 с. В книге подробно рассматривается решение более 100 задач: математических, словесных, комбинаторных, вероятностных, игровых. Лучшие упражнения для отработки навыков программирования на языке Питон. [Си-шарп] Рубанцев Валерий Компьютер, наука и жизнь Занимательные математические задачи: От древности до современности. 500 с. Около 150 проектов на языке Сишарп, показывающих, как можно решать разнообразные занимательные математические задачи на компьютере. 303
[Геогебра] Рубанцев Валерий Высокие технологии в школе Занимательные задачи с Геогеброй. - 160 с. Решение занимательных математических задач в среде Геогебра. Несколько десятков интересных задачек по всем школьным разделам алгебры. [С++] Рубанцев Валерий Развивающее программирование Решение задач на языке С++. - 200 с. Несколько десятков проектов на языке С++, показывающих, как можно решать задачи на компьютере. 304
Cерия Программирование на Питоне [Python 01] Рубанцев Валерий Программирование для всех Компьютерная графика на Питоне 530 с. В книге подробно описываются возможности графической библиотеки Processing для простой и эффективной разработки графических приложений на языке Питон. Все функции проиллюстрированы многочисленными примерами. 305
[Python 02] Рубанцев Валерий Программирование для всех Простые компьютерные игры на Питоне 220 с. В книге подробно описывается разработка 10 простых компьютерных игр. Среди них есть и очень известные игры – Игра Баше, Угадай число, Закраска – и не очень, и совсем новые – Пузыри, Блиц-Клик, Охота на Скалоеда и Скалоедов, две программы про Незнайку и великолепная головоломка Ножки вверх! Цель книги: научиться писать простые компьютерные игры на языке Питон с использованием графической библиотеки Processing. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на Питоне. 306
[Python 03] Рубанцев Валерий Программирование для всех Компьютерные игры и головоломки на Питоне 310 с. Продолжаем программировать компьютерные игры и головоломки: Солитер, Прыгающие лягушки, Крестики-нолики, математическая Игра Ярбро, головоломки Eliminator и Местор Научимся использовать в программах анимацию и метод минимакса, разрабатывать графический интерфейс, писать "решалки" для головоломок, придумывать свои уровни и программы. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на Питоне. 307
[Python 04] Рубанцев Валерий Программирование для всех Программирование игр для детей. На Питоне 240 с. Учимся программировать интересные и красочные компьютерные игры для детей! Самая известная из них – Фрудоку. Это упрощённый вариант судоку 6 х 6 клеточек с фруктами вместо цифр. Менее известны, но не менее увлекательны игры ABCD, Arukone, Bit-Shift, Grand Tour, Кубиковая считалка и Римская задача. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на Питоне. 308
[Python 05] Рубанцев Валерий Программирование для всех Программирование детских игр на Питоне 230 с. Новые компьютерные игры и головоломки для детей! Фокус, Собиратель монет, Собиратель букв, Анаграммы, Коровы, Сикаку, Тетрамино, Тетраминки. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на Питоне. 309
Cерия Учись программировать с Котлином [Kotlin01] Рубанцев Валерий Привет, Котлин! Программирование на языке Котлин для детей 350 с. Это самоучитель по программированию на языке Котлин для детей, начиная с 10-летнего возраста. Для книги отобрано ровно столько учебного материала, сколько его необходимо и достаточно, чтобы начать писать программы на языке Котлин самостоятельно. 310
[Kotlin02] Рубанцев Валерий Практикум по решение задач на языке Kotlin для детей 200 с. В этой книге собраны занимательные математические задачи, которые можно решать на языке Котлин. Решение всех задач подробно описано. В них используются все теоретические знания, которые читатели получили в первой книге. Решение задач – отличная тренировка при изучении нового языка программирования! 311
[Kotlin03] Рубанцев Валерий Практикум по решению задач на языке Kotlin для школьников 250 с. Решаем занимательные математические задачи всех времён и народов на языке Котлин в среде IntelliJ IDEA. При решении задач используются все базовые понятия языка Котлин. Совершенно необходимая книга для закрепления знаний и укрепления навыков программирования на языке Котлин. 312
[Kotlin05] Рубанцев Валерий Решение задач на языке Kotlin 370 с. Решение математических задач на языке Котлин в среде IntelliJ IDEA. При решении задач используются все базовые понятия языка Котлин. Совершенно необходимая книга для закрепления знаний и укрепления навыков программирования на языке Котлин. 313
[Kotlin06] Рубанцев Валерий Основы компьютерной графики на языке Котлин 500 с. Это самоучитель по компьютерной графике для начинающих. В книге подробно описаны возможности графической библиотеки core.js – основы языка Процессинг - для простой и эффективной разработки графических приложений на языке Котлин. Эта книга поможет вам быстро изучить основы компьютерной графики, и вы сможете самостоятельно рисовать красивые узоры, решать задачи, писать игры и разрабатывать компьютерные модели по биологии, физике, химии. В ней вы найдёте исчерпывающий теоретический материал для самостоятельного и разностороннего творчества. 314
Cерия Учись программировать с Процессингом [Processing 03] Рубанцев Валерий Учись программировать с Процессингом 540 с. Это самоучитель по программированию на языке Ява для начинающих. Для книги отобрано ровно столько учебного материала, сколько его необходимо и достаточно, чтобы писать программы школьного уровня. 315
[Processing 04] Рубанцев Валерий Учись программировать с Процессингом Основы компьютерной графики 490 с. Это самоучитель по компьютерной графике для начинающих. Эта книга поможет вам быстро изучить основы компьютерной графики, и вы сможете самостоятельно рисовать красивые узоры, решать задачи, писать игры и разрабатывать компьютерные модели по биологии, физике, химии. В ней вы найдёте исчерпывающий теоретический материал для самостоятельного и разностороннего творчества. 316
Cерия Программирование на ЯваСкрипте [JavaScript 01] Рубанцев Валерий Программирование на ЯваСкрипте Занимательная графика на ЯваСкрипте 430 с. В книге подробно описываются возможности графической библиотеки p5.js для простой и эффективной разработки графических приложений на языке ЯваСкрипт. Все функции проиллюстрированы многочисленными примерами. 317
[JavaScript 02] Рубанцев Валерий Тотальный тренинг по ЯваСкрипту Массивы и функциональное программирование 260 с. Книга о массивах, методах и функциональном программировании на ЯваСкрипте. Все методы проиллюстрированы демонстрационными проектами. На занимательных примерах показано, как решать практические задачи на ЯваСкрипте в функциональном стиле. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 318
[JavaScript 03] Рубанцев Валерий Тотальный тренинг по ЯваСкрипту Простые компьютерные игры. 220 с. В книге подробно описывается разработка 10 простых компьютерных игр. Среди них есть и очень известные игры – Игра Баше, Угадай число, Закраска – и не очень, и совсем новые – Пузыри, Блиц-Клик, Охота на Скалоеда и Скалоедов, две программы про Незнайку и великолепная головоломка Ножки вверх! Цель книги: научиться писать простые компьютерные игры на языке ЯваСкрипт с использованием графической библиотеки p5.js. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 319
[JavaScript 04] Рубанцев Валерий Тотальный тренинг по ЯваСкрипту Простые компьютерные игры. 310 с. Продолжаем программировать компьютерные игры и головоломки: Солитер, Прыгающие лягушки, Крестики-нолики, математическая Игра Ярбро, головоломки Eliminator и Местор Научимся использовать в программах анимацию и метод минимакса, разрабатывать графический интерфейс, писать "решалки" для головоломок, придумывать свои уровни и программы. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 320
[JavaScript 05] Рубанцев Валерий Программирование на ЯваСкрипте Программирование игр для детей 240 с. Учимся программировать интересные и красочные компьютерные игры для детей! Самая известная из них – Фрудоку. Это упрощённый вариант судоку 6 х 6 клеточек с фруктами вместо цифр. Менее известны, но не менее увлекательны игры ABCD, Arukone, Bit-Shift, Grand Tour, Кубиковая считалка и Римская задача. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 321
[JavaScript 06] Рубанцев Валерий Программирование на ЯваСкрипте Программирование детских игр 230 с. Новые компьютерные игры и головоломки для детей! Фокус, Собиратель монет, Собиратель букв, Анаграммы, Коровы, Сикаку, Тетрамино, Тетраминки. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 322
[JavaScript 07] Рубанцев Валерий Программирование на ЯваСкрипте Классические компьютерные игры 250 с. Совершенствуем и развиваем навыки программирования компьютерных игр на ЯваСкрипте. На этот раз на очень занимательных примерах: Тетрис, Змейка, Сапёр, Bubble Shooter! А также: мы разовьём плодотворные классические идеи и напишем клоны: игры Рекстрис, По грибы и вторую Змейку. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 323
[JavaScript 08] Рубанцев Валерий Программирование на ЯваСкрипте Компьютерные игры для начальной школы 220 с. Новые компьютерные игры и головоломки для детей! Перекинь мостик, Сотенный билет, Найди треугольник, Психологическая считалка, Фруктосчёт, Раскрась карту. Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 324
[JavaScript 09] Рубанцев Валерий Программирование на ЯваСкрипте Новые компьютерные игры 290 с. В этой книжке только одна головоломная программа, а все остальные – это весёлые, заводные игры: • Головоломка Инь-Ян • Игра Катапульта • Игра Сквош • Игра Сквош на двоих • Игра Теннис • Игра Танчики • Игра Платформер Для учащихся, учителей информатики, любителей программирования и начинающих программистов, имеющих небольшой опыт в программировании на ЯваСкрипте. 325