]> Untitled Git - axy/ft/pacman.git/commitdiff
pacman v1
authorThomas Rimelen <trimelen@k1r1p7.42mulhouse.fr>
Wed, 26 Aug 2026 15:53:42 +0000 (17:53 +0200)
committerThomas Rimelen <trimelen@k1r1p7.42mulhouse.fr>
Wed, 26 Aug 2026 15:53:42 +0000 (17:53 +0200)
61 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
asset/enemy/ghostA_D.png [new file with mode: 0644]
asset/enemy/ghostA_L.png [new file with mode: 0644]
asset/enemy/ghostA_R.png [new file with mode: 0644]
asset/enemy/ghostA_U.png [new file with mode: 0644]
asset/enemy/ghostB_D.png [new file with mode: 0644]
asset/enemy/ghostB_L.png [new file with mode: 0644]
asset/enemy/ghostB_R.png [new file with mode: 0644]
asset/enemy/ghostB_U.png [new file with mode: 0644]
asset/enemy/ghostC_D.png [new file with mode: 0644]
asset/enemy/ghostC_L.png [new file with mode: 0644]
asset/enemy/ghostC_R.png [new file with mode: 0644]
asset/enemy/ghostC_U.png [new file with mode: 0644]
asset/enemy/ghostD_D.png [new file with mode: 0644]
asset/enemy/ghostD_L.png [new file with mode: 0644]
asset/enemy/ghostD_R.png [new file with mode: 0644]
asset/enemy/ghostD_U.png [new file with mode: 0644]
asset/enemy/ghost_blue.png [new file with mode: 0644]
asset/player/player_DA.png [new file with mode: 0644]
asset/player/player_DB.png [new file with mode: 0644]
asset/player/player_DC.png [new file with mode: 0644]
asset/player/player_LA.png [new file with mode: 0644]
asset/player/player_LB.png [new file with mode: 0644]
asset/player/player_LC.png [new file with mode: 0644]
asset/player/player_RA.png [new file with mode: 0644]
asset/player/player_RB.png [new file with mode: 0644]
asset/player/player_RC.png [new file with mode: 0644]
asset/player/player_UA.png [new file with mode: 0644]
asset/player/player_UB.png [new file with mode: 0644]
asset/player/player_UC.png [new file with mode: 0644]
asset/walls/0.png [new file with mode: 0644]
asset/walls/1.png [new file with mode: 0644]
asset/walls/10.png [new file with mode: 0644]
asset/walls/11.png [new file with mode: 0644]
asset/walls/12.png [new file with mode: 0644]
asset/walls/13.png [new file with mode: 0644]
asset/walls/14.png [new file with mode: 0644]
asset/walls/15.png [new file with mode: 0644]
asset/walls/2.png [new file with mode: 0644]
asset/walls/3.png [new file with mode: 0644]
asset/walls/4.png [new file with mode: 0644]
asset/walls/5.png [new file with mode: 0644]
asset/walls/6.png [new file with mode: 0644]
asset/walls/7.png [new file with mode: 0644]
asset/walls/8.png [new file with mode: 0644]
asset/walls/9.png [new file with mode: 0644]
config.json [new file with mode: 0644]
mazegenerator-00001-py3-none-any.whl [new file with mode: 0644]
score.json [new file with mode: 0644]
src/__init__.py [new file with mode: 0644]
src/__main__.py [new file with mode: 0644]
src/collectibles.py [new file with mode: 0644]
src/config.py [new file with mode: 0644]
src/error.py [new file with mode: 0644]
src/game.py [new file with mode: 0644]
src/input.py [new file with mode: 0644]
src/player.py [new file with mode: 0644]
src/render.py [new file with mode: 0644]
src/score.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..734025d
--- /dev/null
@@ -0,0 +1,8 @@
+**/add.mypy_cache
+orga.txt
+Project Management.xlsx
+cell.txt
+.venv
+mazegeneratordemerde.txt
+a.txt
+**/__pycache__
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..f541daa
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+*This project has been created as part of the 42 curriculum by agilliar and trimelen*
diff --git a/asset/enemy/ghostA_D.png b/asset/enemy/ghostA_D.png
new file mode 100644 (file)
index 0000000..177aaf4
Binary files /dev/null and b/asset/enemy/ghostA_D.png differ
diff --git a/asset/enemy/ghostA_L.png b/asset/enemy/ghostA_L.png
new file mode 100644 (file)
index 0000000..919077c
Binary files /dev/null and b/asset/enemy/ghostA_L.png differ
diff --git a/asset/enemy/ghostA_R.png b/asset/enemy/ghostA_R.png
new file mode 100644 (file)
index 0000000..609f29c
Binary files /dev/null and b/asset/enemy/ghostA_R.png differ
diff --git a/asset/enemy/ghostA_U.png b/asset/enemy/ghostA_U.png
new file mode 100644 (file)
index 0000000..e4febc5
Binary files /dev/null and b/asset/enemy/ghostA_U.png differ
diff --git a/asset/enemy/ghostB_D.png b/asset/enemy/ghostB_D.png
new file mode 100644 (file)
index 0000000..bef0bf6
Binary files /dev/null and b/asset/enemy/ghostB_D.png differ
diff --git a/asset/enemy/ghostB_L.png b/asset/enemy/ghostB_L.png
new file mode 100644 (file)
index 0000000..b1534d4
Binary files /dev/null and b/asset/enemy/ghostB_L.png differ
diff --git a/asset/enemy/ghostB_R.png b/asset/enemy/ghostB_R.png
new file mode 100644 (file)
index 0000000..4844ad3
Binary files /dev/null and b/asset/enemy/ghostB_R.png differ
diff --git a/asset/enemy/ghostB_U.png b/asset/enemy/ghostB_U.png
new file mode 100644 (file)
index 0000000..a260f74
Binary files /dev/null and b/asset/enemy/ghostB_U.png differ
diff --git a/asset/enemy/ghostC_D.png b/asset/enemy/ghostC_D.png
new file mode 100644 (file)
index 0000000..0e8839c
Binary files /dev/null and b/asset/enemy/ghostC_D.png differ
diff --git a/asset/enemy/ghostC_L.png b/asset/enemy/ghostC_L.png
new file mode 100644 (file)
index 0000000..bdd3e17
Binary files /dev/null and b/asset/enemy/ghostC_L.png differ
diff --git a/asset/enemy/ghostC_R.png b/asset/enemy/ghostC_R.png
new file mode 100644 (file)
index 0000000..f4c823d
Binary files /dev/null and b/asset/enemy/ghostC_R.png differ
diff --git a/asset/enemy/ghostC_U.png b/asset/enemy/ghostC_U.png
new file mode 100644 (file)
index 0000000..e05a7ed
Binary files /dev/null and b/asset/enemy/ghostC_U.png differ
diff --git a/asset/enemy/ghostD_D.png b/asset/enemy/ghostD_D.png
new file mode 100644 (file)
index 0000000..0d0685e
Binary files /dev/null and b/asset/enemy/ghostD_D.png differ
diff --git a/asset/enemy/ghostD_L.png b/asset/enemy/ghostD_L.png
new file mode 100644 (file)
index 0000000..0e4fe14
Binary files /dev/null and b/asset/enemy/ghostD_L.png differ
diff --git a/asset/enemy/ghostD_R.png b/asset/enemy/ghostD_R.png
new file mode 100644 (file)
index 0000000..706f761
Binary files /dev/null and b/asset/enemy/ghostD_R.png differ
diff --git a/asset/enemy/ghostD_U.png b/asset/enemy/ghostD_U.png
new file mode 100644 (file)
index 0000000..1b4b625
Binary files /dev/null and b/asset/enemy/ghostD_U.png differ
diff --git a/asset/enemy/ghost_blue.png b/asset/enemy/ghost_blue.png
new file mode 100644 (file)
index 0000000..760b2de
Binary files /dev/null and b/asset/enemy/ghost_blue.png differ
diff --git a/asset/player/player_DA.png b/asset/player/player_DA.png
new file mode 100644 (file)
index 0000000..50c265a
Binary files /dev/null and b/asset/player/player_DA.png differ
diff --git a/asset/player/player_DB.png b/asset/player/player_DB.png
new file mode 100644 (file)
index 0000000..71ab862
Binary files /dev/null and b/asset/player/player_DB.png differ
diff --git a/asset/player/player_DC.png b/asset/player/player_DC.png
new file mode 100644 (file)
index 0000000..d65c866
Binary files /dev/null and b/asset/player/player_DC.png differ
diff --git a/asset/player/player_LA.png b/asset/player/player_LA.png
new file mode 100644 (file)
index 0000000..d3e5fe9
Binary files /dev/null and b/asset/player/player_LA.png differ
diff --git a/asset/player/player_LB.png b/asset/player/player_LB.png
new file mode 100644 (file)
index 0000000..9c6cb7a
Binary files /dev/null and b/asset/player/player_LB.png differ
diff --git a/asset/player/player_LC.png b/asset/player/player_LC.png
new file mode 100644 (file)
index 0000000..5ab5be9
Binary files /dev/null and b/asset/player/player_LC.png differ
diff --git a/asset/player/player_RA.png b/asset/player/player_RA.png
new file mode 100644 (file)
index 0000000..641500d
Binary files /dev/null and b/asset/player/player_RA.png differ
diff --git a/asset/player/player_RB.png b/asset/player/player_RB.png
new file mode 100644 (file)
index 0000000..1537710
Binary files /dev/null and b/asset/player/player_RB.png differ
diff --git a/asset/player/player_RC.png b/asset/player/player_RC.png
new file mode 100644 (file)
index 0000000..5d1d33a
Binary files /dev/null and b/asset/player/player_RC.png differ
diff --git a/asset/player/player_UA.png b/asset/player/player_UA.png
new file mode 100644 (file)
index 0000000..0f43f68
Binary files /dev/null and b/asset/player/player_UA.png differ
diff --git a/asset/player/player_UB.png b/asset/player/player_UB.png
new file mode 100644 (file)
index 0000000..af99964
Binary files /dev/null and b/asset/player/player_UB.png differ
diff --git a/asset/player/player_UC.png b/asset/player/player_UC.png
new file mode 100644 (file)
index 0000000..9f6b4d1
Binary files /dev/null and b/asset/player/player_UC.png differ
diff --git a/asset/walls/0.png b/asset/walls/0.png
new file mode 100644 (file)
index 0000000..7479afa
Binary files /dev/null and b/asset/walls/0.png differ
diff --git a/asset/walls/1.png b/asset/walls/1.png
new file mode 100644 (file)
index 0000000..8591da0
Binary files /dev/null and b/asset/walls/1.png differ
diff --git a/asset/walls/10.png b/asset/walls/10.png
new file mode 100644 (file)
index 0000000..1efcfbd
Binary files /dev/null and b/asset/walls/10.png differ
diff --git a/asset/walls/11.png b/asset/walls/11.png
new file mode 100644 (file)
index 0000000..c2f3e8e
Binary files /dev/null and b/asset/walls/11.png differ
diff --git a/asset/walls/12.png b/asset/walls/12.png
new file mode 100644 (file)
index 0000000..5cb94d8
Binary files /dev/null and b/asset/walls/12.png differ
diff --git a/asset/walls/13.png b/asset/walls/13.png
new file mode 100644 (file)
index 0000000..2b18403
Binary files /dev/null and b/asset/walls/13.png differ
diff --git a/asset/walls/14.png b/asset/walls/14.png
new file mode 100644 (file)
index 0000000..c4b299e
Binary files /dev/null and b/asset/walls/14.png differ
diff --git a/asset/walls/15.png b/asset/walls/15.png
new file mode 100644 (file)
index 0000000..b01342b
Binary files /dev/null and b/asset/walls/15.png differ
diff --git a/asset/walls/2.png b/asset/walls/2.png
new file mode 100644 (file)
index 0000000..61318a7
Binary files /dev/null and b/asset/walls/2.png differ
diff --git a/asset/walls/3.png b/asset/walls/3.png
new file mode 100644 (file)
index 0000000..3b26f8a
Binary files /dev/null and b/asset/walls/3.png differ
diff --git a/asset/walls/4.png b/asset/walls/4.png
new file mode 100644 (file)
index 0000000..66f49a1
Binary files /dev/null and b/asset/walls/4.png differ
diff --git a/asset/walls/5.png b/asset/walls/5.png
new file mode 100644 (file)
index 0000000..63b3804
Binary files /dev/null and b/asset/walls/5.png differ
diff --git a/asset/walls/6.png b/asset/walls/6.png
new file mode 100644 (file)
index 0000000..8f1faf5
Binary files /dev/null and b/asset/walls/6.png differ
diff --git a/asset/walls/7.png b/asset/walls/7.png
new file mode 100644 (file)
index 0000000..36c05c2
Binary files /dev/null and b/asset/walls/7.png differ
diff --git a/asset/walls/8.png b/asset/walls/8.png
new file mode 100644 (file)
index 0000000..7632e52
Binary files /dev/null and b/asset/walls/8.png differ
diff --git a/asset/walls/9.png b/asset/walls/9.png
new file mode 100644 (file)
index 0000000..adc0bc8
Binary files /dev/null and b/asset/walls/9.png differ
diff --git a/config.json b/config.json
new file mode 100644 (file)
index 0000000..39bbc53
--- /dev/null
@@ -0,0 +1,13 @@
+{
+    "highscore_filename": "score.json",
+    "levels": 10,
+    "width": 20,
+    "height": 20,
+    "lives": 3,
+    "pacgum": 80,
+    "points_per_pacgum": 1,
+    "points_per_super_pacgum": 5,
+    "points_per_ghost": 20,
+    "time": 180,
+    "cheat_mod": false
+}
\ No newline at end of file
diff --git a/mazegenerator-00001-py3-none-any.whl b/mazegenerator-00001-py3-none-any.whl
new file mode 100644 (file)
index 0000000..03d6ac1
Binary files /dev/null and b/mazegenerator-00001-py3-none-any.whl differ
diff --git a/score.json b/score.json
new file mode 100644 (file)
index 0000000..02d2466
--- /dev/null
@@ -0,0 +1,26 @@
+[
+    {
+        "name": "Garobert",
+        "score": 1
+    },
+    {
+        "name": "ssss",
+        "score": 0
+    },
+    {
+        "name": "player",
+        "score": 51
+    },
+    {
+        "name": "dd",
+        "score": 61
+    },
+    {
+        "name": "",
+        "score": 0
+    },
+    {
+        "name": "kkk",
+        "score": 0
+    }
+]
\ No newline at end of file
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/__main__.py b/src/__main__.py
new file mode 100644 (file)
index 0000000..d2af9a8
--- /dev/null
@@ -0,0 +1,22 @@
+from .game import Game
+from .config import load_config
+from .error import error
+import importlib
+import sys
+
+
+for lib in ["pydantic", "mazegenerator", "pygame"]:
+    try:
+        importlib.import_module(lib)
+    except ModuleNotFoundError:
+        error("import missing, use Make install")
+
+try:
+    file_path = sys.argv[1]
+except IndexError:
+    file_path = ""
+config = load_config(file_path)
+try:
+    game = Game(config)
+except KeyboardInterrupt:
+    error("keyboardInterrupt.")
diff --git a/src/collectibles.py b/src/collectibles.py
new file mode 100644 (file)
index 0000000..c5e6d8e
--- /dev/null
@@ -0,0 +1,61 @@
+from mazegenerator import MazeGenerator  # type: ignore[import-untyped]
+from abc import ABC
+from .config import Configuration
+from .player import Player
+import random
+
+
+class Collectibles(ABC):
+    def __init__(self, point: int, size: int) -> None:
+        self.point = point
+        self.size = size
+
+    def collect(self) -> int:
+        return self.point
+
+
+class Pacgum(Collectibles):
+    def __init__(self, config: Configuration):
+        point = config.points_per_pacgum
+        super().__init__(point, 3)
+
+
+class SuperPacgum(Collectibles):
+    def __init__(self, config: Configuration):
+        point = config.points_per_pacgum
+        super().__init__(point, 6)
+
+
+class CollectableManager:
+    def __init__(self) -> None:
+        self.collectibles: dict[tuple[int, int], Collectibles] = {}
+
+    def fill(self, config: Configuration, maze: MazeGenerator,
+             player: Player) -> None:
+        collectibles: dict[tuple[int, int], Collectibles] = {}
+        h = maze._height
+        w = maze._width
+        for i in range(w):
+            for j in range(h):
+                if (i, j) == player.coordonate or maze.maze[j][i] == 15:
+                    continue
+                if (i, j) in [(0, 0), (h - 1, 0), (0, w - 1), (h - 1, w - 1)]:
+                    collectibles[(i, j)] = SuperPacgum(config)
+                else:
+                    if random.randint(0, 100) < config.pacgum:
+                        collectibles[(i, j)] = Pacgum(config)
+
+        self.collectibles = collectibles
+
+    def collect(self, pos: tuple[int, int]) -> tuple[int, bool]:
+        point = 0
+        hunt = False
+        if self.collectibles.get(pos):
+            pacgum = self.collectibles.pop(pos)
+            point = pacgum.collect()
+            if isinstance(pacgum, SuperPacgum):
+                hunt = True
+        return (point, hunt)
+
+    def is_empty(self) -> bool:
+        return len(self.collectibles) == 0
diff --git a/src/config.py b/src/config.py
new file mode 100644 (file)
index 0000000..b45b7d4
--- /dev/null
@@ -0,0 +1,74 @@
+from pydantic import ValidationError, BaseModel, Field
+from pathlib import Path
+from .error import error
+import json
+
+
+CELL_SIZE = 64
+
+
+class Configuration(BaseModel):
+    highscore_filename: str = Field(default="score.json")
+    levels: int = Field(ge=1, le=256, default=10)
+    width: int = Field(ge=5, le=20, default=13)
+    height: int = Field(ge=5, le=20, default=13)
+    lives: int = Field(ge=1, le=99, default=3)
+    pacgum: int = Field(ge=10, le=100, default=80)
+    points_per_pacgum: int = Field(ge=1, le=99, default=1)
+    points_per_super_pacgum: int = Field(ge=1, le=99, default=5)
+    points_per_ghost: int = Field(ge=1, le=99, default=20)
+    cheat_mod: bool = Field(default=False)
+    time: int = Field(ge=60, le=300, default=180)
+
+
+def load_config(path: str) -> Configuration:
+    if not path:
+        error("no file path given, default configuration applied.", False)
+        return Configuration()
+
+    file_path = Path(path)
+
+    if not file_path.exists():
+        error(f"file not found: '{path}', default configuration applied.",
+              False)
+        return Configuration()
+
+    text = ""
+    for item in file_path.read_text(encoding="utf-8").split("\n"):
+        if (
+            not item.strip().startswith("#") and
+            not item.strip().startswith("//")
+        ):
+            if text:
+                text += "\n"
+            text += item
+
+    try:
+        data = json.loads(text)
+    except json.JSONDecodeError:
+        error(f"invalid JSON in {path}, default configuration applied..",
+              False)
+        return Configuration()
+
+    try:
+        config = Configuration.model_validate(data)
+
+    except ValidationError as err:
+        errors = err.errors()
+
+        for e in errors:
+            field = e["loc"][0]
+
+            if e["type"] == "missing":
+                error(f"missing '{field}' configuration, default"
+                      "value applied", False)
+            else:
+                error(f"wrong value for '{field}' configuration, "
+                      "default value applied", False)
+
+        for e in errors:
+            data.pop(e["loc"][0], None)
+
+        config = Configuration.model_validate(data)
+
+    return config
diff --git a/src/error.py b/src/error.py
new file mode 100644 (file)
index 0000000..b597bae
--- /dev/null
@@ -0,0 +1,28 @@
+import sys
+
+
+def error(err: str, close: bool = True, line: int = 0) -> None:
+    """
+    Display an error message to the standard error stream and terminate
+    the program with exit code 1.
+
+    Args:
+        err (str): Description of the error.
+        line (int, optional): Line number where the error occurred.
+            If provided and greater than 0, it is included in the error
+            message. Defaults to 0.
+
+    Returns:
+        NoReturn: This function never returns because it terminates
+        the program.
+
+    Raises:
+        SystemExit: Always raised with exit code 1.
+    """
+    if line:
+        sys.stderr.write("\nError in line " + str(line) + ": " + err + "\n")
+    else:
+        sys.stderr.write("\nError: " + err + "\n")
+    sys.stderr.flush()
+    if close:
+        exit(1)
diff --git a/src/game.py b/src/game.py
new file mode 100644 (file)
index 0000000..6fe1004
--- /dev/null
@@ -0,0 +1,160 @@
+from mazegenerator import MazeGenerator  # type: ignore[import-untyped]
+from .config import Configuration
+from .score import ScoreManager
+from .render import MenuRenderer, GameRenderer, EndingRenderer
+from .render import Renderer, RenderInfo
+from .input import MenuInput, GameInput, EndingInput, Input
+from .player import Player
+from .collectibles import CollectableManager
+import pygame
+import time
+
+
+class Game:
+    def __init__(self, config: Configuration):
+        self.config = config
+        self.screen = pygame.display.set_mode((2560, 1440))
+        self.score_manager = ScoreManager(config.highscore_filename)
+        self.score = 0
+        self.lives = config.lives
+        self.maze = MazeGenerator(
+            (self.config.height, self.config.width),
+            False, (0, 0), (1, 1), 42
+        )
+        self.level = 0
+        self.render_info = self.get_render_info()
+        self.renderer: Renderer = MenuRenderer(
+            self.screen,
+            self.render_info,
+            self.score_manager)
+        self.input: Input = MenuInput(self.renderer, self.new_game, self.quit)
+        self.collectibles = CollectableManager()
+        self.player = Player(self.maze.maze)
+        self.running = True
+        self.start_time = 0.0
+        self.now_time = 0.0
+        self.run()
+
+    def add_collectibles(self) -> None:
+        self.collectibles.fill(self.config, self.maze, self.player)
+
+    def finish_game(self, victory: bool) -> None:
+        self.renderer = EndingRenderer(self.screen, self.render_info, victory)
+        self.input = EndingInput(self.renderer, self.add_score)
+
+    def win(self) -> None:
+        self.finish_game(True)
+
+    def loose(self) -> None:
+        self.finish_game(False)
+
+    def time_out(self) -> None:
+        if isinstance(self.renderer, GameRenderer):
+            self.renderer.time_out()
+            pygame.display.flip()
+        time.sleep(3)
+        self.loose()
+
+    def update_time(self) -> None:
+        self.now_time = time.time()
+        remain_time = self.config.time - (self.now_time - self.start_time)
+        self.render_info.remain_time = round(remain_time, 1)
+        if remain_time <= 0:
+            self.time_out()
+
+    def restart(self) -> None:
+        self.renderer = MenuRenderer(
+            self.screen,
+            self.render_info,
+            self.score_manager)
+        self.input = MenuInput(self.renderer, self.new_game, self.quit)
+
+    def get_render_info(self) -> RenderInfo:
+        return RenderInfo(
+            fps=0,
+            width=self.config.width,
+            height=self.config.height,
+            maze=self.maze.maze,
+            level=self.level,
+            score=self.score,
+            lives=self.lives,
+            remain_time=0.0
+        )
+
+    def next_level(self) -> None:
+        if self.level == 10:
+            self.win()
+            return
+        self.level += 1
+        self.maze.generate()
+        self.render_info.level = self.level
+        self.render_info.maze = self.maze.maze
+        self.player.next_level((1, 1), self.maze.maze)
+        if isinstance(self.renderer, GameRenderer):
+            self.renderer.next_level()
+        self.add_collectibles()
+        self.start_time = time.time()
+
+    def new_game(self) -> None:
+        self.score = 0
+        self.level = 0
+        self.lives = self.config.lives
+        self.renderer = GameRenderer(
+            self.screen,
+            self.render_info,
+            self.player,
+            self.collectibles)
+        self.input = GameInput(self.player, self.next_level, self.kill_player)
+        self.next_level()
+
+    def hunt(self) -> None:
+        print("Hunting ghost")
+        pass
+
+    def kill_player(self) -> None:
+        self.lives -= 1
+        self.render_info.lives = self.lives
+        if self.lives == 0:
+            self.loose()
+        else:
+            self.player.reset()
+
+    def pick_up(self) -> None:
+        point, hunt = self.collectibles.collect(self.player.coordonate)
+        self.score += point
+        self.render_info.score = self.score
+        if hunt:
+            self.hunt()
+        if self.collectibles.is_empty():
+            self.next_level()
+
+    def quit(self) -> None:
+        self.running = False
+
+    def run(self) -> None:
+        pygame.init()
+        pygame.font.init()
+        clock = pygame.time.Clock()
+
+        while self.running:
+            for event in pygame.event.get():
+                if event.type == pygame.QUIT:
+                    self.running = False
+                self.input.get_event(event)
+            keys = pygame.key.get_pressed()
+            self.input.get_key(keys)
+            self.render_info.fps = round(clock.get_fps(), 2)
+            if isinstance(self.input, GameInput):
+                self.player.move()
+                self.update_time()
+                if self.player.can_move():
+                    self.pick_up()
+            self.screen.fill("black")
+            self.renderer.render()
+            pygame.display.flip()
+            clock.tick(60)
+        pygame.quit()
+
+    def add_score(self, name: str) -> None:
+        self.score_manager.add_score(name, self.score)
+        self.restart()
diff --git a/src/input.py b/src/input.py
new file mode 100644 (file)
index 0000000..91d325a
--- /dev/null
@@ -0,0 +1,93 @@
+from abc import ABC
+from collections.abc import Callable
+from .render import MenuRenderer, EndingRenderer
+from .player import Player
+import pygame
+import time
+
+
+class Input(ABC):
+    def get_event(self, event: pygame.event.Event) -> None:
+        return
+
+    def get_key(self, keys: pygame.key.ScancodeWrapper) -> None:
+        return
+
+
+class MenuInput(Input):
+    def __init__(self, renderer: MenuRenderer, new_game: Callable[[], None],
+                 quit: Callable[[], None]) -> None:
+        self.renderer = renderer
+        self.index = 0
+        self.functions = {
+            0: new_game,
+            1: quit,
+        }
+        self.cooldown = 0.0
+
+    def change_index(self, value: int) -> None:
+        self.index = max(0, min(1, self.index + value))
+        self.renderer.index = self.index
+        self.cooldown = time.time()
+
+    def validation(self) -> None:
+        self.functions[self.index]()
+
+    def get_event(self, event: pygame.event.Event) -> None:
+        if event.type == pygame.KEYDOWN:
+            if event.key in [1073741906, 119]:
+                self.change_index(-1)
+            elif event.key in [1073741905, 115]:
+                self.change_index(1)
+            elif event.key in [13, 32]:
+                self.validation()
+
+
+class GameInput(Input):
+    def __init__(self, player: Player, next_lvl: Callable[[], None],
+                 kill: Callable[[], None]):
+        self.player = player
+        self.next_level = next_lvl
+        self.kill = kill
+
+    def get_key(self, keys: pygame.key.ScancodeWrapper) -> None:
+        if keys[pygame.K_UP] or keys[pygame.K_w]:
+            self.player.go_to((0, -1))
+        elif keys[pygame.K_DOWN] or keys[pygame.K_s]:
+            self.player.go_to((0, 1))
+        elif keys[pygame.K_LEFT] or keys[pygame.K_a]:
+            self.player.go_to((-1, 0))
+        elif keys[pygame.K_RIGHT] or keys[pygame.K_d]:
+            self.player.go_to((1, 0))
+
+    def get_event(self, event: pygame.event.Event) -> None:
+        if event.type == pygame.KEYDOWN:
+            if event.unicode == "n":
+                self.next_level()
+            if event.unicode == "k":
+                self.kill()
+
+
+class EndingInput(Input):
+    def __init__(self, renderer: EndingRenderer,
+                 add_score: Callable[[str], None]) -> None:
+        self.renderer = renderer
+        self.add_score = add_score
+        self.name = ""
+
+    def get_event(self, event: pygame.event.Event) -> None:
+        if event.type == pygame.TEXTINPUT:
+            c: str = event.text
+            if c.isalnum():
+                if len(self.name) < 10:
+                    self.name += event.text
+        elif event.type == pygame.KEYDOWN:
+            if event.key == 8:
+                if self.name:
+                    self.name = self.name[0:-1]
+            elif event.key == 32:
+                if len(self.name) < 10:
+                    self.name += ' '
+            elif event.key == 13:
+                self.add_score(self.name)
+        self.renderer.name = self.name
diff --git a/src/player.py b/src/player.py
new file mode 100644 (file)
index 0000000..7427558
--- /dev/null
@@ -0,0 +1,80 @@
+from .config import CELL_SIZE as CELL_SIZE
+import pygame
+
+
+class Player:
+    def __init__(self, maze: list[list[int]]):
+        self.position = (0, 0)
+        self.coordonate = (0, 0)
+        self.direction = (0, 0)
+        self.start_coordonate = (0, 0)
+        self.speed = 4
+        self.maze = maze
+        self.spritesheet = self.load_player_sprites()
+        self.sprite = self.spritesheet[(1, 0)]
+
+    def update_position(self) -> None:
+        self.position = (self.coordonate[0] * CELL_SIZE,
+                         self.coordonate[1] * CELL_SIZE)
+
+    def next_level(self, pos: tuple[int, int], maze: list[list[int]]) -> None:
+        self.maze = maze
+        self.start_coordonate = pos
+        self.reset()
+
+    def reset(self) -> None:
+        self.sprite = self.spritesheet[(1, 0)]
+        self.direction = (0, 0)
+        self.coordonate = self.start_coordonate
+        self.update_position()
+
+    @staticmethod
+    def load_player_sprites() -> dict[tuple[int, int], pygame.Surface]:
+        player_textures = {}
+        for t, l in [((0, 1), "D"),
+                     ((0, -1), "U"),
+                     ((1, 0), "R"),
+                     ((-1, 0), "L")]:
+            filename = "asset/player/player_" + str(l) + "A.png"
+            img = pygame.image.load(filename)
+            player_textures[t] = img
+        return player_textures
+
+    def get_sprite(self) -> pygame.Surface:
+        if self.direction != (0, 0):
+            self.sprite = self.spritesheet[self.direction]
+        return self.sprite
+
+    @staticmethod
+    def binary(cell: int) -> list[int]:
+        b = bin(cell)[2:].zfill(4)
+        r = [1 if i == "1" else 0 for i in b]
+        return r[::-1]
+
+    def can_go(self, dir: tuple[int, int]) -> bool:
+        cell = self.maze[self.coordonate[1]][self.coordonate[0]]
+        index = [(0, -1), (1, 0), (0, 1), (-1, 0)].index(dir)
+        return self.binary(cell)[index] == 0
+
+    def can_move(self) -> bool:
+        x = self.position[0]
+        y = self.position[1]
+        return (x % CELL_SIZE == 0 and y % CELL_SIZE == 0)
+
+    def go_to(self, dir: tuple[int, int]) -> None:
+        if not self.can_go(dir) or not self.can_move():
+            return
+        self.direction = dir
+
+    def move(self) -> None:
+        if self.direction == (0, 0):
+            return
+        x = self.position[0] + self.direction[0] * self.speed
+        y = self.position[1] + self.direction[1] * self.speed
+        self.position = (x, y)
+        if self.can_move():
+            x = self.coordonate[0] + self.direction[0]
+            y = self.coordonate[1] + self.direction[1]
+            self.coordonate = (x, y)
+            if not self.can_go(self.direction):
+                self.direction = (0, 0)
diff --git a/src/render.py b/src/render.py
new file mode 100644 (file)
index 0000000..6a9c386
--- /dev/null
@@ -0,0 +1,231 @@
+from pydantic import BaseModel
+from abc import ABC, abstractmethod
+from .config import CELL_SIZE as CELL_SIZE
+from .score import ScoreManager
+from .player import Player
+from .collectibles import CollectableManager
+import pygame
+import time
+
+
+class RenderInfo(BaseModel):
+    fps: float
+    width: int
+    height: int
+    maze: list[list[int]]
+    level: int
+    score: int
+    lives: int
+    remain_time: float
+
+
+class Renderer(ABC):
+    def __init__(self, screen: pygame.Surface, info: RenderInfo) -> None:
+        self.screen = screen
+        self.info = info
+        super().__init__()
+
+    @abstractmethod
+    def render(self) -> None:
+        ...
+
+    def draw_text(self, text: str, font_color: tuple[int, int, int],
+                  font_size: int, position: tuple[int, int]) -> int:
+        font = pygame.font.Font(None, font_size)
+        txt = font.render(text, True, font_color)
+        self.screen.blit(txt, position)
+        return txt.get_width()
+
+
+class Animate_chararacter:
+    def __init__(self, sprite: str):
+        self.sprite = {letter: pygame.image.load("asset/enemy/ghost_blue.png")
+                       if ("ghost" in sprite and letter == "R")
+                       else pygame.image.load(sprite.replace("*", letter))
+                       for letter in ["L", "R"]}
+
+    def get_sprite(self, dir: str) -> pygame.Surface:
+        return self.sprite[dir]
+
+
+class MenuRenderer(Renderer):
+    def __init__(self, screen: pygame.Surface, info: RenderInfo,
+                 score_manager: ScoreManager) -> None:
+        super().__init__(screen, info)
+        self.index = 0
+        self.characters = self.add_animated_characters()
+        self.pos = (-1000, 100)
+        self.dir = "R"
+        self.score_manager = score_manager
+
+    def add_animated_characters(self) -> list[Animate_chararacter]:
+        char = [Animate_chararacter("asset/player/player_*A.png")]
+        for letter in "ABCD":
+            s = "asset/enemy/ghost" + letter + "_*.png"
+            char.append(Animate_chararacter(s))
+        return char
+
+    def move(self) -> None:
+        self.pos = (self.pos[0] + 8 * (1 if self.dir == "R" else -1),
+                    self.pos[1])
+
+        if self.pos[0] > 2700:
+            self.dir = "L"
+        if self.pos[0] < -600:
+            self.dir = "R"
+
+    def draw_character(self) -> None:
+        self.move()
+        padding = 80
+        i = 0
+        for c in self.characters:
+            pos = (self.pos[0] + i * padding + (100 if i else 0), self.pos[1])
+            self.screen.blit(c.get_sprite(self.dir), pos)
+            i += 1
+
+    def highscore(self) -> None:
+        x = 1800
+        y = 400
+        padding = 50
+
+        self.draw_text("High Score", (255, 255, 255), 100, (x + 50, y))
+
+        count = 0
+        lst = sorted(self.score_manager.score_list,
+                     key=lambda x: x.score, reverse=True)
+        for s in lst:
+            count += 1
+            txt = str(count) + ".  " + s.name + " : " + str(s.score)
+            self.draw_text(txt, (255, 255, 255), 40,
+                           (x + 100, y + 100 + count * padding))
+            if count == 10:
+                break
+
+    def instructions(self) -> None:
+        x = 100
+        y = 400
+        padding = 70
+        size = 40
+        self.draw_text("Instructions", (255, 255, 255), 100, (x + 50, y))
+        self.draw_text("Use arrows or WASD to move in the maze.",
+                       (255, 255, 255), size, (x, y + 2 * padding))
+        self.draw_text("Pick up every pacgum to win the level.",
+                       (255, 255, 255), size, (x, y + 3 * padding))
+        self.draw_text("If a ghost touch you, you lose a life.",
+                       (255, 255, 255), size, (x, y + 4 * padding))
+        self.draw_text("If your life or the timer go to 0 you lose.",
+                       (255, 255, 255), size, (x, y + 5 * padding))
+        self.draw_text("If you finish every level you win.",
+                       (255, 255, 255), size, (x, y + 6 * padding))
+
+    def render(self,) -> None:
+        self.instructions()
+        self.highscore()
+        self.draw_character()
+        x = 1130
+        y = 600
+        padding = 150
+        self.draw_text("PAC-MAN", (255, 255, 0), 200, (x - 180, 200))
+        len = self.draw_text("New Game", (255, 255, 255), 80, (x, y))
+        self.draw_text("Quit", (255, 255, 255), 80, (x + 80, y + padding))
+        rect = pygame.Rect(x - 20, y - 20 + self.index * padding, len + 40, 92)
+        pygame.draw.rect(self.screen, (255, 255, 255), rect, 4)
+
+
+class GameRenderer(Renderer):
+    def __init__(self, screen: pygame.Surface, info: RenderInfo,
+                 player: Player, collectibles: CollectableManager):
+        super().__init__(screen, info)
+        self.maze = self.render_maze()
+        self.player = player
+        self.collectibles = collectibles
+        self.x = 500
+        self.y = 80 + (1280 - self.info.height * CELL_SIZE) / 2
+
+    @staticmethod
+    def load_wall_textures() -> dict[int, pygame.Surface]:
+        return {n: pygame.image.load("asset/walls/" + str(n) + ".png")
+                for n in range(16)}
+
+    def render_maze(self) -> pygame.Surface:
+        maze_surface = pygame.Surface(
+            (self.info.width * CELL_SIZE, self.info.height * CELL_SIZE))
+        walls_textures = self.load_wall_textures()
+        y = 0
+        for row in self.info.maze:
+            x = 0
+            for cell in row:
+                img = walls_textures[cell]
+                maze_surface.blit(img, (x * CELL_SIZE, y * CELL_SIZE))
+                x += 1
+            y += 1
+        return maze_surface
+
+    def next_level(self) -> None:
+        self.maze = self.render_maze()
+
+    def draw_HUD(self) -> None:
+        x = 200
+        y = 100
+        padding = 40
+        self.draw_text("FPS: " + str(self.info.fps), (255, 255, 255),
+                       30, (x, y))
+        self.draw_text("Level: " + str(self.info.level), (255, 255, 255),
+                       30, (x, y + padding))
+        self.draw_text("Lives: " + str(self.info.lives), (255, 255, 255),
+                       30, (x, y + 2 * padding))
+        self.draw_text("Score: " + str(self.info.score), (255, 255, 255),
+                       30, (x, y + 3 * padding))
+        self.draw_text("Time: " + str(self.info.remain_time), (255, 255, 255),
+                       30, (x, y + 4 * padding))
+
+    def render(self) -> None:
+        self.draw_HUD()
+        self.screen.blit(self.maze, (self.x, self.y))
+        player_sprite = self.player.get_sprite()
+        x = self.x + self.player.position[0]
+        y = self.y + self.player.position[1]
+        self.screen.blit(player_sprite, (x, y))
+        for i in self.collectibles.collectibles:
+            pacgum = self.collectibles.collectibles[i]
+            size = pacgum.size
+            center = (
+                self.x + i[0] * CELL_SIZE + CELL_SIZE // 2,
+                self.y + i[1] * CELL_SIZE + CELL_SIZE // 2
+                )
+            pygame.draw.circle(self.screen, (255, 255, 255), center, size)
+
+    def time_out(self) -> None:
+        x = self.x + (self.info.width * CELL_SIZE) / 2 - 200
+        y = self.y + (self.info.height * CELL_SIZE) / 2 - 50
+        rect = pygame.Rect(x - 22, y - 21, 372, 110)
+        pygame.draw.rect(self.screen, (0, 0, 0), rect)
+        pygame.draw.rect(self.screen, (255, 255, 255), rect, 4)
+
+
+class EndingRenderer(Renderer):
+    def __init__(self, screen: pygame.Surface, info: RenderInfo,
+                 victory: bool) -> None:
+        super().__init__(screen, info)
+        self.name = ""
+        self.victory = victory
+
+    def render(self) -> None:
+        x = 900
+        y = 500
+        if self.victory:
+            self.draw_text("YOU WIN !", (255, 255, 50), 200, (x + 50, y - 100))
+            self.draw_text("Enter your name", (255, 255, 255), 50,
+                           (x + 250, y + 300))
+        else:
+            self.draw_text("GAME OVER", (200, 50, 50), 200, (x, y - 100))
+            self.draw_text("Enter your name, loser", (255, 255, 255), 50,
+                           (x + 200, y + 300))
+        self.draw_text("Your score : " + str(self.info.score), (255, 255, 255),
+                       80, (x + 200, y + 200))
+        txt = self.draw_text(self.name, (255, 255, 255), 50,
+                             (x + 400 - 10 * len(self.name), y + 400))
+        if round(time.time()) % 2:
+            rect = pygame.Rect(x + 405 + txt - 10 * len(self.name),
+                               y + 395, 4, 40)
+            pygame.draw.rect(self.screen, (255, 255, 255), rect)
diff --git a/src/score.py b/src/score.py
new file mode 100644 (file)
index 0000000..39b8a12
--- /dev/null
@@ -0,0 +1,69 @@
+from pydantic import ValidationError, BaseModel
+from pathlib import Path
+from .error import error
+import json
+
+
+class ScoreDefinition(BaseModel):
+    name: str
+    score: int
+
+
+class ScoreManager:
+    def __init__(self, path: str) -> None:
+        file_path = Path(path)
+        self.score_list: list[ScoreDefinition] = []
+        self.file_path = file_path
+        if not file_path.exists():
+            error("No highscore file found : new file created", False)
+            self.save()
+        else:
+            self.load()
+
+    def score_error(self) -> None:
+        error("Highscore file corrupted : new file created", False)
+        self.save()
+
+    def load(self) -> None:
+        try:
+            data = json.loads(self.file_path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            return self.score_error()
+
+        if not isinstance(data, list):
+            return self.score_error()
+
+        score_list: list[ScoreDefinition] = []
+
+        for item in data:
+            try:
+                score_list.append(ScoreDefinition.model_validate(item))
+            except ValidationError:
+                return self.score_error()
+        self.score_list = score_list
+
+    def save(self) -> None:
+        data = [item.model_dump() for item in self.score_list]
+        self.file_path.parent.mkdir(parents=True, exist_ok=True)
+        self.file_path.write_text(
+            json.dumps(data, indent=4, ensure_ascii=False),
+            encoding="utf-8"
+        )
+
+    def add_score(self, name: str, score: int) -> None:
+        try:
+            item = ScoreDefinition(
+                name=name,
+                score=score
+            )
+        except ValidationError:
+            return
+        exist = False
+        for i in self.score_list:
+            if i.name == name:
+                exist = True
+                if i.score < score:
+                    i.score = score
+        if not exist:
+            self.score_list.append(item)
+        self.save()