From ec09dad02802b4898ecaacfb4ff2052482ffdaec Mon Sep 17 00:00:00 2001 From: japandotorg Date: Sun, 21 Jul 2024 20:05:19 +0530 Subject: [PATCH] [BatteRoyale] add exp, leveling, profile, typehint changes and gray out dead players --- battleroyale/__init__.py | 4 +- battleroyale/constants.py | 20 ++- battleroyale/core.py | 194 ++++++++++++++++-------- battleroyale/data/fonts/ACME.ttf | Bin 0 -> 21948 bytes battleroyale/data/fonts/LICENSE | 94 ++++++++++++ battleroyale/game.py | 27 ++-- battleroyale/models/_pillow.py | 245 +++++++++++++++++++++++++++++++ battleroyale/utils.py | 37 ++++- 8 files changed, 541 insertions(+), 80 deletions(-) create mode 100644 battleroyale/data/fonts/ACME.ttf create mode 100644 battleroyale/data/fonts/LICENSE create mode 100644 battleroyale/models/_pillow.py diff --git a/battleroyale/__init__.py b/battleroyale/__init__.py index 17320469..40bc965a 100644 --- a/battleroyale/__init__.py +++ b/battleroyale/__init__.py @@ -26,9 +26,7 @@ from .core import BattleRoyale -__red_end_user_data_statement__ = "This cog does not persistently store data about users." - async def setup(bot: Red) -> None: - cog = BattleRoyale(bot) + cog: BattleRoyale = BattleRoyale(bot) await bot.add_cog(cog) diff --git a/battleroyale/constants.py b/battleroyale/constants.py index e0ba35eb..805a1507 100644 --- a/battleroyale/constants.py +++ b/battleroyale/constants.py @@ -22,11 +22,25 @@ SOFTWARE. """ -from typing import Final, List +from typing import Final, List, Tuple import discord -__all__ = ("SWORDS", "PROMPTS", "WINNER_PROMPTS") +__all__: Tuple[str, ...] = ( + "EXP_MULTIPLIER", + "STARTING_EXP", + "SWORDS", + "PROMPTS", + "WINNER_PROMPTS", +) + + +MIN_EXP: Final[int] = 10 +MAX_EXP: Final[int] = 100 + + +EXP_MULTIPLIER: Final[int] = 30 +STARTING_EXP: Final[int] = 10_000 SWORDS: Final[str] = "https://cdn.discordapp.com/emojis/1123588896136106074.webp" @@ -102,7 +116,7 @@ "{emoji} | {killer} brought about the demise of {killed} with precision.", "{emoji} | {killer} enacted a deadly scheme that ended {killed}'s life.", "{emoji} | {killed}'s life was claimed by the cold grip of {killer}", - "{emoji} | {killer} sent {killer} to their eternal rest.", + "{emoji} | {killer} sent {killed} to their eternal rest.", "{emoji} | {killer} left no trace of {killed}'s existence.", "{emoji} | {killed} met a horrifying end at the hands of {killer}.", "{emoji} | {killer} unleashed unspeakable terror upon {killed}.", diff --git a/battleroyale/core.py b/battleroyale/core.py index 78225d20..bf961831 100644 --- a/battleroyale/core.py +++ b/battleroyale/core.py @@ -32,7 +32,7 @@ from io import BytesIO from pathlib import Path from types import ModuleType -from typing import Any, Dict, Final, List, Literal, Optional, Union, cast +from typing import Any, Coroutine, Dict, Final, List, Literal, Optional, Tuple, Union, cast import aiohttp import discord @@ -41,14 +41,24 @@ from redbot.core import Config, bank, commands from redbot.core.bot import Red from redbot.core.data_manager import bundled_data_path, cog_data_path -from redbot.core.utils.chat_formatting import box, humanize_list, pagify +from redbot.core.utils.chat_formatting import box, humanize_list, humanize_number, pagify from redbot.core.utils.views import SimpleMenu -from .constants import SWORDS -from .converters import EmojiConverter from .game import Game -from .utils import _cooldown, _get_attachments, exceptions, guild_roughly_chunked, truncate from .views import JoinGameView +from .converters import EmojiConverter +from .constants import SWORDS, EXP_MULTIPLIER, MIN_EXP, MAX_EXP +from .models._pillow import Canvas, Editor, Font +from .utils import ( + _cooldown, + _get_attachments, + get_exp_percentage, + exceptions, + guild_roughly_chunked, + truncate, + generate_max_exp_for_level, + maybe_update_level, +) log: logging.Logger = logging.getLogger("red.seina.battleroyale") @@ -70,52 +80,53 @@ def __init__(self, bot: Red) -> None: self.games: Dict[discord.Message, Game] = {} + self.font_path: Path = bundled_data_path(self) / "fonts" / "ACME.ttf" self.backgrounds_path: Path = bundled_data_path(self) / "backgrounds" self.custom_backgrounds_path: Path = cog_data_path(self) / "backgrounds" - self.config: Config = Config.get_conf(self, identifier=14, force_registration=True) self.log: logging.LoggerAdapter[logging.Logger] = logging.LoggerAdapter( log, {"version": self.__version__} ) - default_user: Dict[str, int] = { + self.config: Config = Config.get_conf(self, identifier=14, force_registration=True) + default_user: Dict[str, Union[int, str]] = { "games": 0, "wins": 0, "kills": 0, "deaths": 0, + "exp": 0, + "level": 1, + "bio": "I'm just a plain human.", } - default_guild: Dict[str, int] = { - "prize": 100, - } + default_guild: Dict[str, int] = {"prize": 100} default_global: Dict[str, Union[int, str, Dict[str, int]]] = { "wait": 120, "battle_emoji": "⚔️", "cooldown": 60, } - self.config.register_user(**default_user) self.config.register_guild(**default_guild) self.config.register_global(**default_global) self.cache: Dict[str, Image.Image] = {} - self._cooldown: Optional[int] = None + self._cooldown: Optional[int] = None # type: ignore for k, v in {"br": (lambda x: self), "brgame": game_tool}.items(): with suppress(RuntimeError): self.bot.add_dev_env_value(k, v) def format_help_for_context(self, ctx: commands.Context) -> str: - pre_processed = super().format_help_for_context(ctx) or "" - n = "\n" if "\n\n" not in pre_processed else "" - text = [ + pre_processed: str = super().format_help_for_context(ctx) or "" + n: str = "\n" if "\n\n" not in pre_processed else "" + text: List[str] = [ f"{pre_processed}{n}", f"Cog Version: **{self.__version__}**", f"Author: **{self.__author__}**", ] return "\n".join(text) - async def red_delete_data_for_user(self, **kwargs: Any): + async def red_delete_data_for_user(self, **kwargs: Any) -> None: """Nothing to delete.""" return @@ -126,7 +137,17 @@ async def add_stats_to_leaderboard( ) -> None: for user in users: count = await self.config.user(user).get_raw(_type) - await self.config.user(user).set_raw(_type, value=count + 1) + await self.config.user(user).set_raw(_type, value=int(count) + 1) + + async def add_exp_and_maybe_update_level(self, user: discord.User) -> None: + config: Dict[str, Union[int, str]] = await self.config.user(user).all() + _exp: int = cast(int, config["exp"]) + level: int = cast(int, config["level"]) + random_exp: int = random.randint(MIN_EXP, MAX_EXP) + await self.config.user(user).exp.set(_exp + random_exp) + max_exp_for_level: int = generate_max_exp_for_level(level, EXP_MULTIPLIER) + if (new_level := maybe_update_level(_exp + random_exp, max_exp_for_level, level)) > level: + await self.config.user(user).level.set(new_level) async def cog_load(self) -> None: self._cooldown: int = await self.config.cooldown() @@ -135,7 +156,7 @@ async def cog_load(self) -> None: async def generate_image( self, user_1: discord.Member, user_2: discord.Member, to_file: bool = True ) -> Union[discord.File, Image.Image]: - backgrounds = [ + backgrounds: List[Path] = [ self.backgrounds_path / background for background in os.listdir(self.backgrounds_path) ] if self.custom_backgrounds_path.exists(): @@ -146,24 +167,26 @@ async def generate_image( ] ) while True: - background = random.choice(backgrounds) + background: Path = random.choice(backgrounds) with open(background, mode="rb") as f: background_bytes = f.read() try: - img = Image.open(BytesIO(background_bytes)) + img: Image.Image = Image.open(BytesIO(background_bytes)) except UnidentifiedImageError: continue else: break - img = img.convert("RGBA") - avatar_1 = Image.open(BytesIO(await user_1.display_avatar.read())) - avatar_1 = avatar_1.resize((400, 400)) + img: Image.Image = img.convert("RGBA") + avatar_1: Image.Image = Image.open(BytesIO(await user_1.display_avatar.read())) + avatar_1: Image.Image = avatar_1.resize((400, 400)) img.paste( avatar_1, ((0 + 30), (int(img.height / 2) - 200), (0 + 30 + 400), (int(img.height / 2) + 200)), ) - avatar_2 = Image.open(BytesIO(await user_2.display_avatar.read())) - avatar_2 = avatar_2.resize((400, 400)) + avatar_2: Image.Image = Image.open(BytesIO(await user_2.display_avatar.read())).convert( + "L" + ) + avatar_2: Image.Image = avatar_2.resize((400, 400)) img.paste( avatar_2, ( @@ -173,15 +196,15 @@ async def generate_image( (int(img.height / 2) + 200), ), ) - swords_bytes = await self._get_content_from_url(SWORDS) - swords = Image.open(BytesIO(swords_bytes)) - swords = swords.convert("RGBA") + swords_bytes: Image.Image = await self._get_content_from_url(SWORDS) + swords: Image.Image = Image.open(BytesIO(swords_bytes)) + swords: Image.Image = swords.convert("RGBA") for i in range(swords.width): for j in range(swords.height): - r, g, b, a = swords.getpixel((i, j)) + r, g, b, a = cast(Tuple[float, ...], swords.getpixel((i, j))) if r == 0 and g == 0 and b == 0: swords.putpixel((i, j), (r, g, b, 0)) - swords = swords.resize((300, 300)) + swords: Image.Image = swords.resize((300, 300)) img.paste( swords, ( @@ -194,11 +217,57 @@ async def generate_image( ) if not to_file: return img - buffer = BytesIO() + buffer: BytesIO = BytesIO() img.save(buffer, format="PNG", optimize=True) buffer.seek(0) return discord.File(buffer, filename="image.png") + @exceptions + async def generate_profile( + self, user: discord.Member, *, to_file: bool = True + ) -> Union[Editor, discord.File]: + config: Dict[str, Union[str, int]] = await self.config.user(user).all() + background: Editor = Editor(Canvas((800, 240), color="#2F3136")) + profile: Editor = Editor(BytesIO(await user.display_avatar.read())).resize((200, 200)) + f40, f25, f20 = ( + Font(self.font_path, size=40), + Font(self.font_path, size=25), + Font(self.font_path, size=20), + ) + background.paste(profile, (20, 20)) + background.text((240, 20), user.global_name, font=f40, color="white") + background.text((240, 80), config["bio"], font=f20, color="white") + background.text((250, 170), "Wins", font=f25, color="white") + background.text((310, 155), config["wins"], font=f40, color="white") + background.rectangle((390, 170), 360, 25, outline="white", stroke_width=2) + max_exp: int = generate_max_exp_for_level(config["level"], EXP_MULTIPLIER) + background.bar( + (394, 174), + 352, + 17, + percentage=get_exp_percentage(config["exp"], max_exp), + fill="white", + stroke_width=2, + ) + background.text( + (390, 135), + "Level: {}".format(humanize_number(cast(int, config["level"]))), + font=f25, + color="white", + ) + background.text( + (750, 135), + "XP: {} / {}".format( + humanize_number(cast(int, config["exp"])), humanize_number(max_exp) + ), + font=f25, + color="white", + align="right", + ) + if not to_file: + return background + return discord.File(background.image_bytes, filename="profile.png") + async def _get_content_from_url(self, url: str) -> Image.Image: if url in self.cache: return self.cache[url] @@ -368,7 +437,10 @@ async def _settings(self, ctx: commands.Context): @commands.bot_has_permissions(embed_links=True) @commands.group(aliases=["br"], invoke_without_command=True) async def battleroyale( - self, ctx: commands.Context, delay: commands.Range[int, 10, 20] = 10, skip: bool = False + self, + ctx: commands.GuildContext, + delay: commands.Range[int, 10, 20] = 10, + skip: bool = False, ): """ Battle Royale with other members! @@ -398,7 +470,7 @@ async def battleroyale( embed.description = ( f"Not enough players to start. (need at least 3, {len(players)} found)." ) - self.battleroyale.reset_cooldown(ctx) + cast(commands.Command, self.battleroyale).reset_cooldown(ctx) with contextlib.suppress(discord.NotFound, discord.HTTPException): return await join_view._message.edit(embed=embed, view=None) @@ -406,10 +478,10 @@ async def battleroyale( self.games[join_view._message] = game await game.start(ctx, players=players, original_message=join_view._message) - @battleroyale.command() + @cast(commands.Group, battleroyale).command() async def auto( self, - ctx: commands.Context, + ctx: commands.GuildContext, players: commands.Range[int, 10, 100] = 30, delay: commands.Range[int, 10, 20] = 10, skip: bool = False, @@ -442,10 +514,10 @@ async def auto( self.games[message] = game await game.start(ctx, players=players, original_message=message) - @battleroyale.command() + @cast(commands.Group, battleroyale).command() async def role( self, - ctx: commands.Context, + ctx: commands.GuildContext, role: discord.Role, delay: commands.Range[int, 10, 20] = 10, skip: bool = False, @@ -493,33 +565,37 @@ async def role( self.games[message] = game await game.start(ctx, players=players, original_message=message) - @battleroyale.command(name="profile", aliases=["stats"]) - async def profile(self, ctx: commands.Context, *, user: Optional[discord.Member] = None): + @cast(commands.Group, battleroyale).group( + name="profile", aliases=["stats"], invoke_without_command=True + ) + async def profile(self, ctx: commands.GuildContext, *, user: Optional[discord.Member] = None): """ Show your battle royale profile. + + - Use the `[p]br profile bio ` command to change the bio. """ - user = user or ctx.author - data = await self.config.user(user).all() - embed: discord.Embed = discord.Embed( - title=f"{user.display_name}'s Profile", - description=( - box( - ( - f"Games : {data['games']} \n" - f"Wins : {data['wins']} \n" - f"Kills : {data['kills']} \n" - f"Deaths : {data['deaths']} \n" - ), - lang="prolog", - ) - ), - color=await ctx.embed_color(), - ) - await ctx.send(embed=embed) + if not ctx.invoked_subcommand: + user = user or cast(discord.Member, ctx.author) + file: Coroutine[Any, Any, discord.File] = await asyncio.to_thread( + self.generate_profile, user=user + ) + await ctx.send( + files=[await file], + reference=ctx.message.to_reference(fail_if_not_exists=False), + allowed_mentions=discord.AllowedMentions(replied_user=False), + ) + + @profile.command(name="bio", aliases=["setbio", "bioset"]) + async def bio(self, ctx: commands.GuildContext, *, message: commands.Range[str, 1, 25]): + """ + Change your default bio. + """ + await self.config.user(ctx.author).bio.set(message) + await ctx.send("Bio changed to '{}'.".format(message)) - @battleroyale.command(name="leaderboard", aliases=["lb"]) + @cast(commands.Group, battleroyale).command(name="leaderboard", aliases=["lb"]) async def _leaderboard( - self, ctx: commands.Context, sort_by: Literal["wins", "games", "kills"] = "wins" + self, ctx: commands.GuildContext, sort_by: Literal["wins", "games", "kills"] = "wins" ): """Show the leaderboard. diff --git a/battleroyale/data/fonts/ACME.ttf b/battleroyale/data/fonts/ACME.ttf new file mode 100644 index 0000000000000000000000000000000000000000..affb1a3155f3ba6319fdae822e70b45862a573d8 GIT binary patch literal 21948 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 None: - self.cog: commands.Cog = cog - self.ctx: commands.Context = None + def __init__(self, cog: "BattleRoyale", delay: int = 10, skip: bool = False) -> None: + self.cog: "BattleRoyale" = cog + self.ctx: commands.Context = None # type: ignore self.delay: int = delay self.skip: bool = skip self.players: List[discord.Member] = [] self.messages: List[discord.Message] = [] - self.original_message: discord.Message = None + self.original_message: discord.Message = None # type: ignore @exceptions async def start( @@ -106,7 +110,6 @@ async def start( self.messages.append(original_message) self.original_message: discord.Message = await self.ctx.send(embed=embed) await self.cog.add_stats_to_leaderboard("games", users=self.players) - places: List[discord.Member] = [] kills: Counter = Counter() prompts = "" @@ -120,7 +123,6 @@ async def start( places.insert(0, killed) await self.cog.add_stats_to_leaderboard("kills", users=[killer]) await self.cog.add_stats_to_leaderboard("deaths", users=[killed]) - if not self.skip: custom_emoji: Optional[discord.Emoji] = self.ctx.bot.get_emoji( 1163151336024588368 @@ -133,8 +135,8 @@ async def start( ) if len(self.players) <= 30 or len(self.remaining_players) <= 2 or i >= 2: start = time.time() - image: discord.File = await self.cog.generate_image( - user_1=killer, user_2=killed, to_file=True + image: Coroutine[Any, Any, discord.File] = await asyncio.to_thread( + self.cog.generate_image, user_1=killer, user_2=killed, to_file=True ) end = time.time() delay = self.delay - (end - start) @@ -153,12 +155,14 @@ async def start( ) if EDIT_ORIGINAL_MESSAGE: _message = await self.original_message.edit( - embed=embed, attachments=[image], view=_view + embed=embed, attachments=[await image], view=_view ) _view._message = _message else: - _message = await self.ctx.send(embed=embed, files=[image], view=_view) + _message = await self.ctx.send( + embed=embed, files=[await image], view=_view + ) _view._message = _message prompts = "" i = 0 @@ -193,4 +197,5 @@ async def start( else: await self.ctx.send(embed=embed) await self.cog.add_stats_to_leaderboard("wins", users=[winner]) + await self.cog.add_exp_and_maybe_update_level(winner) return winner diff --git a/battleroyale/models/_pillow.py b/battleroyale/models/_pillow.py new file mode 100644 index 00000000..6abf0560 --- /dev/null +++ b/battleroyale/models/_pillow.py @@ -0,0 +1,245 @@ +""" +MIT License + +Copyright (c) 2023-present japandotorg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +# much of this code has been taken from https://github.com/shahriyardx/easy-pil/tree/master + +from io import BytesIO +from pathlib import Path +import io +from typing import Any, Dict, Literal, Optional, Tuple, Union + +from PIL import Image, ImageFont, ImageDraw, _typing + + +Color = Union[int, str, Tuple[int, int, int], Tuple[int, int, int, int]] + + +class Font: + def __init__(self, path: str, size: int = 10, **kwargs: Any) -> None: + self._font: ImageFont.FreeTypeFont = ImageFont.truetype(path, size=size, **kwargs) + + @property + def font(self) -> ImageFont.FreeTypeFont: + return self._font + + def getsize(self, text: str) -> Tuple[float, float]: + bbox: Tuple[float, float, float, float] = self.font.getbbox(text) + return bbox[2], bbox[3] + + +class Text: + def __init__( + self, text: str, font: Union[ImageFont.FreeTypeFont, Font], color: Color = "black" + ) -> None: + self.text: str = text + self.color: Color = color + if isinstance(font, Font): + self.font: ImageFont.FreeTypeFont = font.font + else: + self.font: ImageFont.FreeTypeFont = font + + def getsize(self) -> Tuple[float, float]: + bbox: Tuple[float, float, float, float] = self.font.getbbox(self.text) + return bbox[2], bbox[3] + + +class Canvas: + def __init__( + self, + size: Optional[Tuple[int, int]] = None, + width: int = 0, + height: int = 0, + color: Color = 0, + ) -> None: + if not (size or (width and height)): + raise ValueError( + "Expecting size or width & height, got {} & {} instead.".format( + type(size), "{}:{}".format(type(width), type(height)) + ) + ) + elif not size: + size: Tuple[int, int] = (width, height) + self.size: Tuple[int, int] = size + self.color: Color = color + self.image: Image.Image = Image.new("RGBA", size, color=color) + + +class Editor: + def __init__(self, _image: Union[Image.Image, str, BytesIO, "Editor", Canvas, Path]) -> None: + if isinstance(_image, (str, BytesIO, Path)): + self.image: Image.Image = Image.open(_image) + elif isinstance(_image, (Canvas, Editor)): + self.image: Image.Image = _image.image + elif isinstance(_image, Image): + self.image: Image.Image = _image + else: + raise ValueError( + "Editor requires an Image, Path, Editor or Canvas, recieved {} instead.".format( + type(_image) + ) + ) + self.image: Image.Image = self.image.convert("RGBA") + + @property + def image_bytes(self) -> io.BytesIO: + _bytes: io.BytesIO = io.BytesIO() + self.image.save(_bytes, "png", optimize=True) + _bytes.seek(0) + return _bytes + + def show(self) -> None: + self.image.show() + + def save( + self, fp: _typing.StrOrBytesPath, file_format: Optional[str] = None, **params: Any + ) -> None: + self.image.save(fp, file_format, **params) + + def resize(self, size: Tuple[int, int], crop: bool = False) -> "Editor": + if not crop: + self.image = self.image.resize(size, Image.Resampling.LANCZOS) + else: + width, height = self.image.size + ideal_width, ideal_height = size + aspect = width / height + ideal_aspect = ideal_width / ideal_height + if aspect > ideal_aspect: + new_width = ideal_aspect * height + offset = int((width - new_width) / 2) + resize = (offset, 0, width - offset, height) + else: + new_height = width / ideal_aspect + offset = int((height - new_height) / 2) + resize = (0, offset, width, height - offset) + self.image = self.image.crop(resize).resize( + (ideal_width, ideal_height), Image.Resampling.LANCZOS + ) + return self + + def paste( + self, image: Union[Image.Image, "Editor", Canvas], position: Tuple[int, int] + ) -> "Editor": + blank: Image.Image = Image.new("RGBA", size=self.image.size, color=(255, 255, 255, 0)) + if isinstance(image, Editor) or isinstance(image, Canvas): + image: Image.Image = image.image + blank.paste(image, position) + self.image = Image.alpha_composite(self.image, blank) + blank.close() + return self + + def text( + self, + position: Tuple[float, float], + text: str, + font: Optional[Union[ImageFont.FreeTypeFont, Font]] = None, + color: Color = "black", + align: Literal["left", "center", "right"] = "left", + stroke_width: Optional[int] = None, + stroke_fill: Color = "black", + ) -> "Editor": + if isinstance(font, Font): + font: ImageFont.FreeTypeFont = font.font + anchors: Dict[str, str] = {"left": "lt", "center": "mt", "right": "rt"} + draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.image) + if stroke_width: + draw.text( + position, + text, + color, + font=font, + anchor=anchors[align], + stroke_width=stroke_width, + stroke_fill=stroke_fill, + ) + else: + draw.text(position, text, color, font=font, anchor=anchors[align]) + return self + + def rectangle( + self, + position: Tuple[float, float], + width: float, + height: float, + fill: Optional[Color] = None, + color: Optional[Color] = None, + outline: Optional[Color] = None, + stroke_width: float = 1, + radius: int = 0, + ) -> "Editor": + draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.image) + to_width: float = width + position[0] + to_height: float = height + position[1] + if color: + fill: Color = color + if radius <= 0: + draw.rectangle( + position + (to_width, to_height), + fill=fill, + outline=outline, + width=stroke_width, + ) + else: + draw.rounded_rectangle( + position + (to_width, to_height), + radius=radius, + fill=fill, + outline=outline, + width=stroke_width, + ) + return self + + def bar( + self, + position: Tuple[float, float], + max_width: Union[int, float], + height: Union[int, float], + percentage: int = 1, + fill: Optional[Color] = None, + color: Optional[Color] = None, + outline: Optional[Color] = None, + stroke_width: float = 1, + radius: int = 0, + ) -> "Editor": + draw: ImageDraw.ImageDraw = ImageDraw.Draw(self.image) + if color: + fill: Color = color + ratio: float = max_width / 100 + to_width: float = ratio * percentage + position[0] + height: float = height + position[1] + if radius <= 0: + draw.rectangle( + position + (to_width, height), + fill=fill, + outline=outline, + width=stroke_width, + ) + else: + draw.rounded_rectangle( + position + (to_width, height), + radius=radius, + fill=fill, + outline=outline, + width=stroke_width, + ) + return self diff --git a/battleroyale/utils.py b/battleroyale/utils.py index cf2cae9e..9406acf8 100644 --- a/battleroyale/utils.py +++ b/battleroyale/utils.py @@ -24,23 +24,48 @@ import functools import logging -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union, cast import discord from redbot.core import commands from typing_extensions import ParamSpec +from .constants import STARTING_EXP + P = ParamSpec("P") log: logging.Logger = logging.getLogger("red.seina.battleroyale.utils") __all__ = ( + "Emoji", "exceptions", "_get_attachments", "_cooldown", ) +def generate_max_exp_for_level(level: int, increase: int, start: int = STARTING_EXP) -> int: + if level <= 0: + raise ValueError("Level must be greater than 0.") + exp: float = start + for _ in range(1, level): + exp *= 1 + increase / 100 + return int(exp) + + +def maybe_update_level(exp: int, max_exp: int, level: int) -> int: + if exp >= max_exp: + level += 1 + return level + + +def get_exp_percentage(exp: int, max_exp: int) -> float: + if max_exp <= 0: + raise ValueError("Max exp must be greater than 0.") + percentage: float = (exp / max_exp) * 100 + return percentage + + def exceptions(func: Callable[P, Any]) -> Callable[P, Any]: @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: @@ -58,7 +83,11 @@ def _get_attachments(ctx: commands.Context) -> List: if ctx.message.attachments: attachments = list(ctx.message.attachments) content.extend(attachments) - if ctx.message.reference is not None and ctx.message.reference.resolved is not None: + if ( + ctx.message.reference is not None + and ctx.message.reference.resolved is not None + and not isinstance(ctx.message.reference.resolved, discord.DeletedReferencedMessage) + ): attachments = list(ctx.message.reference.resolved.attachments) content.extend(attachments) return content @@ -93,11 +122,11 @@ def as_emoji(self) -> str: def _cooldown(ctx: commands.Context) -> Optional[commands.Cooldown]: if ctx.author.id in ctx.bot.owner_ids: return None - return commands.Cooldown(1, ctx.cog._cooldown) + return commands.Cooldown(1, ctx.cog._cooldown) # type: ignore def guild_roughly_chunked(guild: discord.Guild) -> bool: - return len(guild.members) / guild.member_count > 0.9 + return len(guild.members) / cast(int, guild.member_count) > 0.9 def truncate(text: str, *, max: int) -> str: