first commit
This commit is contained in:
375
bot/games/twenty_48.py
Normal file
375
bot/games/twenty_48.py
Normal file
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
from io import BytesIO
|
||||
import os
|
||||
import asyncio
|
||||
import random
|
||||
import pathlib
|
||||
import itertools
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from utils.emoji import ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ARROW_DOWN, STOP_BUTTON
|
||||
|
||||
from .utils import *
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
Board: TypeAlias = list[list[int]]
|
||||
|
||||
|
||||
async def create_2048_emojis(
|
||||
guild: discord.Guild, names: Optional[list[str]] = None
|
||||
) -> list[discord.Emoji]:
|
||||
"""
|
||||
creates 2048 emojis in the specified Guild
|
||||
intended to be ran once initially manually.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild : discord.Guild
|
||||
the guild to create the emojis in
|
||||
names : Optional[list[str]], optional
|
||||
names to use for the emojis
|
||||
if not specified, _<number> will be used, by default None
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Emoji]
|
||||
returns the list of emojis created
|
||||
"""
|
||||
result: list[discord.Emoji] = []
|
||||
|
||||
directory = pathlib.Path(__file__).parent / "assets/2048-emoji-asset-examples"
|
||||
files = os.listdir(directory)
|
||||
names = map(lambda n: f"_{n[:-4]}", files) if not names else names
|
||||
|
||||
for name, file in zip(names, files):
|
||||
with open(os.path.join(directory, file), "rb") as fp:
|
||||
result.append(
|
||||
await guild.create_custom_emoji(
|
||||
name=name, image=fp.read(), reason="2048 emojis"
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class Twenty48:
|
||||
"""
|
||||
Twenty48 Game
|
||||
"""
|
||||
|
||||
player: discord.User
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
number_to_display_mapping: dict[str, str] = {},
|
||||
*,
|
||||
render_image: bool = False,
|
||||
) -> None:
|
||||
|
||||
self.embed_color: Optional[DiscordColor] = None
|
||||
self.embed: Optional[discord.Embed] = None
|
||||
|
||||
self.board: Board = [[0 for _ in range(4)] for _ in range(4)]
|
||||
self.message: Optional[discord.Message] = None
|
||||
|
||||
self._controls = [ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ARROW_DOWN]
|
||||
self._conversion = number_to_display_mapping
|
||||
self._render_image = render_image
|
||||
|
||||
if self._render_image and discord.version_info.major < 2:
|
||||
raise ValueError(
|
||||
"discord.py versions under v2.0.0 do not support rendering images since editing files is new in 2.0"
|
||||
)
|
||||
|
||||
if self._render_image:
|
||||
self._color_mapping: dict[str, tuple[tuple[int, int, int], int]] = {
|
||||
"0": ((204, 192, 179), 50),
|
||||
"2": ((237, 227, 217), 50),
|
||||
"4": ((237, 224, 200), 50),
|
||||
"8": ((242, 177, 121), 50),
|
||||
"16": ((245, 149, 100), 50),
|
||||
"32": ((246, 124, 95), 50),
|
||||
"64": ((246, 94, 59), 50),
|
||||
"128": ((236, 206, 113), 40),
|
||||
"256": ((236, 203, 96), 40),
|
||||
"512": ((236, 199, 80), 40),
|
||||
"1024": ((236, 196, 62), 30),
|
||||
"2048": ((236, 193, 46), 30),
|
||||
"4096": ((59, 57, 49), 30),
|
||||
"8192": ((59, 57, 49), 30),
|
||||
}
|
||||
|
||||
self.LIGHT_CLR = (249, 246, 242)
|
||||
self.DARK_CLR = (119, 110, 101)
|
||||
self.BG_CLR = (187, 173, 160)
|
||||
|
||||
self.BORDER_W = 20
|
||||
self.SQ_S = 100
|
||||
self.SPACE_W = 15
|
||||
|
||||
self.IMG_LENGTH = self.BORDER_W * 2 + self.SQ_S * 4 + self.SPACE_W * 3
|
||||
|
||||
self._font = ImageFont.truetype(
|
||||
str(pathlib.Path(__file__).parent / "assets/ClearSans-Bold.ttf"), 50
|
||||
)
|
||||
|
||||
def _reverse(self, board: Board) -> Board:
|
||||
return [row[::-1] for row in board]
|
||||
|
||||
def _transp(self, board: Board) -> Board:
|
||||
return [[board[i][j] for i in range(4)] for j in range(4)]
|
||||
|
||||
def _merge(self, board: Board) -> Board:
|
||||
for i in range(4):
|
||||
for j in range(3):
|
||||
tile = board[i][j]
|
||||
if tile == board[i][j + 1] and tile != 0:
|
||||
board[i][j] *= 2
|
||||
board[i][j + 1] = 0
|
||||
return board
|
||||
|
||||
def _compress(self, board: Board) -> Board:
|
||||
new_board = [[0 for _ in range(4)] for _ in range(4)]
|
||||
for i in range(4):
|
||||
pos = 0
|
||||
for j in range(4):
|
||||
if board[i][j] != 0:
|
||||
new_board[i][pos] = board[i][j]
|
||||
pos += 1
|
||||
return new_board
|
||||
|
||||
def move_left(self) -> None:
|
||||
stage = self._compress(self.board)
|
||||
stage = self._merge(stage)
|
||||
stage = self._compress(stage)
|
||||
self.board = stage
|
||||
|
||||
def move_right(self) -> None:
|
||||
stage = self._reverse(self.board)
|
||||
stage = self._compress(stage)
|
||||
stage = self._merge(stage)
|
||||
stage = self._compress(stage)
|
||||
stage = self._reverse(stage)
|
||||
self.board = stage
|
||||
|
||||
def move_up(self) -> None:
|
||||
stage = self._transp(self.board)
|
||||
stage = self._compress(stage)
|
||||
stage = self._merge(stage)
|
||||
stage = self._compress(stage)
|
||||
stage = self._transp(stage)
|
||||
self.board = stage
|
||||
|
||||
def move_down(self) -> None:
|
||||
stage = self._transp(self.board)
|
||||
stage = self._reverse(stage)
|
||||
stage = self._compress(stage)
|
||||
stage = self._merge(stage)
|
||||
stage = self._compress(stage)
|
||||
stage = self._reverse(stage)
|
||||
stage = self._transp(stage)
|
||||
self.board = stage
|
||||
|
||||
def spawn_new(self) -> bool:
|
||||
"""
|
||||
spawns a new `2`
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
returns whether or not the game is lost
|
||||
"""
|
||||
board = self.board
|
||||
zeroes = [
|
||||
(j, i) for j, sub in enumerate(board) for i, el in enumerate(sub) if el == 0
|
||||
]
|
||||
|
||||
if not zeroes:
|
||||
return True
|
||||
else:
|
||||
i, j = random.choice(zeroes)
|
||||
board[i][j] = 2
|
||||
return False
|
||||
|
||||
def number_to_emoji(self) -> str:
|
||||
board = self.board
|
||||
game_string = ""
|
||||
|
||||
emoji_array = [
|
||||
[self._conversion.get(str(l), f"`{l}` ") for l in row] for row in board
|
||||
]
|
||||
|
||||
for row in emoji_array:
|
||||
game_string += "".join(row) + "\n"
|
||||
return game_string
|
||||
|
||||
def check_win(self) -> bool:
|
||||
flattened = itertools.chain(*self.board)
|
||||
|
||||
for num in (2048, 4096, 8192):
|
||||
if num in flattened:
|
||||
if num == 2048:
|
||||
self.embed = discord.Embed(description="", color=discord.Color.random())
|
||||
self.embed.description += f"⭐: Congrats! You hit **{num}**!\n"
|
||||
|
||||
if num == self.win_at:
|
||||
self.embed.description += "**Game Over! You Won**\n"
|
||||
return True
|
||||
return False
|
||||
|
||||
@executor()
|
||||
def render_image(self) -> discord.File:
|
||||
SQ = self.SQ_S
|
||||
with Image.new("RGB", (self.IMG_LENGTH, self.IMG_LENGTH), self.BG_CLR) as img:
|
||||
cursor = ImageDraw.Draw(img)
|
||||
|
||||
x = y = self.BORDER_W
|
||||
for row in self.board:
|
||||
for tile in row:
|
||||
tile = str(tile)
|
||||
color, fsize = self._color_mapping.get(tile)
|
||||
font = self._font.font_variant(size=fsize)
|
||||
cursor.rounded_rectangle(
|
||||
(x, y, x + SQ, y + SQ), radius=5, width=0, fill=color
|
||||
)
|
||||
|
||||
if tile != "0":
|
||||
text_fill = (
|
||||
self.DARK_CLR if tile in ("2", "4") else self.LIGHT_CLR
|
||||
)
|
||||
cursor.text(
|
||||
(x + SQ / 2, y + SQ / 2),
|
||||
tile,
|
||||
font=font,
|
||||
anchor="mm",
|
||||
fill=text_fill,
|
||||
)
|
||||
|
||||
x += SQ + self.SPACE_W
|
||||
x = self.BORDER_W
|
||||
y += SQ + self.SPACE_W
|
||||
|
||||
buf = BytesIO()
|
||||
img.save(buf, "PNG")
|
||||
buf.seek(0)
|
||||
return discord.File(buf, "2048.png")
|
||||
|
||||
async def start(
|
||||
self,
|
||||
ctx: commands.Context[commands.Bot],
|
||||
*,
|
||||
win_at: Literal[2048, 4096, 8192] = 8192,
|
||||
timeout: Optional[float] = None,
|
||||
remove_reaction_after: bool = False,
|
||||
delete_button: bool = False,
|
||||
embed_color: DiscordColor = DEFAULT_COLOR,
|
||||
**kwargs,
|
||||
) -> discord.Message:
|
||||
"""
|
||||
starts the 2048 game
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx : commands.Context
|
||||
the context of the invokation command
|
||||
win_at : Literal[2048, 4096, 8192], optional
|
||||
the tile to stop the game / win at, by default 8192
|
||||
timeout : Optional[float], optional
|
||||
the timeout when waiting, by default None
|
||||
remove_reaction_after : bool, optional
|
||||
specifies whether or not to remove the move reaction, by default False
|
||||
delete_button : bool, optional
|
||||
specifies whether or not to include a stop button or not, by default False
|
||||
embed_color : DiscordColor, optional
|
||||
the color of the game embed, by default DEFAULT_COLOR
|
||||
|
||||
Returns
|
||||
-------
|
||||
discord.Message
|
||||
returns the game message
|
||||
"""
|
||||
self.win_at = win_at
|
||||
self.embed_color = embed_color
|
||||
self.player = ctx.author
|
||||
|
||||
self.board[random.randrange(4)][random.randrange(4)] = 2
|
||||
self.board[random.randrange(4)][random.randrange(4)] = 2
|
||||
|
||||
if self._render_image:
|
||||
image = await self.render_image()
|
||||
self.message = await ctx.send(file=image, **kwargs)
|
||||
else:
|
||||
board_string = self.number_to_emoji()
|
||||
self.message = await ctx.send(board_string, **kwargs)
|
||||
|
||||
if delete_button:
|
||||
self._controls.append(STOP_BUTTON)
|
||||
|
||||
for button in self._controls:
|
||||
await self.message.add_reaction(button)
|
||||
|
||||
while not ctx.bot.is_closed():
|
||||
|
||||
def check(reaction: discord.Reaction, user: discord.User) -> bool:
|
||||
return (
|
||||
str(reaction.emoji) in self._controls
|
||||
and user == self.player
|
||||
and reaction.message == self.message
|
||||
)
|
||||
|
||||
try:
|
||||
reaction, user = await ctx.bot.wait_for(
|
||||
"reaction_add", timeout=timeout, check=check
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
emoji = str(reaction.emoji)
|
||||
|
||||
if delete_button and emoji == STOP_BUTTON:
|
||||
await self.message.delete()
|
||||
break
|
||||
|
||||
if emoji == ARROW_RIGHT:
|
||||
self.move_right()
|
||||
|
||||
elif emoji == ARROW_LEFT:
|
||||
self.move_left()
|
||||
|
||||
elif emoji == ARROW_DOWN:
|
||||
self.move_down()
|
||||
|
||||
elif emoji == ARROW_UP:
|
||||
self.move_up()
|
||||
|
||||
if remove_reaction_after:
|
||||
try:
|
||||
await self.message.remove_reaction(emoji, user)
|
||||
except discord.DiscordException:
|
||||
pass
|
||||
|
||||
lost = self.spawn_new()
|
||||
won = self.check_win()
|
||||
|
||||
if lost:
|
||||
self.embed = discord.Embed(
|
||||
description="Game Over! You lost.",
|
||||
color=discord.Color.random(),
|
||||
)
|
||||
|
||||
if self._render_image:
|
||||
image = await self.render_image()
|
||||
await self.message.edit(attachments=[image], embed=self.embed)
|
||||
else:
|
||||
board_string = self.number_to_emoji()
|
||||
await self.message.edit(content=board_string, embed=self.embed)
|
||||
|
||||
if won or lost:
|
||||
break
|
||||
|
||||
return self.message
|
||||
Reference in New Issue
Block a user