389 lines
13 KiB
Python
389 lines
13 KiB
Python
# ╔══════════════════════════════════════════════════════════════════╗
|
|
# ║ ║
|
|
# ║ +-+-+-+-+-+-+-+-+ ║
|
|
# ║ |H|e|x|a|H|o|s|t| ║
|
|
# ║ +-+-+-+-+-+-+-+-+ ║
|
|
# ║ ║
|
|
# ║ © 2026 HexaHost — All Rights Reserved ║
|
|
# ║ ║
|
|
# ║ discord ── https://discord.gg/hexahost ║
|
|
# ║ github ── https://github.com/theoneandonlymace ║
|
|
# ║ ║
|
|
# ╚══════════════════════════════════════════════════════════════════╝
|
|
|
|
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
|