diff --git a/skorohodovsa/task_2/source/view/__init__.py b/skorohodovsa/task_2/source/view/__init__.py new file mode 100644 index 0000000..2865973 --- /dev/null +++ b/skorohodovsa/task_2/source/view/__init__.py @@ -0,0 +1,13 @@ +from source.view.observer import Observer, ConsoleView, Event +from source.view.command import Player, Command, MoveCommand, CommandHistory, DIRECTIONS + +__all__ = [ + "Observer", + "ConsoleView", + "Event", + "Player", + "Command", + "MoveCommand", + "CommandHistory", + "DIRECTIONS", +] \ No newline at end of file diff --git a/skorohodovsa/task_2/source/view/command.py b/skorohodovsa/task_2/source/view/command.py new file mode 100644 index 0000000..9ae96e5 --- /dev/null +++ b/skorohodovsa/task_2/source/view/command.py @@ -0,0 +1,153 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from source.models.base import Maze, Cell + + +# ---------------------------------------------------------------------------- # +# Игрок # +# ---------------------------------------------------------------------------- # + +class Player: + """Хранит текущее положение игрока в лабиринте. + + Attributes: + cell: Текущая клетка игрока. + """ + + def __init__(self, cell: Cell) -> None: + """Инициализирует игрока на заданной клетке. + + Args: + cell: Начальная клетка игрока. + """ + self.cell = cell + + def __repr__(self) -> str: + return f"Player(x={self.cell.x}, y={self.cell.y})" + + +# ---------------------------------------------------------------------------- # +# Интерфейс команды # +# ---------------------------------------------------------------------------- # + +class Command(ABC): + """Интерфейс команды с поддержкой отмены.""" + + @abstractmethod + def execute(self) -> bool: + """Выполняет команду. + + Returns: + True если команда выполнена успешно, False иначе. + """ + + @abstractmethod + def undo(self) -> None: + """Отменяет команду, восстанавливая предыдущее состояние.""" + + +# ---------------------------------------------------------------------------- # +# Команда перемещения # +# ---------------------------------------------------------------------------- # + +DIRECTIONS = { + "w": (0, -1), + "s": (0, 1), + "a": (-1, 0), + "d": (1, 0), +} + + +class MoveCommand(Command): + """Перемещает игрока в заданном направлении. + + Сохраняет предыдущую клетку для возможности отмены хода. + """ + + def __init__(self, player: Player, direction: str, maze: Maze) -> None: + """Инициализирует команду перемещения. + + Args: + player: Объект игрока. + direction: Направление ('w', 'a', 's', 'd'). + maze: Объект лабиринта для проверки проходимости. + + Raises: + ValueError: Если направление не распознано. + """ + if direction not in DIRECTIONS: + raise ValueError(f"Неизвестное направление '{direction}'. Используй: w/a/s/d") + + self._player = player + self._direction = direction + self._maze = maze + self._prev_cell: Optional[Cell] = None + + def execute(self) -> bool: + """Перемещает игрока если целевая клетка проходима. + + Returns: + True если перемещение выполнено, False если клетка непроходима. + """ + dx, dy = DIRECTIONS[self._direction] + target = self._maze.get_cell( + self._player.cell.x + dx, + self._player.cell.y + dy, + ) + + if target is None or not target.is_possible(): + return False + + self._prev_cell = self._player.cell + self._player.cell = target + return True + + def undo(self) -> None: + """Возвращает игрока на предыдущую клетку.""" + if self._prev_cell is not None: + self._player.cell = self._prev_cell + + +# ---------------------------------------------------------------------------- # +# История команд # +# ---------------------------------------------------------------------------- # + +class CommandHistory: + """Хранит историю выполненных команд и позволяет отменять их. + + Example: + history = CommandHistory() + cmd = MoveCommand(player, 'w', maze) + if cmd.execute(): + history.push(cmd) + + history.undo() # отменяет последний успешный ход + """ + + def __init__(self) -> None: + self._history: list[Command] = [] + + def push(self, command: Command) -> None: + """Добавляет выполненную команду в историю. + + Args: + command: Успешно выполненная команда. + """ + self._history.append(command) + + def undo(self) -> bool: + """Отменяет последнюю команду из истории. + + Returns: + True если отмена выполнена, False если история пуста. + """ + if not self._history: + print("Нечего отменять.") + return False + self._history.pop().undo() + return True + + def clear(self) -> None: + """Очищает историю команд.""" + self._history.clear() \ No newline at end of file diff --git a/skorohodovsa/task_2/source/view/observer.py b/skorohodovsa/task_2/source/view/observer.py new file mode 100644 index 0000000..5c51210 --- /dev/null +++ b/skorohodovsa/task_2/source/view/observer.py @@ -0,0 +1,111 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +from source.models.base import Maze, Cell +from source.settings import cell_mapping + + +# ---------------------------------------------------------------------------- # +# События # +# ---------------------------------------------------------------------------- # + +@dataclass +class Event: + """Событие, передаваемое наблюдателям. + + Attributes: + type: Тип события ('maze_loaded', 'path_found', 'move', 'no_path'). + payload: Дополнительные данные события. + """ + type: str + payload: dict = None + + +# ---------------------------------------------------------------------------- # +# Интерфейс наблюдателя # +# ---------------------------------------------------------------------------- # + +class Observer(ABC): + """Интерфейс наблюдателя за событиями лабиринта.""" + + @abstractmethod + def update(self, event: Event) -> None: + """Обрабатывает входящее событие. + + Args: + event: Объект события с типом и данными. + """ + + +# --------------------------------------------------------------------------- +# Консольный наблюдатель +# --------------------------------------------------------------------------- + +class ConsoleView(Observer): + """Отображает состояние лабиринта и события в консоли.""" + + # Символ игрока на карте + PLAYER_SYMBOL = "P" + PATH_SYMBOL = "·" + + def update(self, event: Event) -> None: + """Реагирует на события и выводит информацию в консоль. + + Args: + event: Объект события. + """ + match event.type: + case "maze_loaded": + print("Лабиринт загружен.") + self.render(event.payload["maze"]) + case "path_found": + print(f"Путь найден! Длина: {event.payload['length']} шагов.") + self.render( + event.payload["maze"], + path=event.payload["path"], + ) + case "no_path": + print("Путь не найден.") + case "move": + print(f"Ход: {event.payload['direction']}") + self.render( + event.payload["maze"], + player=event.payload["player_cell"], + path=event.payload.get("path"), + ) + case _: + print(f"[событие] {event.type}") + + def render( + self, + maze: Maze, + player: Optional[Cell] = None, + path: Optional[list[Cell]] = None, + ) -> None: + """Рисует лабиринт в консоли. + + Путь отмечается символом '·', позиция игрока — 'P'. + + Args: + maze: Объект лабиринта. + player: Текущая клетка игрока (опционально). + path: Список клеток найденного пути (опционально). + """ + path_set = set(path) if path else set() + rows, cols = maze.shape + + print("+" + "─" * cols + "+") + for y in range(rows): + row_str = "|" + for x in range(cols): + cell = maze[y, x] + if player and cell is player: + row_str += self.PLAYER_SYMBOL + elif cell in path_set: + row_str += self.PATH_SYMBOL + else: + row_str += str(cell) + row_str += "|" + print(row_str) + print("+" + "─" * cols + "+") \ No newline at end of file diff --git a/skorohodovsa/task_2/test/play.py b/skorohodovsa/task_2/test/play.py new file mode 100644 index 0000000..d6cc572 --- /dev/null +++ b/skorohodovsa/task_2/test/play.py @@ -0,0 +1,57 @@ +from source.build.builder import TextFileBuilder +from source.models.base import Maze +from source.view.observer import ConsoleView, Event +from source.view.command import Player, MoveCommand, CommandHistory + + +maze: Maze = TextFileBuilder().build_from_file("source/templates/10x10_path_v1.txt") + +rows, cols = maze.shape + +start = None +for y in range(rows): + for x in range(cols): + if maze[y, x].is_start: + start = maze[y, x] + break + +if start is None: + print("Стартовая клетка не найдена!") + exit() + +player = Player(start) +history = CommandHistory() +view = ConsoleView() + +view.update(Event("maze_loaded", {"maze": maze})) +print("Управление: w/a/s/d — движение, z — отмена, q — выход\n") + +while True: + key = input(">>> ").strip().lower() + + if key == 'q': + print("Выход.") + break + + elif key == 'z': + if history.undo(): + print("Ход отменён.") + view.render(maze, player=player.cell) + + elif key in ('w', 'a', 's', 'd'): + cmd = MoveCommand(player, key, maze) + if cmd.execute(): + history.push(cmd) + view.update(Event("move", { + "maze": maze, + "player_cell": player.cell, + "direction": key, + })) + if player.cell.is_exit: + print("Выход найден! Победа!") + break + else: + print("Туда нельзя — стена или граница.") + + else: + print("Неизвестная команда. Используй: w/a/s/d, z, q") \ No newline at end of file