refactor for up to 4 players
authorGeoffrey Allott <geoffrey@allott.email>
Sun, 25 Dec 2022 19:37:29 +0000 (19:37 +0000)
committerGeoffrey Allott <geoffrey@allott.email>
Sun, 25 Dec 2022 19:37:29 +0000 (19:37 +0000)
snake.py

index 79bdd2d393606059fb8eec2a2254fba32a0015c2..c4cd0aa434e0cd1fd5ee053858acdd82eab151d0 100644 (file)
--- a/snake.py
+++ b/snake.py
@@ -1,3 +1,4 @@
+import argparse
 import curses
 from enum import Enum
 import os
@@ -11,58 +12,203 @@ class ExitGameException(BaseException):
 class RestartGameException(BaseException):
     pass
 
-class TileContents(Enum):
-    Empty = "Empty"
-    Wall = "Wall"
-    Snake = "Snake"
-    Fruit = "Fruit"
-    TurboFruit = "TurboFruit"
-    PortalFruit = "PortalFruit"
-    Dead = "Dead"
-
-    def char(self, *, turbo: bool):
-        if self is TileContents.Empty:
-            return ' '
-        elif self is TileContents.Wall:
-            return '#'
-        elif self is TileContents.Snake:
-            return '*' if turbo else '§'
-        elif self is TileContents.Fruit:
-            return '@'
-        elif self is TileContents.TurboFruit:
-            return '»'
-        elif self is TileContents.PortalFruit:
-            return 'O'
-        elif self is TileContents.Dead:
-            return 'X'
-
 class Direction(Enum):
     Left = "Left"
     Right = "Right"
     Up = "Up"
     Down = "Down"
 
-class Fruit:
-    def __init__(self, row, column, life, id):
+    def opposite(self):
+        if self == Direction.Left: return Direction.Right
+        if self == Direction.Right: return Direction.Left
+        if self == Direction.Up: return Direction.Down
+        if self == Direction.Down: return Direction.Up
+
+class Action:
+    def is_teleport(self) -> bool:
+        return False
+
+    def is_score(self) -> bool:
+        return False
+
+    def is_turbo(self) -> bool:
+        return False
+
+    def is_death(self) -> bool:
+        return False
+
+class NullAction(Action):
+    pass
+
+class DeathAction(Action):
+    def is_death(self) -> bool:
+        return True
+
+class ScoreAction(Action):
+    def is_score(self) -> bool:
+        return True
+
+class TurboAction(ScoreAction):
+    def is_turbo(self) -> bool:
+        return True
+
+class TeleportAction(ScoreAction):
+    def __init__(self, row, column):
         self.row = row
         self.column = column
+
+    def is_teleport(self) -> bool:
+        return True
+
+class Tile:
+    def char(self) -> str:
+        raise NotImplementedError
+
+    def color(self) -> int:
+        return curses.color_pair(0)
+
+    def action(self) -> Action:
+        raise NotImplementedError
+
+    def is_empty(self) -> bool:
+        return False
+
+    def is_fruit(self) -> bool:
+        return False
+
+class EmptyTile(Tile):
+    def char(self) -> str:
+        return ' '
+
+    def action(self) -> Action:
+        return NullAction()
+
+    def is_empty(self) -> bool:
+        return True
+
+class WallTile(Tile):
+    def char(self) -> str:
+        return '#'
+
+    def action(self) -> Action:
+        return DeathAction()
+
+class Snake:
+    def __init__(self, segments, color, dir):
+        self.segments = segments
+        self.turbo = 0
+        self.color = color
+        self.alive = True
+        self.prev_dir = dir
+        self.dir = dir
+
+    def len(self):
+        return len(self.segments)
+
+    def set_dir(self, dir):
+        if self.prev_dir == dir.opposite():
+            curses.beep()
+        elif self.dir != self.prev_dir:
+            pass
+        else:
+            self.dir = dir
+
+    def slither(self):
+        if self.dir == Direction.Left:
+            self.segments.append((self.segments[-1][0], self.segments[-1][1] - 1))
+        if self.dir == Direction.Right:
+            self.segments.append((self.segments[-1][0], self.segments[-1][1] + 1))
+        if self.dir == Direction.Up:
+            self.segments.append((self.segments[-1][0] - 1, self.segments[-1][1]))
+        if self.dir == Direction.Down:
+            self.segments.append((self.segments[-1][0] + 1, self.segments[-1][1]))
+        self.prev_dir = self.dir
+
+    def shrink(self):
+        self.segments = self.segments[1:]
+
+class SnakeTile(Tile):
+    def __init__(self, snake: Snake):
+        self.snake = snake
+
+    def char(self) -> str:
+        return '*' if self.snake.turbo else '§'
+
+    def color(self) -> int:
+        return self.snake.color
+
+    def action(self) -> Action:
+        return DeathAction()
+
+class DeadTile(Tile):
+    def char(self) -> str:
+        return 'X'
+
+    def action(self) -> Action:
+        return DeathAction()
+
+class FruitTile(Tile):
+    def __init__(self, life):
         self.life = life
-        self.id = id
+
+    def char(self) -> str:
+        return '@'
+
+    def action(self) -> Action:
+        return ScoreAction()
+
+    def is_fruit(self) -> bool:
+        return True
+
+class TurboFruitTile(FruitTile):
+    def char(self) -> str:
+        return '»'
+
+    def action(self) -> Action:
+        return TurboAction()
+
+class PortalFruitTile(FruitTile):
+    def __init__(self, life: int, other_end_row: int, other_end_column: int):
+        self.life = life
+        self.other_end_row = other_end_row
+        self.other_end_column = other_end_column
+
+    def char(self) -> str:
+        return 'O'
+
+    def action(self) -> Action:
+        return TeleportAction(self.other_end_row, self.other_end_column)
 
 class GameArea:
-    def __init__(self, *, win: "curses window", highscore_file: str):
+    def __init__(self, *, win: "curses window", highscore_file: str, players: int):
         self.win = win
         self.height, self.width = win.getmaxyx()
         self.tiles = [
-            [TileContents.Wall for _ in range(self.width)],
-            *[[TileContents.Wall] + [TileContents.Empty for _ in range(self.width - 2)] + [TileContents.Wall] for _ in range(self.height - 3)],
-            [TileContents.Wall for _ in range(self.width)],
+            [WallTile() for _ in range(self.width)],
+            *[[WallTile()] + [EmptyTile() for _ in range(self.width - 2)] + [WallTile()] for _ in range(self.height - 3)],
+            [WallTile() for _ in range(self.width)],
         ]
-        self.snake = [(self.height // 2, self.width // 2)]
-        self.fruit = []
-        self.tiles[self.snake[0][0]][self.snake[0][1]] = TileContents.Snake
-        self.dir = Direction.Right
-        self.turbo = 0
+        self.frame = 0
+        player_layout = {
+            1: [0],
+            2: [1, 0],
+            3: [1, 2, 0],
+            4: [1, 3, 2, 0],
+        }[players]
+        self.snakes = [
+            Snake(
+                [(
+                    self.height // 2,
+                    (self.width * (1+player_layout[i]*2)) // 2 // players
+                )],
+                color=curses.color_pair(i+1),
+                dir=[Direction.Left, Direction.Right][i%2]
+            )
+            for i in range(players)
+        ]
+        for snake in self.snakes:
+            for row, column in snake.segments:
+                self.tiles[row][column] = SnakeTile(snake)
         self.random = random.Random()
         self.highscore_file = highscore_file
         try:
@@ -74,38 +220,37 @@ class GameArea:
         self.add_fruit()
 
     def score(self):
-        return len(self.snake) - 1
+        return max(snake.len() - 1 for snake in self.snakes)
 
     def paint(self):
         for i, row in enumerate(self.tiles):
             for j, tile in enumerate(row):
-                self.win.addch(i, j, tile.char(turbo=self.turbo))
+                self.win.addch(i, j, tile.char(), tile.color())
         self.win.addstr(i + 1, 2, f'{self.highscore:04}')
-        self.win.addstr(i + 1, j - 6, f'{self.score():04}')
+        self.win.addstr(i + 1, j - 1 - 5 * len(self.snakes), ' '.join(f'{snake.len() - 1:04}' for snake in self.snakes))
         self.win.refresh()
     
     def getch(self):
         ch = self.win.getch()
-        if ch == curses.KEY_LEFT or ch == ord('h'):
-            if self.dir == Direction.Right:
-                curses.beep()
-            else:
-                self.dir = Direction.Left
-        if ch == curses.KEY_RIGHT or ch == ord('l'):
-            if self.dir == Direction.Left:
-                curses.beep()
-            else:
-                self.dir = Direction.Right
-        if ch == curses.KEY_UP or ch == ord('k'):
-            if self.dir == Direction.Down:
-                curses.beep()
-            else:
-                self.dir = Direction.Up
-        if ch == curses.KEY_DOWN or ch == ord('j'):
-            if self.dir == Direction.Up:
-                curses.beep()
-            else:
-                self.dir = Direction.Down
+        if ch == curses.KEY_LEFT: self.snakes[0].set_dir(Direction.Left)
+        if ch == curses.KEY_RIGHT: self.snakes[0].set_dir(Direction.Right)
+        if ch == curses.KEY_UP: self.snakes[0].set_dir(Direction.Up)
+        if ch == curses.KEY_DOWN: self.snakes[0].set_dir(Direction.Down)
+        if len(self.snakes) >= 2:
+            if ch == ord('a'): self.snakes[1].set_dir(Direction.Left)
+            if ch == ord('d'): self.snakes[1].set_dir(Direction.Right)
+            if ch == ord('w'): self.snakes[1].set_dir(Direction.Up)
+            if ch == ord('s'): self.snakes[1].set_dir(Direction.Down)
+        if len(self.snakes) >= 3:
+            if ch == ord('j'): self.snakes[2].set_dir(Direction.Left)
+            if ch == ord('l'): self.snakes[2].set_dir(Direction.Right)
+            if ch == ord('i'): self.snakes[2].set_dir(Direction.Up)
+            if ch == ord('k'): self.snakes[2].set_dir(Direction.Down)
+        if len(self.snakes) >= 4:
+            if ch == ord('v'): self.snakes[3].set_dir(Direction.Left)
+            if ch == ord('n'): self.snakes[3].set_dir(Direction.Right)
+            if ch == ord('g'): self.snakes[3].set_dir(Direction.Up)
+            if ch == ord('b'): self.snakes[3].set_dir(Direction.Down)
         if ch == 27 or ch == ord('p'):
             self.pause()
         if ch == ord('q'):
@@ -118,38 +263,41 @@ class GameArea:
             (r, c)
             for r, row in enumerate(self.tiles)
             for c, tile in enumerate(row)
-            if tile is TileContents.Empty
+            if tile.is_empty()
         ])
 
+    def two_random_empty_tiles(self):
+        return self.random.sample([
+            (r, c)
+            for r, row in enumerate(self.tiles)
+            for c, tile in enumerate(row)
+            if tile.is_empty()
+        ], k=2)
+
     def add_fruit(self):
-        r, c = self.random_empty_tile()
         fruit_type = self.random.choices(
-            [TileContents.Fruit, TileContents.TurboFruit, TileContents.PortalFruit],
+            ['fruit', 'turbo_fruit', 'portal_fruit'],
             weights=[0.8, 0.1, 0.1]
         )[0]
-        id = object()
-        if fruit_type is TileContents.Fruit:
-            life = 100
-        if fruit_type is TileContents.TurboFruit:
-            life = 50
-        if fruit_type is TileContents.PortalFruit:
-            life = 150
-            self.tiles[r][c] = fruit_type
-            self.fruit.append(Fruit(r, c, life, id))
+        if fruit_type == 'fruit':
+            r, c = self.random_empty_tile()
+            self.tiles[r][c] = FruitTile(life=100)
+        elif fruit_type == 'turbo_fruit':
             r, c = self.random_empty_tile()
-        self.tiles[r][c] = fruit_type
-        self.fruit.append(Fruit(r, c, life, id))
+            self.tiles[r][c] = TurboFruitTile(life=50)
+        elif fruit_type == 'portal_fruit':
+            life = 150
+            (r1, c1), (r2, c2) = self.two_random_empty_tiles()
+            self.tiles[r1][c1] = PortalFruitTile(life=150, other_end_row=r2, other_end_column=c2)
+            self.tiles[r2][c2] = PortalFruitTile(life=150, other_end_row=r1, other_end_column=c1)
 
     def decay_fruit(self):
-        for fruit in self.fruit:
-            fruit.life -= 1
-            if fruit.life <= 0:
-                self.tiles[fruit.row][fruit.column] = TileContents.Empty
-        self.fruit = [
-            fruit
-            for fruit in self.fruit
-            if fruit.life > 0
-        ]
+        for r, row in enumerate(self.tiles):
+            for c, tile in enumerate(row):
+                if tile.is_fruit():
+                    tile.life -= 1
+                    if tile.life == 0:
+                        self.tiles[r][c] = EmptyTile()
 
     def write_highscore(self):
         os.makedirs(os.path.dirname(self.highscore_file), exist_ok=True)
@@ -183,75 +331,58 @@ class GameArea:
         while True:
             ch = self.win.getch()
             if ch == 27 or ch == ord('q'):
-                break
+                raise ExitGameException()
             if ch == ord('r'):
                 raise RestartGameException()
-        raise ExitGameException()
 
     def update(self):
-        if self.dir == Direction.Left:
-            self.snake.append((self.snake[-1][0], self.snake[-1][1] - 1))
-        if self.dir == Direction.Right:
-            self.snake.append((self.snake[-1][0], self.snake[-1][1] + 1))
-        if self.dir == Direction.Up:
-            self.snake.append((self.snake[-1][0] - 1, self.snake[-1][1]))
-        if self.dir == Direction.Down:
-            self.snake.append((self.snake[-1][0] + 1, self.snake[-1][1]))
-        r, c = self.snake[-1]
-        if self.tiles[r][c] is TileContents.Fruit:
-            self.fruit = [fruit for fruit in self.fruit if (fruit.row, fruit.column) != (r, c)]
-            self.tiles[r][c] = TileContents.Snake
-        elif self.tiles[r][c] is TileContents.TurboFruit:
-            self.fruit = [fruit for fruit in self.fruit if (fruit.row, fruit.column) != (r, c)]
-            self.tiles[r][c] = TileContents.Snake
-            self.turbo = 100
-        elif self.tiles[r][c] is TileContents.PortalFruit:
-            portal_id = [fruit.id for fruit in self.fruit if (fruit.row, fruit.column) == (r, c)]
-            dest_portals = [
-                (fruit.row, fruit.column)
-                for fruit in self.fruit
-                if fruit.id in portal_id
-                    and self.tiles[fruit.row][fruit.column] is TileContents.PortalFruit
-                    and (fruit.row, fruit.column) != (r, c)
-            ]
-            self.fruit = [fruit for fruit in self.fruit if (fruit.row, fruit.column) != (r, c)]
-            if len(dest_portals) == 0:
-                self.tiles[r][c] = TileContents.Snake
-                r, c = self.snake[0]
-                self.tiles[r][c] = TileContents.Empty
-                self.snake = self.snake[1:]
-            else:
-                dest_portal = self.random.choice(dest_portals)
-                self.snake[-1] = dest_portal
-                self.tiles[r][c] = TileContents.Empty
-        elif self.tiles[r][c] is TileContents.Snake:
-            self.tiles[r][c] = TileContents.Dead
-            self.snake = self.snake[1:]
-            self.game_over()
-        elif self.tiles[r][c] is TileContents.Wall:
-            self.tiles[r][c] = TileContents.Dead
-            self.snake = self.snake[1:]
-            self.game_over()
-        else:
-            self.tiles[r][c] = TileContents.Snake
-            r, c = self.snake[0]
-            self.tiles[r][c] = TileContents.Empty
-            self.snake = self.snake[1:]
-        if self.score() > self.highscore:
-            self.highscore = self.score()
-            self.write_highscore()
-        if not self.turbo:
+        self.frame += 1
+        for snake in self.snakes:
+            if not snake.alive: continue
+            if not snake.turbo and self.frame % 2 == 0: continue
+            snake.slither()
+            r, c = snake.segments[-1]
+            action = self.tiles[r][c].action()
+            if action.is_teleport():
+                self.tiles[r][c] = EmptyTile()
+                r, c = snake.segments[-1] = action.row, action.column
+            self.tiles[r][c] = SnakeTile(snake)
+            if action.is_turbo():
+                snake.turbo = 100
+            if not action.is_score():
+                r0, c0 = snake.segments[0]
+                self.tiles[r0][c0] = EmptyTile()
+                snake.shrink()
+            if action.is_death():
+                self.tiles[r][c] = DeadTile()
+                snake.alive = False
+                if not any(snake.alive for snake in self.snakes):
+                    self.game_over()
+            if snake.turbo:
+                snake.turbo -= 1
+        if any(snake.turbo for snake in self.snakes):
             self.decay_fruit()
-        else:
-            self.turbo -= 1
+        elif self.frame % 2 == 0:
+            return
         if self.random.uniform(0, 1) < 0.05:
             self.add_fruit()
 
     def __str__(self) -> str:
         return '\n'.join(''.join(str(tile) for tile in row) for row in self.tiles)
 
-def setup():
+def setup(players: int):
     win = curses.initscr()
+    curses.start_color()
+    curses.use_default_colors()
+    if players == 1:
+        curses.init_pair(1, -1, -1)
+    else:
+        curses.init_pair(1, curses.COLOR_RED, -1)
+        curses.init_pair(2, curses.COLOR_GREEN, -1)
+        curses.init_pair(3, curses.COLOR_BLUE, -1)
+        curses.init_pair(4, curses.COLOR_CYAN, -1)
+        curses.init_pair(5, curses.COLOR_MAGENTA, -1)
+        curses.init_pair(6, curses.COLOR_BLACK, -1)
     curses.cbreak()
     curses.noecho()
     curses.nonl()
@@ -261,16 +392,20 @@ def setup():
     return win
 
 if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--players', '-p', default=1, type=int, help='number of players')
+    args = parser.parse_args()
+
+    if args.players < 1 or args.players > 4:
+        raise Exception('Number of players must be between 1 and 4')
+
     while True:
-        win = setup()
-        gamearea = GameArea(win=win, highscore_file=os.path.expanduser('~/.local/snake/highscore'))
+        win = setup(args.players)
+        gamearea = GameArea(win=win, highscore_file=os.path.expanduser('~/.local/snake/highscore'), players=args.players)
         try:
             while True:
                 gamearea.paint()
-                if not gamearea.turbo:
-                    sleep(0.1)
-                else:
-                    sleep(0.05)
+                sleep(0.05)
                 gamearea.getch()
                 gamearea.update()
         except ExitGameException: