from __future__ import annotations from typing import TYPE_CHECKING, Optional, Union, ClassVar from io import BytesIO import asyncio import pathlib import random import re import discord from discord.ext import commands from PIL import Image, ImageDraw from .utils import * if TYPE_CHECKING: from typing_extensions import TypeAlias Coords: TypeAlias = tuple[int, int] SHIPS: dict[str, tuple[int, tuple[int, int, int]]] = { "carrier": (5, (52, 152, 219)), "battleship": (4, (246, 246, 112)), "destroyer": (3, (14, 146, 150)), "submarine": (3, (95, 245, 80)), "patrol boat": (2, (190, 190, 190)), } class Ship: def __init__( self, name: str, size: int, start: Coords, color: tuple[int, int, int], vertical: bool = False, ) -> None: self.name: str = name self.size: int = size self.start: Coords = start self.vertical: bool = vertical self.color: tuple[int, int, int] = color self.end: Coords = ( (self.start[0], self.start[1] + self.size - 1) if self.vertical else (self.start[0] + self.size - 1, self.start[1]) ) self.span: list[Coords] = ( [(self.start[0], i) for i in range(self.start[1], self.end[1] + 1)] if self.vertical else [(i, self.start[1]) for i in range(self.start[0], self.end[0] + 1)] ) self.hits: list[bool] = [False] * self.size class Board: def __init__(self, player: discord.User, random: bool = True) -> None: self.player: discord.User = player self.ships: list[Ship] = [] self.my_hits: list[Coords] = [] self.my_misses: list[Coords] = [] self.op_hits: list[Coords] = [] self.op_misses: list[Coords] = [] if random: self._place_ships() @property def moves(self) -> list[Coords]: return self.my_hits + self.my_misses def _is_valid(self, ship: Ship) -> bool: if ship.end[0] > 10 or ship.end[1] > 10: return False for existing in self.ships: if any(c in existing.span for c in ship.span): return False return True def _place_ships(self) -> None: def place_ship(ship: str, size: int, color: tuple[int, int, int]) -> None: start = random.randint(1, 10), random.randint(1, 10) vertical = bool(random.randint(0, 1)) new_ship = Ship( name=ship, size=size, start=start, vertical=vertical, color=color, ) if self._is_valid(new_ship): self.ships.append(new_ship) else: place_ship(ship, size, color) for ship, (size, color) in SHIPS.items(): place_ship(ship, size, color) def won(self) -> bool: return all(all(ship.hits) for ship in self.ships) def draw_dot( self, cur: ImageDraw.Draw, x: int, y: int, fill: Union[int, tuple[int, ...]] ) -> None: x1, y1 = x - 10, y - 10 x2, y2 = x + 10, y + 10 cur.ellipse((x1, y1, x2, y2), fill=fill) def draw_sq( self, cur: ImageDraw.Draw, x: int, y: int, *, coord: Coords, ship: Ship ) -> None: vertical = ship.vertical left_end = ship.span.index(coord) == 0 right_end = ship.span.index(coord) == ship.size - 1 if vertical and left_end: diffs = (18, 18, 25, 18) elif vertical and right_end: diffs = (25, 18, 18, 18) elif not vertical and left_end: diffs = (18, 18, 18, 25) elif not vertical and right_end: diffs = (18, 25, 18, 18) elif vertical: diffs = (25, 18, 25, 18) else: diffs = (18, 25, 18, 25) d1, d2, d3, d4 = diffs x1, y1 = x - d1, y - d2 x2, y2 = x + d3, y + d4 cur.rounded_rectangle((x1, y1, x2, y2), radius=5, fill=ship.color) def get_ship(self, coord: Coords) -> Optional[Ship]: if s := [ship for ship in self.ships if coord in ship.span]: return s[0] @executor() def to_image(self, hide: bool = False) -> BytesIO: RED = (255, 0, 0) GRAY = (128, 128, 128) with Image.open(pathlib.Path(__file__).parent / "assets/battleship.png") as img: cur = ImageDraw.Draw(img) for i, y in zip(range(1, 11), range(75, 530, 50)): for j, x in zip(range(1, 11), range(75, 530, 50)): coord = (i, j) if coord in self.op_misses: self.draw_dot(cur, x, y, fill=GRAY) elif coord in self.op_hits: if hide: self.draw_dot(cur, x, y, fill=RED) else: ship = self.get_ship(coord) self.draw_sq(cur, x, y, coord=coord, ship=ship) self.draw_dot(cur, x, y, fill=RED) elif ship := self.get_ship(coord): if not hide: self.draw_sq(cur, x, y, coord=coord, ship=ship) buffer = BytesIO() img.save(buffer, "PNG") buffer.seek(0) del img return buffer class BattleShip: """ BattleShip Game """ inputpat: ClassVar[re.Pattern] = re.compile(r"([a-j])(10|[1-9])") def __init__( self, player1: discord.User, player2: discord.User, *, random: bool = True, ) -> None: self.embed_color: Optional[DiscordColor] = None self.player1: discord.User = player1 self.player2: discord.User = player2 self.random: bool = random self.player1_board: Board = Board(player1, random=self.random) self.player2_board: Board = Board(player2, random=self.random) self.turn: discord.User = self.player1 self.timeout: Optional[int] = None self.message1: Optional[discord.Message] = None self.message2: Optional[discord.Message] = None def get_board(self, player: discord.User, other: bool = False) -> Board: if other: return self.player2_board if player == self.player1 else self.player1_board else: return self.player1_board if player == self.player1 else self.player2_board def place_move(self, player: discord.User, coords: Coords) -> tuple[bool, bool]: board = self.get_board(player) op_board = self.get_board(player, other=True) for i, ship in enumerate(op_board.ships): for j, coord in enumerate(ship.span): if coords == coord: op_board.ships[i].hits[j] = True board.my_hits.append(coords) op_board.op_hits.append(coords) return all(op_board.ships[i].hits), True board.my_misses.append(coords) op_board.op_misses.append(coords) return False, False async def get_file( self, player: discord.User, *, hide: bool = True ) -> tuple[discord.Embed, discord.File, discord.Embed, discord.File]: board = self.get_board(player) image1 = await board.to_image() board2 = self.get_board(player, other=True) image2 = await board2.to_image(hide=hide) file1 = discord.File(image1, "board1.png") file2 = discord.File(image2, "board2.png") embed1 = discord.Embed(color=self.embed_color) embed2 = discord.Embed(color=self.embed_color) embed1.set_image(url="attachment://board1.png") embed2.set_image(url="attachment://board2.png") return embed1, file1, embed2, file2 def to_num(self, alpha: str) -> int: return ord(alpha) % 96 def get_coords(self, inp: str) -> tuple[str, Coords]: inp = re.sub(r"\s+", "", inp).lower() match = self.inputpat.match(inp) x, y = match.group(1), match.group(2) return (inp, (self.to_num(x), int(y))) def who_won(self) -> Optional[discord.User]: if self.player1_board.won(): return self.player2 elif self.player2_board.won(): return self.player1 else: return None async def get_ship_inputs( self, ctx: commands.Context[commands.Bot], user: discord.User ) -> bool: board = self.get_board(user) async def place_ship(ship: str, size: int, color: tuple[int, int, int]) -> bool: embed, file, _, _ = await self.get_file(user) await user.send( f"Where do you want to place your `{ship}`?\nSend the start coordinate... e.g. (`a1`)", embed=embed, file=file, ) def check(msg: discord.Message) -> bool: if not msg.guild and msg.author == user: content = re.sub(r"\s+", "", message.content).lower() return bool(self.inputpat.match(content)) try: message: discord.Message = await ctx.bot.wait_for( "message", check=check, timeout=self.timeout ) except asyncio.TimeoutError: await user.send( f"The timeout of {self.timeout} seconds, has been reached. Aborting..." ) return False _, start = self.get_coords(message.content) await user.send("Do you want it to be vertical?\nSay `yes` or `no`") def check(msg: discord.Message) -> bool: if not msg.guild and msg.author == user: content = msg.content.replace(" ", "").lower() return content in ("yes", "no") try: message: discord.Message = await ctx.bot.wait_for( "message", check=check, timeout=self.timeout ) except asyncio.TimeoutError: await user.send( f"The timeout of {self.timeout} seconds, has been reached. Aborting..." ) return False vertical = message.content.replace(" ", "").lower() != "yes" new_ship = Ship( name=ship, size=size, start=start, vertical=vertical, color=color, ) if board._is_valid(new_ship): board.ships.append(new_ship) else: await user.send("That is a not a valid location, please try again") await place_ship(ship, size, color) for ship, (size, color) in SHIPS.items(): await place_ship(ship, size, color) await user.send("All setup! (Game will soon start after the opponent finishes)") return True async def start( self, ctx: commands.Context[commands.Bot], *, timeout: Optional[float] = None ) -> tuple[discord.Message, discord.Message]: """ starts the battleship game Parameters ---------- ctx : commands.Context the context of the invokation command timeout : Optional[float], optional the timeout for when waiting, by default None Returns ------- tuple[discord.Message, discord.Message] returns both player's messages respectively """ await ctx.send("**Game Started!**\nI've setup the boards in your dms!") if not self.random: await asyncio.gather( self.get_ship_inputs(ctx, self.player1), self.get_ship_inputs(ctx, self.player2), ) _, f1, _, f2 = await self.get_file(self.player1) _, f3, _, f4 = await self.get_file(self.player2) self.message1 = await self.player1.send("**Game starting!**", files=[f2, f1]) self.message2 = await self.player2.send("**Game starting!**", files=[f4, f3]) self.timeout = timeout while not ctx.bot.is_closed(): def check(msg: discord.Message) -> bool: if not msg.guild and msg.author == self.turn: content = msg.content.replace(" ", "").lower() return bool(self.inputpat.match(content)) try: message: discord.Message = await ctx.bot.wait_for( "message", check=check, timeout=self.timeout ) except asyncio.TimeoutError: await ctx.send( f"The timeout of {timeout} seconds, has been reached. Aborting..." ) break raw, coords = self.get_coords(message.content) if coords in self.get_board(self.turn): await self.turn.send("You've attacked this coordinate before!") else: sunk, hit = self.place_move(self.turn, coords) next_turn: discord.User = ( self.player2 if self.turn == self.player1 else self.player1 ) if hit and sunk: await self.turn.send( f"`{raw}` was a hit!, you also sank one of their ships! :)" ) await next_turn.send( f"They went for `{raw}`, and it was a hit!\nOne of your ships also got sunk! :(" ) elif hit: await self.turn.send(f"`{raw}` was a hit :)") await next_turn.send(f"They went for `{raw}`, and it was a hit! :(") else: await self.turn.send(f"`{raw}` was a miss :(") await next_turn.send( f"They went for `{raw}`, and it was a miss! :)" ) _, f1, _, f2 = await self.get_file(self.player1) _, f3, _, f4 = await self.get_file(self.player2) await self.player1.send(files=[f2, f1]) await self.player2.send(files=[f4, f3]) self.turn = next_turn if winner := self.who_won(): await winner.send("Congrats, you won! :)") other = self.player2 if winner == self.player1 else self.player1 await other.send("You lost, better luck next time :(") break return self.message1, self.message2