Метка: игры

Дурак по сети на Python: часть 1

Дурак - иллюстрация

Давайте попробуем разработать сетевую игру на Python, чтобы можно было играть по локальной сети. Думаю, начать надо именно с логики игры, а потом добавить уже сетевое взаимодействие. Я выбрал в качестве игры – карточную игру «Дурак», чтобы, во-первых, не создавать очередные надоевшие крестики-нолики, а, во-вторых, чтобы добавить элемент синхронных взаимодействий вместо исключительно пошаговых (активный игрок может подкидывать карты, пока другой отбивается).

Начнем с определения некоторых констант в файле durak.py:

import random

# масти
SPADES = '♠'
HEARTS = '♥'
DIAMS = '♦'
CLUBS = '♣'

# достоинтсва карт
NOMINALS = ['6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']

# поиск индекса по достоинству
NAME_TO_VALUE = {n: i for i, n in enumerate(NOMINALS)}

# карт в руке при раздаче
CARDS_IN_HAND_MAX = 6

N_PLAYERS = 2

# эталонная колода (каждая масть по каждому номиналу) - 36 карт
DECK = [(nom, suit) for nom in NOMINALS for suit in [SPADES, HEARTS, DIAMS, CLUBS]]

Посмотрим нашу эталонную колоду:

print(DECK)
[('6', '♠'), ('6', '♥'), ('6', '♦'), ('6', '♣'), ('7', '♠'), ('7', '♥'), ('7', '♦'), ('7', '♣'), ('8', '♠'), ('8', '♥'), ('8', '♦'), ('8', '♣'), ('9', '♠'), ('9', '♥'), ('9', '♦'), ('9', '♣'), ('10', '♠'), ('10', '♥'), ('10', '♦'), ('10', '♣'), ('J', '♠'), ('J', '♥'), ('J', '♦'), ('J', '♣'), ('Q', '♠'), ('Q', '♥'), ('Q', '♦'), ('Q', '♣'), ('K', '♠'), ('K', '♥'), ('K', '♦'), ('K', '♣'), ('A', '♠'), ('A', '♥'), ('A', '♦'), ('A', '♣')]

Мы не будем ее менять, просто при создании игры будем копировать этот список в колоду текущей игры. Каждая карта в колоде или в руке игрока – это кортеж из строки-достоинства и строки-масти.

Создадим класс игрока. Его свойства: список карт на руке и индекс игрока в массиве игроков (0 – для первого и 1 – для второго). Индекс нужен, чтобы определять текущего ходящего игрока. Игрок может брать недостающее число карт из колоды или просто добавлять себе в руку список карт, когда вынужден взять неотбитый им стол.

class Player:
    def __init__(self, index, cards):
        self.index = index
        self.cards = list(map(tuple, cards))  # убедимся, что будет список кортежей

    def take_cards_from_deck(self, deck: list):
        """
        Взять недостающее количество карт из колоды
        Колода уменьшится
        :param deck: список карт колоды 
        """
        lack = max(0, CARDS_IN_HAND_MAX - len(self.cards))
        n = min(len(deck), lack)
        self.add_cards(deck[:n])
        del deck[:n]
        return self

    def sort_hand(self):
        """
        Сортирует карты по достоинству и масти
        """
        self.cards.sort(key=lambda c: (NAME_TO_VALUE[c[0]], c[1]))
        return self

    def add_cards(self, cards):
        self.cards += list(cards)
        self.sort_hand()
        return self

    # всякие вспомогательные функции:
    
    def __repr__(self):
        return f"Player{self.cards!r}"

    def take_card(self, card):
        self.cards.remove(card)

    @property
    def n_cards(self):
        return len(self.cards)

    def __getitem__(self, item):
        return self.cards[item]

Приступим же к классу Durak – основному классу игровой логики:

class Durak:
    def __init__(self, rng: random.Random = None):
        self.rng = rng or random.Random()  # генератор случайных чисел

        self.deck = list(DECK)  # копируем колоду
        self.rng.shuffle(self.deck)  # мешаем карты в копии колоды

        # создаем игроков и раздаем им по 6 карт из перемешанной колоды
        self.players = [Player(i, []).take_cards_from_deck(self.deck)
                        for i in range(N_PLAYERS)]

        # козырь - карта сверху
        self.trump = self.deck[0][1]
        # кладем козырь под низ вращая список по кругу на 1 назад
        self.deck = rotate(self.deck, -1)

        # игровое поле: ключ - атакующая карта, значения - защищающаяся или None
        self.field = {}  

        self.attacker_index = 0  # индекс атакующего игрока
        self.winner = None  # индекс победителя

Генератор случайных чисел можно указать из-вне, это нужно для отладки, чтобы каждый раз воспроизводилась одна и та же раздача, если ГСЧ не задать, то он будет создан на месте и игра будет случайна.

При инициализации, как и в реальной игре, мы берем колоду, перемешиваем ее, раздаем по 6 карт игрокам, берем козырь сверху, запоминаем его и кладем под низ. Кстати, вот функция rotate , которая сдвигает циклично список на n позиций влево (n < 0) или вправо (n > 0):

def rotate(l, n):
    return l[n:] + l[:n]

Я не стал выбирать первого игрока по наличию младшего козыря, потому что обычно это нужно только в первый кон, а дальше ходят под дурака. И то, как договорятся. Просто назначаем первым игрока с индексом 0.

Игровое поле здесь – это словарь, где ключ – атакующая карта, а значение – отбивающая карта (если игрок отбился) или None (если он пока еще не отбился от конкретно этой атакующей карты).

Для получения списков карт на поле вводим такие свойства:

    @property
    def attacking_cards(self):
        """
        Список атакующих карт
        """
        return list(filter(bool, self.field.keys()))

    @property
    def defending_cards(self):
        """
        Список отбивающих карт (фильртруем None)
        """
        return list(filter(bool, self.field.values()))

    @property
    def any_unbeaten_card(self):
        """
        Есть ли неотбитые карты
        """
        return any(c is None for c in self.defending_cards)

А эти свойства помогают определить, кто текущий игрок, а кто его соперник:

    @property
    def current_player(self):
        return self.players[self.attacker_index]

    @property
    def opponent_player(self):
        return self.players[(self.attacker_index + 1) % N_PLAYERS]

Рассмотрим теперь методы атаки и защиты:

    def attack(self, card):
        assert not self.winner  # игра не должна быть окончена!

        # можно ли добавить эту карту на поле? (по масти или достоинству)
        if not self.can_add_to_field(card):
            return False
        cur, opp = self.current_player, self.opponent_player
        cur.take_card(card)  # уберем карту из руки атакующего
        self.field[card] = None  # карта добавлена на поле, пока не бита
        return True

Ходить можно с любой карты, если игровое поле пусто. Но подбрасывать можно только, если карта соответствует по достоинству или масти – этой проверкой заведует метод can_add_to_field:

    def can_add_to_field(self, card):
        if not self.field:  
            # на пустое поле можно ходить любой картой
            return True

        # среди всех атакующих и отбивающих карт ищем совпадения по достоинствам
        for attack_card, defend_card in self.field.items():
            if self.card_match(attack_card, card) or self.card_match(defend_card, card):
                return True
        return False

    def card_match(self, card1, card2):
        if card1 is None or card2 is None:
            return False
        n1, _ = card1
        n2, _ = card2
        return n1 == n2   # равны ли достоинства карт?

Переходим к защите:

    def defend(self, attacking_card, defending_card):
        """
        Защита
        :param attacking_card: какую карту отбиваем 
        :param defending_card: какой картой защищаемя
        :return: bool - успех или нет
        """
        assert not self.winner  # игра не должна быть окончена!

        if self.field[attacking_card] is not None:
            # если эта карта уже отбита - уходим
            return False
        if self.can_beat(attacking_card, defending_card):
            # еслии можем побить, то кладем ее на поле 
            self.field[attacking_card] = defending_card
            # и изымаем из руки защищающегося
            self.opponent_player.take_card(defending_card)
            return True
        return False

Метод, который определяет бьет ли первая карта вторую выглядит так. Обратите внимание, что предварительно надо преобразовать название достоинства карты в числовую характеристику – индекс в массиве достоинств по возрастанию (индекс шестерки – 0, семерки – 1, а у туза – 8).

    def can_beat(self, card1, card2):
        """
        Бьет ли card1 карту card2
        """
        nom1, suit1 = card1
        nom2, suit2 = card2

        # преобразуем строку-достоинство в численные характеристики
        nom1 = NAME_TO_VALUE[nom1]
        nom2 = NAME_TO_VALUE[nom2]

        if suit2 == self.trump:
            # если козырь, то бьет любой не козырь или козырь младше
            return suit1 != self.trump or nom2 > nom1
        elif suit1 == suit2:
            # иначе должны совпадать масти и номинал второй карты старше первой
            return nom2 > nom1
        else:
            return False

Метод завершающий ход finish_turn возвращает результат хода. В зависимости от ситуации на столе могут быть такие варианты. 1) Отбиты все карты. Тогда ход переходит к игроку, который защищался. Оба добирают из колоды недостающее число карт. 2) Не отбил что-то, тогда право хода не меняется, атакующий добирает карты, а защищающийся собирает со стола все карты к себе в руку. 3) Игра завешена, так как карт в колоде больше нет, и один из соперников тоже избавился от всех карт. Тот, кто остался с картами на руках в конце игры – ДУРАК 😉

    # константы результатов хода
    NORMAL = 'normal'
    TOOK_CARDS = 'took_cards'
    GAME_OVER = 'game_over'
    
    @property
    def attack_succeed(self):
        return any(def_card is None for def_card in self.field.values())

    def finish_turn(self):
        assert not self.winner

        took_cards = False
        if self.attack_succeed:
            # забрать все карты, если игрок не отбился в момент завершения хода
            self._take_all_field()
            took_cards = True
        else:
            # бито! очищаем поле (отдельного списка для бито нет, просто удаляем карты)
            self.field = {}

        # очередность взятия карт из колоды определяется индексом атакующего (можно сдвигать на 1, или нет)
        for p in rotate(self.players, self.attacker_index): 
            p.take_cards_from_deck(self.deck)

        # колода опустела?
        if not self.deck:
            for p in self.players:
                if not p.cards:  # если у кого-то кончились карты, он победил!
                    self.winner = p.index
                    return self.GAME_OVER

        if took_cards:
            return self.TOOK_CARDS
        else:
            # переход хода, если не отбился
            self.attacker_index = self.opponent_player.index
            return self.NORMAL

    def _take_all_field(self):
        """
        Соперник берет все катры со стола себе.  
        """
        cards = self.attacking_cards + self.defending_cards
        self.opponent_player.add_cards(cards)
        self.field = {}

Вот и вся логика. Один атакует attack, другой отбивается defend. В любой момент может быть вызван finish_turn, чтобы завершить ход. Смотрим на результат хода, и если игра окончена, то в поле winner будет индекс игрока-победителя.

Теперь реализуем локальную игру в консоли, как будто бы оба играют за одним компьютером. Функции по отрисовке состояния игры в консоль собраны в файле render.py. Не буду их разбирать подробно, так как они не так важны, а в будущем мы прикрутим графическую оболочку и консольные функции потеряют актуальность.

Сам же игровой интерфейс реализован в файле local_game.py:

from render import ConsoleRenderer
from durak import Durak
import random

def local_game():
    # rng = random.Random(42)  # игра с фиксированным рандомом (для отладки)
    rng = random.Random()  # случайная игра

    g = Durak(rng=rng)
    renderer = ConsoleRenderer()

    renderer.help()

    while not g.winner:
        renderer.render_game(g, my_index=0)

        renderer.sep()
        choice = input('Ваш выбор: ')
        # разбиваем на части: команда - пробел - номер карты
        parts = choice.lower().split(' ')
        if not parts:
            break

        command = parts[0]

        try:
            if command == 'f':
                r = g.finish_turn()
                print(f'Ход окончен: {r}')
            elif command == 'a':
                index = int(parts[1]) - 1
                card = g.current_player[index]
                if not g.attack(card):
                    print('Вы не можете ходить с этой карты!')
            elif command == 'd':
                index = int(parts[1]) - 1
                new_card = g.opponent_player[index]

                # варианты защиты выбранной картой
                variants = g.defend_variants(new_card)

                if len(variants) == 1:
                    def_index = variants[0]
                else:
                    def_index = int(input(f'Какую позицию отбить {new_card}? ')) - 1

                old_card = list(g.field.keys())[def_index]
                if not g.defend(old_card, new_card):
                    print('Не можете так отбиться')
            elif command == 'q':
                print('QUIT!')
                break
        except IndexError:
            print('Неправильный выбор карты')
        except ValueError:
            print('Введите число через пробел после команды')

        if g.winner:
            print(f'Игра окончена, победитель игрок: #{g.winner + 1}')
            break

if __name__ == '__main__':
    local_game()

Команды (a #номер карты – атака, d #номер карты – защита, просто f – завершить ход, q – выход). Номера карт задаются с 1 (там будет нумерация возле карт).

Локальную версию игры можно пощупать в браузере через replit.

Пример игры:

Козырь – [♦], 24 карт в колоде осталось.
1: 1. [7♥], 2. [10♠], 3. [J♥], 4. [K♥], 5. [A♥], 6. [A♦] <-- ходит (это я) 
2: 1. [6♠], 2. [7♠], 3. [8♣], 4. [8♦], 5. [9♦], 6. [K♣]
--------------------------------------------------------------------------------
Ваш выбор: a 1
--------------------------------------------------------------------------------
Козырь – [♦], 24 карт в колоде осталось.
1: 1. [10♠], 2. [J♥], 3. [K♥], 4. [A♥], 5. [A♦] <-- ходит (это я) 
2: 1. [6♠], 2. [7♠], 3. [8♣], 4. [8♦], 5. [9♦], 6. [K♣]

1. Ходит: [7♥] - отбиться: [  ]
--------------------------------------------------------------------------------
Ваш выбор: d 5
--------------------------------------------------------------------------------
Козырь – [♦], 24 карт в колоде осталось.
1: 1. [10♠], 2. [J♥], 3. [K♥], 4. [A♥], 5. [A♦] <-- ходит (это я) 
2: 1. [6♠], 2. [7♠], 3. [8♣], 4. [8♦], 5. [K♣]

1. Ходит: [7♥] - отбиться: [9♦]
--------------------------------------------------------------------------------
Ваш выбор: f
Ход окончен: normal
--------------------------------------------------------------------------------
Козырь – [♦], 22 карт в колоде осталось.
1: 1. [10♠], 2. [J♥], 3. [K♥], 4. [K♦], 5. [A♥], 6. [A♦] (это я) 
2: 1. [6♠], 2. [7♠], 3. [7♦], 4. [8♣], 5. [8♦], 6. [K♣] <-- ходит
--------------------------------------------------------------------------------
Ваш выбор: 

Весь код будет доступен в репозитории. О сетевой подсистеме игры я расскажу в следующих частях очень скоро!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Игра понг ASCII на Python

В продолжение последней темы написал сегодня с утра игру «Понг» для терминала. Обошелся только встроенными модулями. Для графики и ввода использовал модуль curses (обертка над ncurses). Исходный код доступен здесь. Благодаря современным чудо-технологиям в игру можно поиграть прямо в браузере, хоть она и работает не очень стабильно (зависит от вашего интернет соединения). Управление: W — вверх, S — вниз (только английская раскладка).

Скриншот текстовой игры ПОНГ

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Лаптичка возвращается

Я не из тех, кто любит навсегда оставлять прошлое. Я до сих пор считаю, что Лапта достаточно хороша (особенно для дебютного проекта), чтобы уйти в забытие. Поэтому за эти выходные я пересмотрел код, почистил его и отправил обновление.
— Исправление физики
— Повыше динамичность
— Без рекламы
— Без шаринга
— Без промоэффектов
— Частичная поддержка длинных айФонов
Посмотрю на статистику, если еще есть игруны, то будут еще обновления.M5CLez0V8q8