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 00000000..affb1a31 Binary files /dev/null and b/battleroyale/data/fonts/ACME.ttf differ diff --git a/battleroyale/data/fonts/LICENSE b/battleroyale/data/fonts/LICENSE new file mode 100644 index 00000000..c2d2dfe1 --- /dev/null +++ b/battleroyale/data/fonts/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2011, Juan Pablo del Peral (juan@huertatipografica.com.ar), +with Reserved Font Names "Acme" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/battleroyale/game.py b/battleroyale/game.py index e8313b1d..a7e7aecc 100644 --- a/battleroyale/game.py +++ b/battleroyale/game.py @@ -26,7 +26,7 @@ import random import time from collections import Counter -from typing import List, Optional, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Coroutine, List, Optional, Protocol, TypeVar import discord from redbot.core import bank, commands @@ -37,6 +37,10 @@ from .utils import exceptions from .views import RemainingPlayerView +if TYPE_CHECKING: + from .core import BattleRoyale + + T = TypeVar("T", covariant=True) EDIT_ORIGINAL_MESSAGE = False @@ -57,16 +61,16 @@ async def start( class Game(GameBase): - def __init__(self, cog: commands.Cog, delay: int = 10, skip: bool = False) -> 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: